@lotics/cli 0.279.1 → 0.281.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 +708 -547
- package/dist/src/client.d.ts +8 -0
- package/dist/src/client.js +13 -0
- package/docs/cli_reference.md +3 -2
- package/package.json +1 -1
package/dist/src/cli.js
CHANGED
|
@@ -1197,13 +1197,13 @@ var require_ast = __commonJS({
|
|
|
1197
1197
|
helperExpression: function helperExpression(node) {
|
|
1198
1198
|
return node.type === "SubExpression" || (node.type === "MustacheStatement" || node.type === "BlockStatement") && !!(node.params && node.params.length || node.hash);
|
|
1199
1199
|
},
|
|
1200
|
-
scopedId: function scopedId(
|
|
1201
|
-
return /^\.|this\b/.test(
|
|
1200
|
+
scopedId: function scopedId(path16) {
|
|
1201
|
+
return /^\.|this\b/.test(path16.original);
|
|
1202
1202
|
},
|
|
1203
1203
|
// an ID is simple if it only has one part, and that part is not
|
|
1204
1204
|
// `..` or `this`.
|
|
1205
|
-
simpleId: function simpleId(
|
|
1206
|
-
return
|
|
1205
|
+
simpleId: function simpleId(path16) {
|
|
1206
|
+
return path16.parts.length === 1 && !AST.helpers.scopedId(path16) && !path16.depth;
|
|
1207
1207
|
}
|
|
1208
1208
|
}
|
|
1209
1209
|
};
|
|
@@ -2273,12 +2273,12 @@ var require_helpers2 = __commonJS({
|
|
|
2273
2273
|
loc
|
|
2274
2274
|
};
|
|
2275
2275
|
}
|
|
2276
|
-
function prepareMustache(
|
|
2276
|
+
function prepareMustache(path16, params, hash2, open, strip, locInfo) {
|
|
2277
2277
|
var escapeFlag = open.charAt(3) || open.charAt(2), escaped = escapeFlag !== "{" && escapeFlag !== "&";
|
|
2278
2278
|
var decorator = /\*/.test(open);
|
|
2279
2279
|
return {
|
|
2280
2280
|
type: decorator ? "Decorator" : "MustacheStatement",
|
|
2281
|
-
path:
|
|
2281
|
+
path: path16,
|
|
2282
2282
|
params,
|
|
2283
2283
|
hash: hash2,
|
|
2284
2284
|
escaped,
|
|
@@ -2596,9 +2596,9 @@ var require_compiler = __commonJS({
|
|
|
2596
2596
|
},
|
|
2597
2597
|
DecoratorBlock: function DecoratorBlock(decorator) {
|
|
2598
2598
|
var program = decorator.program && this.compileProgram(decorator.program);
|
|
2599
|
-
var params = this.setupFullMustacheParams(decorator, program, void 0),
|
|
2599
|
+
var params = this.setupFullMustacheParams(decorator, program, void 0), path16 = decorator.path;
|
|
2600
2600
|
this.useDecorators = true;
|
|
2601
|
-
this.opcode("registerDecorator", params.length,
|
|
2601
|
+
this.opcode("registerDecorator", params.length, path16.original);
|
|
2602
2602
|
},
|
|
2603
2603
|
PartialStatement: function PartialStatement(partial2) {
|
|
2604
2604
|
this.usePartial = true;
|
|
@@ -2662,46 +2662,46 @@ var require_compiler = __commonJS({
|
|
|
2662
2662
|
}
|
|
2663
2663
|
},
|
|
2664
2664
|
ambiguousSexpr: function ambiguousSexpr(sexpr, program, inverse) {
|
|
2665
|
-
var
|
|
2666
|
-
this.opcode("getContext",
|
|
2665
|
+
var path16 = sexpr.path, name = path16.parts[0], isBlock = program != null || inverse != null;
|
|
2666
|
+
this.opcode("getContext", path16.depth);
|
|
2667
2667
|
this.opcode("pushProgram", program);
|
|
2668
2668
|
this.opcode("pushProgram", inverse);
|
|
2669
|
-
|
|
2670
|
-
this.accept(
|
|
2669
|
+
path16.strict = true;
|
|
2670
|
+
this.accept(path16);
|
|
2671
2671
|
this.opcode("invokeAmbiguous", name, isBlock);
|
|
2672
2672
|
},
|
|
2673
2673
|
simpleSexpr: function simpleSexpr(sexpr) {
|
|
2674
|
-
var
|
|
2675
|
-
|
|
2676
|
-
this.accept(
|
|
2674
|
+
var path16 = sexpr.path;
|
|
2675
|
+
path16.strict = true;
|
|
2676
|
+
this.accept(path16);
|
|
2677
2677
|
this.opcode("resolvePossibleLambda");
|
|
2678
2678
|
},
|
|
2679
2679
|
helperSexpr: function helperSexpr(sexpr, program, inverse) {
|
|
2680
|
-
var params = this.setupFullMustacheParams(sexpr, program, inverse),
|
|
2680
|
+
var params = this.setupFullMustacheParams(sexpr, program, inverse), path16 = sexpr.path, name = path16.parts[0];
|
|
2681
2681
|
if (this.options.knownHelpers[name]) {
|
|
2682
2682
|
this.opcode("invokeKnownHelper", params.length, name);
|
|
2683
2683
|
} else if (this.options.knownHelpersOnly) {
|
|
2684
2684
|
throw new _exception2["default"]("You specified knownHelpersOnly, but used the unknown helper " + name, sexpr);
|
|
2685
2685
|
} else {
|
|
2686
|
-
|
|
2687
|
-
|
|
2688
|
-
this.accept(
|
|
2689
|
-
this.opcode("invokeHelper", params.length,
|
|
2686
|
+
path16.strict = true;
|
|
2687
|
+
path16.falsy = true;
|
|
2688
|
+
this.accept(path16);
|
|
2689
|
+
this.opcode("invokeHelper", params.length, path16.original, _ast2["default"].helpers.simpleId(path16));
|
|
2690
2690
|
}
|
|
2691
2691
|
},
|
|
2692
|
-
PathExpression: function PathExpression(
|
|
2693
|
-
this.addDepth(
|
|
2694
|
-
this.opcode("getContext",
|
|
2695
|
-
var name =
|
|
2692
|
+
PathExpression: function PathExpression(path16) {
|
|
2693
|
+
this.addDepth(path16.depth);
|
|
2694
|
+
this.opcode("getContext", path16.depth);
|
|
2695
|
+
var name = path16.parts[0], scoped = _ast2["default"].helpers.scopedId(path16), blockParamId = !path16.depth && !scoped && this.blockParamIndex(name);
|
|
2696
2696
|
if (blockParamId) {
|
|
2697
|
-
this.opcode("lookupBlockParam", blockParamId,
|
|
2697
|
+
this.opcode("lookupBlockParam", blockParamId, path16.parts);
|
|
2698
2698
|
} else if (!name) {
|
|
2699
2699
|
this.opcode("pushContext");
|
|
2700
|
-
} else if (
|
|
2700
|
+
} else if (path16.data) {
|
|
2701
2701
|
this.options.data = true;
|
|
2702
|
-
this.opcode("lookupData",
|
|
2702
|
+
this.opcode("lookupData", path16.depth, path16.parts, path16.strict);
|
|
2703
2703
|
} else {
|
|
2704
|
-
this.opcode("lookupOnContext",
|
|
2704
|
+
this.opcode("lookupOnContext", path16.parts, path16.falsy, path16.strict, scoped);
|
|
2705
2705
|
}
|
|
2706
2706
|
},
|
|
2707
2707
|
StringLiteral: function StringLiteral(string4) {
|
|
@@ -3051,16 +3051,16 @@ var require_util = __commonJS({
|
|
|
3051
3051
|
}
|
|
3052
3052
|
exports.urlGenerate = urlGenerate;
|
|
3053
3053
|
function normalize(aPath) {
|
|
3054
|
-
var
|
|
3054
|
+
var path16 = aPath;
|
|
3055
3055
|
var url2 = urlParse(aPath);
|
|
3056
3056
|
if (url2) {
|
|
3057
3057
|
if (!url2.path) {
|
|
3058
3058
|
return aPath;
|
|
3059
3059
|
}
|
|
3060
|
-
|
|
3060
|
+
path16 = url2.path;
|
|
3061
3061
|
}
|
|
3062
|
-
var isAbsolute = exports.isAbsolute(
|
|
3063
|
-
var parts =
|
|
3062
|
+
var isAbsolute = exports.isAbsolute(path16);
|
|
3063
|
+
var parts = path16.split(/\/+/);
|
|
3064
3064
|
for (var part, up = 0, i = parts.length - 1; i >= 0; i--) {
|
|
3065
3065
|
part = parts[i];
|
|
3066
3066
|
if (part === ".") {
|
|
@@ -3077,15 +3077,15 @@ var require_util = __commonJS({
|
|
|
3077
3077
|
}
|
|
3078
3078
|
}
|
|
3079
3079
|
}
|
|
3080
|
-
|
|
3081
|
-
if (
|
|
3082
|
-
|
|
3080
|
+
path16 = parts.join("/");
|
|
3081
|
+
if (path16 === "") {
|
|
3082
|
+
path16 = isAbsolute ? "/" : ".";
|
|
3083
3083
|
}
|
|
3084
3084
|
if (url2) {
|
|
3085
|
-
url2.path =
|
|
3085
|
+
url2.path = path16;
|
|
3086
3086
|
return urlGenerate(url2);
|
|
3087
3087
|
}
|
|
3088
|
-
return
|
|
3088
|
+
return path16;
|
|
3089
3089
|
}
|
|
3090
3090
|
exports.normalize = normalize;
|
|
3091
3091
|
function join2(aRoot, aPath) {
|
|
@@ -5868,8 +5868,8 @@ var require_printer = __commonJS({
|
|
|
5868
5868
|
return this.accept(sexpr.path) + " " + params + hash2;
|
|
5869
5869
|
};
|
|
5870
5870
|
PrintVisitor.prototype.PathExpression = function(id) {
|
|
5871
|
-
var
|
|
5872
|
-
return (id.data ? "@" : "") + "PATH:" +
|
|
5871
|
+
var path16 = id.parts.join("/");
|
|
5872
|
+
return (id.data ? "@" : "") + "PATH:" + path16;
|
|
5873
5873
|
};
|
|
5874
5874
|
PrintVisitor.prototype.StringLiteral = function(string4) {
|
|
5875
5875
|
return '"' + string4.value + '"';
|
|
@@ -5908,8 +5908,8 @@ var require_lib = __commonJS({
|
|
|
5908
5908
|
handlebars.print = printer.print;
|
|
5909
5909
|
module.exports = handlebars;
|
|
5910
5910
|
function extension(module2, filename) {
|
|
5911
|
-
var
|
|
5912
|
-
var templateString =
|
|
5911
|
+
var fs17 = __require("fs");
|
|
5912
|
+
var templateString = fs17.readFileSync(filename, "utf8");
|
|
5913
5913
|
module2.exports = handlebars.compile(templateString);
|
|
5914
5914
|
}
|
|
5915
5915
|
if (typeof __require !== "undefined" && __require.extensions) {
|
|
@@ -5924,8 +5924,8 @@ import dns from "node:dns";
|
|
|
5924
5924
|
import net from "node:net";
|
|
5925
5925
|
|
|
5926
5926
|
// src/cli_dispatch.ts
|
|
5927
|
-
import
|
|
5928
|
-
import
|
|
5927
|
+
import fs16 from "node:fs";
|
|
5928
|
+
import path15 from "node:path";
|
|
5929
5929
|
import readline from "node:readline";
|
|
5930
5930
|
|
|
5931
5931
|
// ../shared/src/multipart_parts.ts
|
|
@@ -6237,8 +6237,8 @@ var LoticsClient = class {
|
|
|
6237
6237
|
headers["x-request-id"] = newRequestId();
|
|
6238
6238
|
return headers;
|
|
6239
6239
|
}
|
|
6240
|
-
async request(method,
|
|
6241
|
-
const url2 = `${this.baseUrl}${
|
|
6240
|
+
async request(method, path16, body) {
|
|
6241
|
+
const url2 = `${this.baseUrl}${path16}`;
|
|
6242
6242
|
const headers = this.buildHeaders();
|
|
6243
6243
|
const init = { method, headers };
|
|
6244
6244
|
if (body !== void 0) {
|
|
@@ -6385,6 +6385,19 @@ var LoticsClient = class {
|
|
|
6385
6385
|
async createApp(body) {
|
|
6386
6386
|
return this.request("POST", "/v1/apps", body);
|
|
6387
6387
|
}
|
|
6388
|
+
async getApp(appId) {
|
|
6389
|
+
return this.request("GET", `/v1/apps/${encodeURIComponent(appId)}`);
|
|
6390
|
+
}
|
|
6391
|
+
/** A built app version's project source, as the `source.tar.gz` its deploy uploaded. */
|
|
6392
|
+
async downloadAppVersionSource(appId, versionId) {
|
|
6393
|
+
const { url: url2 } = await this.request(
|
|
6394
|
+
"GET",
|
|
6395
|
+
`/v1/apps/${encodeURIComponent(appId)}/versions/${encodeURIComponent(versionId)}/source`
|
|
6396
|
+
);
|
|
6397
|
+
const response = await fetch(url2);
|
|
6398
|
+
if (!response.ok) await this.throwResponseError(response);
|
|
6399
|
+
return Buffer.from(await response.arrayBuffer());
|
|
6400
|
+
}
|
|
6388
6401
|
/**
|
|
6389
6402
|
* Workspace-wide dangling-reference sweep — active app/workflow artifacts
|
|
6390
6403
|
* whose prefixed schema ids no longer resolve. Backs
|
|
@@ -8177,10 +8190,10 @@ function mergeDefs(...defs) {
|
|
|
8177
8190
|
function cloneDef(schema) {
|
|
8178
8191
|
return mergeDefs(schema._zod.def);
|
|
8179
8192
|
}
|
|
8180
|
-
function getElementAtPath(obj,
|
|
8181
|
-
if (!
|
|
8193
|
+
function getElementAtPath(obj, path16) {
|
|
8194
|
+
if (!path16)
|
|
8182
8195
|
return obj;
|
|
8183
|
-
return
|
|
8196
|
+
return path16.reduce((acc, key) => acc?.[key], obj);
|
|
8184
8197
|
}
|
|
8185
8198
|
function promiseAllObject(promisesObj) {
|
|
8186
8199
|
const keys2 = Object.keys(promisesObj);
|
|
@@ -8589,11 +8602,11 @@ function explicitlyAborted(x, startIndex = 0) {
|
|
|
8589
8602
|
}
|
|
8590
8603
|
return false;
|
|
8591
8604
|
}
|
|
8592
|
-
function prefixIssues(
|
|
8605
|
+
function prefixIssues(path16, issues) {
|
|
8593
8606
|
return issues.map((iss) => {
|
|
8594
8607
|
var _a3;
|
|
8595
8608
|
(_a3 = iss).path ?? (_a3.path = []);
|
|
8596
|
-
iss.path.unshift(
|
|
8609
|
+
iss.path.unshift(path16);
|
|
8597
8610
|
return iss;
|
|
8598
8611
|
});
|
|
8599
8612
|
}
|
|
@@ -8740,16 +8753,16 @@ function flattenError(error52, mapper = (issue2) => issue2.message) {
|
|
|
8740
8753
|
}
|
|
8741
8754
|
function formatError(error52, mapper = (issue2) => issue2.message) {
|
|
8742
8755
|
const fieldErrors = { _errors: [] };
|
|
8743
|
-
const processError = (error53,
|
|
8756
|
+
const processError = (error53, path16 = []) => {
|
|
8744
8757
|
for (const issue2 of error53.issues) {
|
|
8745
8758
|
if (issue2.code === "invalid_union" && issue2.errors.length) {
|
|
8746
|
-
issue2.errors.map((issues) => processError({ issues }, [...
|
|
8759
|
+
issue2.errors.map((issues) => processError({ issues }, [...path16, ...issue2.path]));
|
|
8747
8760
|
} else if (issue2.code === "invalid_key") {
|
|
8748
|
-
processError({ issues: issue2.issues }, [...
|
|
8761
|
+
processError({ issues: issue2.issues }, [...path16, ...issue2.path]);
|
|
8749
8762
|
} else if (issue2.code === "invalid_element") {
|
|
8750
|
-
processError({ issues: issue2.issues }, [...
|
|
8763
|
+
processError({ issues: issue2.issues }, [...path16, ...issue2.path]);
|
|
8751
8764
|
} else {
|
|
8752
|
-
const fullpath = [...
|
|
8765
|
+
const fullpath = [...path16, ...issue2.path];
|
|
8753
8766
|
if (fullpath.length === 0) {
|
|
8754
8767
|
fieldErrors._errors.push(mapper(issue2));
|
|
8755
8768
|
} else {
|
|
@@ -8776,17 +8789,17 @@ function formatError(error52, mapper = (issue2) => issue2.message) {
|
|
|
8776
8789
|
}
|
|
8777
8790
|
function treeifyError(error52, mapper = (issue2) => issue2.message) {
|
|
8778
8791
|
const result = { errors: [] };
|
|
8779
|
-
const processError = (error53,
|
|
8792
|
+
const processError = (error53, path16 = []) => {
|
|
8780
8793
|
var _a3, _b;
|
|
8781
8794
|
for (const issue2 of error53.issues) {
|
|
8782
8795
|
if (issue2.code === "invalid_union" && issue2.errors.length) {
|
|
8783
|
-
issue2.errors.map((issues) => processError({ issues }, [...
|
|
8796
|
+
issue2.errors.map((issues) => processError({ issues }, [...path16, ...issue2.path]));
|
|
8784
8797
|
} else if (issue2.code === "invalid_key") {
|
|
8785
|
-
processError({ issues: issue2.issues }, [...
|
|
8798
|
+
processError({ issues: issue2.issues }, [...path16, ...issue2.path]);
|
|
8786
8799
|
} else if (issue2.code === "invalid_element") {
|
|
8787
|
-
processError({ issues: issue2.issues }, [...
|
|
8800
|
+
processError({ issues: issue2.issues }, [...path16, ...issue2.path]);
|
|
8788
8801
|
} else {
|
|
8789
|
-
const fullpath = [...
|
|
8802
|
+
const fullpath = [...path16, ...issue2.path];
|
|
8790
8803
|
if (fullpath.length === 0) {
|
|
8791
8804
|
result.errors.push(mapper(issue2));
|
|
8792
8805
|
continue;
|
|
@@ -8818,8 +8831,8 @@ function treeifyError(error52, mapper = (issue2) => issue2.message) {
|
|
|
8818
8831
|
}
|
|
8819
8832
|
function toDotPath(_path) {
|
|
8820
8833
|
const segs = [];
|
|
8821
|
-
const
|
|
8822
|
-
for (const seg of
|
|
8834
|
+
const path16 = _path.map((seg) => typeof seg === "object" ? seg.key : seg);
|
|
8835
|
+
for (const seg of path16) {
|
|
8823
8836
|
if (typeof seg === "number")
|
|
8824
8837
|
segs.push(`[${seg}]`);
|
|
8825
8838
|
else if (typeof seg === "symbol")
|
|
@@ -21511,13 +21524,13 @@ function resolveRef(ref, ctx) {
|
|
|
21511
21524
|
if (!ref.startsWith("#")) {
|
|
21512
21525
|
throw new Error("External $ref is not supported, only local refs (#/...) are allowed");
|
|
21513
21526
|
}
|
|
21514
|
-
const
|
|
21515
|
-
if (
|
|
21527
|
+
const path16 = ref.slice(1).split("/").filter(Boolean);
|
|
21528
|
+
if (path16.length === 0) {
|
|
21516
21529
|
return ctx.rootSchema;
|
|
21517
21530
|
}
|
|
21518
21531
|
const defsKey = ctx.version === "draft-2020-12" ? "$defs" : "definitions";
|
|
21519
|
-
if (
|
|
21520
|
-
const key =
|
|
21532
|
+
if (path16[0] === defsKey) {
|
|
21533
|
+
const key = path16[1];
|
|
21521
21534
|
if (!key || !ctx.defs[key]) {
|
|
21522
21535
|
throw new Error(`Reference not found: ${ref}`);
|
|
21523
21536
|
}
|
|
@@ -25281,16 +25294,16 @@ function conditionValueIssue(type, operator, value, fieldKey) {
|
|
|
25281
25294
|
return null;
|
|
25282
25295
|
}
|
|
25283
25296
|
}
|
|
25284
|
-
function conditionIssues(node,
|
|
25297
|
+
function conditionIssues(node, path16, fieldMap) {
|
|
25285
25298
|
if (!node || typeof node !== "object") {
|
|
25286
|
-
return [`${
|
|
25299
|
+
return [`${path16}: expected condition object, got ${typeof node}`];
|
|
25287
25300
|
}
|
|
25288
25301
|
const obj = node;
|
|
25289
25302
|
if (obj.type === "locked") {
|
|
25290
25303
|
const validOps2 = VALID_OPERATORS.locked;
|
|
25291
25304
|
if (typeof obj.operator !== "string" || !validOps2.includes(obj.operator)) {
|
|
25292
25305
|
return [
|
|
25293
|
-
`${
|
|
25306
|
+
`${path16}: invalid operator '${String(obj.operator)}' for locked filter. Valid: ${validOps2.join(", ")}`
|
|
25294
25307
|
];
|
|
25295
25308
|
}
|
|
25296
25309
|
return [];
|
|
@@ -25299,12 +25312,12 @@ function conditionIssues(node, path15, fieldMap) {
|
|
|
25299
25312
|
const validOps2 = VALID_OPERATORS.current_member;
|
|
25300
25313
|
if (typeof obj.operator !== "string" || !validOps2.includes(obj.operator)) {
|
|
25301
25314
|
return [
|
|
25302
|
-
`${
|
|
25315
|
+
`${path16}: invalid operator '${String(obj.operator)}' for current_member filter. Valid: ${validOps2.join(", ")}`
|
|
25303
25316
|
];
|
|
25304
25317
|
}
|
|
25305
25318
|
if (!Array.isArray(obj.value) || obj.value.length === 0 || !obj.value.every((v) => typeof v === "string" && v.length > 0)) {
|
|
25306
25319
|
return [
|
|
25307
|
-
`${
|
|
25320
|
+
`${path16}: current_member '${obj.operator}' expects a non-empty string[] of group IDs.`
|
|
25308
25321
|
];
|
|
25309
25322
|
}
|
|
25310
25323
|
return [];
|
|
@@ -25313,38 +25326,38 @@ function conditionIssues(node, path15, fieldMap) {
|
|
|
25313
25326
|
const validOps2 = VALID_OPERATORS.record_id;
|
|
25314
25327
|
if (typeof obj.operator !== "string" || !validOps2.includes(obj.operator)) {
|
|
25315
25328
|
return [
|
|
25316
|
-
`${
|
|
25329
|
+
`${path16}: invalid operator '${String(obj.operator)}' for record_id filter. Valid: ${validOps2.join(", ")}`
|
|
25317
25330
|
];
|
|
25318
25331
|
}
|
|
25319
25332
|
if (!Array.isArray(obj.value) || obj.value.length === 0 || !obj.value.every((v) => typeof v === "string" && v.length > 0)) {
|
|
25320
|
-
return [`${
|
|
25333
|
+
return [`${path16}: record_id '${obj.operator}' expects a non-empty string[] of record ids.`];
|
|
25321
25334
|
}
|
|
25322
25335
|
return [];
|
|
25323
25336
|
}
|
|
25324
25337
|
if (typeof obj.field_key !== "string" || !obj.field_key) {
|
|
25325
|
-
return [`${
|
|
25338
|
+
return [`${path16}: field_key must be a non-empty string`];
|
|
25326
25339
|
}
|
|
25327
25340
|
let filterType;
|
|
25328
25341
|
if (fieldMap) {
|
|
25329
25342
|
filterType = fieldMap.get(obj.field_key);
|
|
25330
25343
|
if (filterType === void 0) {
|
|
25331
25344
|
const available = Array.from(fieldMap.keys()).join(", ");
|
|
25332
|
-
return [`${
|
|
25345
|
+
return [`${path16}: unknown field_key '${obj.field_key}'. Available: ${available}`];
|
|
25333
25346
|
}
|
|
25334
25347
|
} else if (typeof obj.type === "string") {
|
|
25335
25348
|
filterType = obj.type;
|
|
25336
25349
|
}
|
|
25337
25350
|
if (filterType === "button") {
|
|
25338
|
-
return [`${
|
|
25351
|
+
return [`${path16}: a button field holds no data and cannot be filtered.`];
|
|
25339
25352
|
}
|
|
25340
25353
|
if (typeof filterType !== "string" || !VALID_FILTER_TYPES.includes(filterType)) {
|
|
25341
25354
|
const named = fieldMap && filterType !== obj.type ? `'${String(filterType)}' (resolved from field '${obj.field_key}')` : `'${String(obj.type)}'`;
|
|
25342
|
-
return [`${
|
|
25355
|
+
return [`${path16}: invalid filter type ${named}. Valid: ${VALID_FILTER_TYPES.join(", ")}`];
|
|
25343
25356
|
}
|
|
25344
25357
|
const validOps = VALID_OPERATORS[filterType];
|
|
25345
25358
|
if (typeof obj.operator !== "string" || !validOps.includes(obj.operator)) {
|
|
25346
25359
|
return [
|
|
25347
|
-
`${
|
|
25360
|
+
`${path16}: invalid operator '${String(obj.operator)}' for ${filterType} filter. Valid: ${validOps.join(", ")}`
|
|
25348
25361
|
];
|
|
25349
25362
|
}
|
|
25350
25363
|
if (obj.unit_option !== void 0 && (filterType !== "number" || typeof obj.unit_option !== "string")) {
|
|
@@ -25355,8 +25368,8 @@ function conditionIssues(node, path15, fieldMap) {
|
|
|
25355
25368
|
const valueIssue = conditionValueIssue(filterType, obj.operator, obj.value, obj.field_key);
|
|
25356
25369
|
return valueIssue ? [valueIssue] : [];
|
|
25357
25370
|
}
|
|
25358
|
-
function validateFilterCondition(condition,
|
|
25359
|
-
return conditionIssues(condition,
|
|
25371
|
+
function validateFilterCondition(condition, path16 = "filter") {
|
|
25372
|
+
return conditionIssues(condition, path16, void 0);
|
|
25360
25373
|
}
|
|
25361
25374
|
function inheritedFilterType(type, format2, fallback) {
|
|
25362
25375
|
switch (type) {
|
|
@@ -25546,11 +25559,11 @@ function perRowRefusals(value) {
|
|
|
25546
25559
|
...currencyField.map((message2) => ({ key: "currency_field", message: message2 }))
|
|
25547
25560
|
];
|
|
25548
25561
|
}
|
|
25549
|
-
function figureIssue(
|
|
25562
|
+
function figureIssue(path16) {
|
|
25550
25563
|
return (value, ctx) => {
|
|
25551
25564
|
const refused = numberUnitRefusal(value.format, value.unit);
|
|
25552
|
-
if (refused !== void 0) ctx.addIssue({ code: "custom", message: refused, path: [...
|
|
25553
|
-
for (const { key, message: message2 } of perRowRefusals(value)) ctx.addIssue({ code: "custom", message: message2, path: [...
|
|
25565
|
+
if (refused !== void 0) ctx.addIssue({ code: "custom", message: refused, path: [...path16, "unit"] });
|
|
25566
|
+
for (const { key, message: message2 } of perRowRefusals(value)) ctx.addIssue({ code: "custom", message: message2, path: [...path16, key] });
|
|
25554
25567
|
};
|
|
25555
25568
|
}
|
|
25556
25569
|
var tableNumberFieldSchema = tableFieldBaseSchema.extend({
|
|
@@ -28030,9 +28043,9 @@ __export(expression_object_exports, {
|
|
|
28030
28043
|
pick: () => pick2,
|
|
28031
28044
|
values: () => values
|
|
28032
28045
|
});
|
|
28033
|
-
function getNestedValue(obj,
|
|
28046
|
+
function getNestedValue(obj, path16) {
|
|
28034
28047
|
if (obj == null) return void 0;
|
|
28035
|
-
const parts =
|
|
28048
|
+
const parts = path16.split(".");
|
|
28036
28049
|
let value = obj;
|
|
28037
28050
|
for (const part of parts) {
|
|
28038
28051
|
if (value == null) return void 0;
|
|
@@ -28071,11 +28084,11 @@ function entries(obj) {
|
|
|
28071
28084
|
}
|
|
28072
28085
|
return Object.entries(obj);
|
|
28073
28086
|
}
|
|
28074
|
-
function get(obj,
|
|
28075
|
-
if (typeof
|
|
28087
|
+
function get(obj, path16, defaultValue) {
|
|
28088
|
+
if (typeof path16 !== "string") {
|
|
28076
28089
|
throw new Error("get: path must be a string");
|
|
28077
28090
|
}
|
|
28078
|
-
const value = getNestedValue(obj,
|
|
28091
|
+
const value = getNestedValue(obj, path16);
|
|
28079
28092
|
return value !== void 0 ? value : defaultValue;
|
|
28080
28093
|
}
|
|
28081
28094
|
function pick2(obj, fields) {
|
|
@@ -31453,12 +31466,12 @@ function parseFixtureDateExpression(value) {
|
|
|
31453
31466
|
function isFixtureScalarValue(value) {
|
|
31454
31467
|
return value === null || typeof value === "string" || typeof value === "number" || typeof value === "boolean";
|
|
31455
31468
|
}
|
|
31456
|
-
function checkTraversalFilter(node,
|
|
31469
|
+
function checkTraversalFilter(node, path16, contextEntity, fieldByEntity, errors) {
|
|
31457
31470
|
if (contextEntity === null) {
|
|
31458
31471
|
errors.push({
|
|
31459
31472
|
severity: "error",
|
|
31460
31473
|
rule: "model.filter",
|
|
31461
|
-
path:
|
|
31474
|
+
path: path16,
|
|
31462
31475
|
message: `traversal filter "${node.path[0]}" resolves only directly over a from_table`
|
|
31463
31476
|
});
|
|
31464
31477
|
return;
|
|
@@ -31470,7 +31483,7 @@ function checkTraversalFilter(node, path15, contextEntity, fieldByEntity, errors
|
|
|
31470
31483
|
errors.push({
|
|
31471
31484
|
severity: "error",
|
|
31472
31485
|
rule: "model.filter",
|
|
31473
|
-
path:
|
|
31486
|
+
path: path16,
|
|
31474
31487
|
message: `traversal path[${index}] "${hop}" is not an entity.field alias`
|
|
31475
31488
|
});
|
|
31476
31489
|
return;
|
|
@@ -31479,7 +31492,7 @@ function checkTraversalFilter(node, path15, contextEntity, fieldByEntity, errors
|
|
|
31479
31492
|
errors.push({
|
|
31480
31493
|
severity: "error",
|
|
31481
31494
|
rule: "model.filter",
|
|
31482
|
-
path:
|
|
31495
|
+
path: path16,
|
|
31483
31496
|
message: index === 0 ? `traversal path[0] "${hop}" is addressed on entity "${parsed2.entity}", but the filter holding it reads "${current}"` : `traversal path[${index}] "${hop}" is addressed on entity "${parsed2.entity}", but the hop before it lands on "${current}"`
|
|
31484
31497
|
});
|
|
31485
31498
|
return;
|
|
@@ -31489,7 +31502,7 @@ function checkTraversalFilter(node, path15, contextEntity, fieldByEntity, errors
|
|
|
31489
31502
|
errors.push({
|
|
31490
31503
|
severity: "error",
|
|
31491
31504
|
rule: "model.filter",
|
|
31492
|
-
path:
|
|
31505
|
+
path: path16,
|
|
31493
31506
|
message: `traversal path[${index}] "${hop}" is not a field on entity "${parsed2.entity}"`
|
|
31494
31507
|
});
|
|
31495
31508
|
return;
|
|
@@ -31498,7 +31511,7 @@ function checkTraversalFilter(node, path15, contextEntity, fieldByEntity, errors
|
|
|
31498
31511
|
errors.push({
|
|
31499
31512
|
severity: "error",
|
|
31500
31513
|
rule: "model.filter",
|
|
31501
|
-
path:
|
|
31514
|
+
path: path16,
|
|
31502
31515
|
message: `traversal path[${index}] "${hop}" is a ${field.type} field, not select_record_link`
|
|
31503
31516
|
});
|
|
31504
31517
|
return;
|
|
@@ -31512,7 +31525,7 @@ function checkTraversalFilter(node, path15, contextEntity, fieldByEntity, errors
|
|
|
31512
31525
|
errors.push({
|
|
31513
31526
|
severity: "error",
|
|
31514
31527
|
rule: "model.filter",
|
|
31515
|
-
path:
|
|
31528
|
+
path: path16,
|
|
31516
31529
|
message: `traversal condition "${innerField}" must be an entity.field alias on "${current}", the entity its path lands on`
|
|
31517
31530
|
});
|
|
31518
31531
|
return;
|
|
@@ -31522,12 +31535,12 @@ function checkTraversalFilter(node, path15, contextEntity, fieldByEntity, errors
|
|
|
31522
31535
|
errors.push({
|
|
31523
31536
|
severity: "error",
|
|
31524
31537
|
rule: "model.filter",
|
|
31525
|
-
path:
|
|
31538
|
+
path: path16,
|
|
31526
31539
|
message: `traversal condition "${innerField}" is not a field on entity "${parsed.entity}"`
|
|
31527
31540
|
});
|
|
31528
31541
|
return;
|
|
31529
31542
|
}
|
|
31530
|
-
checkConditionShape(node.condition, parsed.entity, target,
|
|
31543
|
+
checkConditionShape(node.condition, parsed.entity, target, path16, fieldByEntity, errors);
|
|
31531
31544
|
}
|
|
31532
31545
|
function contractFilterType(entityAlias, field, fieldByEntity, hops = 0) {
|
|
31533
31546
|
if (field.type === "formula") return formulaResultType(field.formula);
|
|
@@ -31550,7 +31563,7 @@ function formulaSelectOptions(entityAlias, field, fieldByEntity, hops = 0) {
|
|
|
31550
31563
|
const shown = fieldByEntity.get(source.target_entity)?.get(field.lookup_field_alias);
|
|
31551
31564
|
return shown === void 0 ? void 0 : formulaSelectOptions(source.target_entity, shown, fieldByEntity, hops + 1);
|
|
31552
31565
|
}
|
|
31553
|
-
function selectWordRefusals(entityAlias, expression, fieldByEntity,
|
|
31566
|
+
function selectWordRefusals(entityAlias, expression, fieldByEntity, path16) {
|
|
31554
31567
|
const words = new Set([...expression.matchAll(/(["'])((?:(?!\1).)*)\1/g)].map((match2) => match2[2]));
|
|
31555
31568
|
if (words.size === 0) return [];
|
|
31556
31569
|
return parseFieldRefTokens(expression).flatMap((token2) => {
|
|
@@ -31564,19 +31577,19 @@ function selectWordRefusals(entityAlias, expression, fieldByEntity, path15) {
|
|
|
31564
31577
|
{
|
|
31565
31578
|
severity: "error",
|
|
31566
31579
|
rule: "field.formula",
|
|
31567
|
-
path:
|
|
31580
|
+
path: path16,
|
|
31568
31581
|
message: held.own ? `compares {${token2}} to "${word}", and a select reaches a formula as the KEYS of its options, never their words \u2014 name the option {${token2}:${spelled.alias}}: includes({${token2}}, {${token2}:${spelled.alias}})` : `compares {${token2}} to "${word}", and a looked-up select reaches a formula as the KEYS of its options, never their words \u2014 test it on "${held.entity}", where the select lives ({field:option} names one there), and look that result up`
|
|
31569
31582
|
}
|
|
31570
31583
|
];
|
|
31571
31584
|
});
|
|
31572
31585
|
}
|
|
31573
|
-
function formulaOptionRefusals(field,
|
|
31586
|
+
function formulaOptionRefusals(field, path16) {
|
|
31574
31587
|
const { options, output_type: stated } = field.formula;
|
|
31575
31588
|
if (options === void 0) {
|
|
31576
|
-
return stated === "select" ? [{ severity: "error", rule: "field.formula", path:
|
|
31589
|
+
return stated === "select" ? [{ severity: "error", rule: "field.formula", path: path16, message: 'states output_type "select" and no options \u2014 a formula yields one of the options it declares' }] : [];
|
|
31577
31590
|
}
|
|
31578
31591
|
const refusals = [];
|
|
31579
|
-
const refuse = (message2) => refusals.push({ severity: "error", rule: "field.formula", path:
|
|
31592
|
+
const refuse = (message2) => refusals.push({ severity: "error", rule: "field.formula", path: path16, message: message2 });
|
|
31580
31593
|
if (stated !== void 0 && stated !== "select") refuse(`states output_type "${stated}" beside options \u2014 a formula with options yields one of them`);
|
|
31581
31594
|
const figure = ["format", "currency", "unit", "unit_field", "currency_field"].filter((key) => field.formula[key] !== void 0);
|
|
31582
31595
|
if (figure.length > 0) refuse(`states ${figure.join(", ")} beside options \u2014 a formula yielding an option is no figure`);
|
|
@@ -31596,12 +31609,12 @@ function formulaOptionRefusals(field, path15) {
|
|
|
31596
31609
|
}
|
|
31597
31610
|
return refusals;
|
|
31598
31611
|
}
|
|
31599
|
-
function checkConditionShape(condition, entityAlias, field,
|
|
31612
|
+
function checkConditionShape(condition, entityAlias, field, path16, fieldByEntity, errors) {
|
|
31600
31613
|
const type = contractFilterType(entityAlias, field, fieldByEntity);
|
|
31601
31614
|
if (type === void 0 && condition.type === void 0) return;
|
|
31602
31615
|
const typed = type === void 0 ? condition : { ...condition, type };
|
|
31603
|
-
for (const message2 of validateFilterCondition(typed,
|
|
31604
|
-
errors.push({ severity: "error", rule: "model.filter", path:
|
|
31616
|
+
for (const message2 of validateFilterCondition(typed, path16)) {
|
|
31617
|
+
errors.push({ severity: "error", rule: "model.filter", path: path16, message: message2.startsWith(`${path16}: `) ? message2.slice(path16.length + 2) : message2 });
|
|
31605
31618
|
}
|
|
31606
31619
|
if (field.type !== "select") return;
|
|
31607
31620
|
const declared = new Set(field.options.map((option) => option.alias));
|
|
@@ -31611,20 +31624,20 @@ function checkConditionShape(condition, entityAlias, field, path15, fieldByEntit
|
|
|
31611
31624
|
errors.push({
|
|
31612
31625
|
severity: "error",
|
|
31613
31626
|
rule: "model.filter",
|
|
31614
|
-
path:
|
|
31627
|
+
path: path16,
|
|
31615
31628
|
message: `filters field "${field.alias}" on option "${raw}", which that field does not declare (${declared.size > 0 ? [...declared].join(", ") : "it declares none"})`
|
|
31616
31629
|
});
|
|
31617
31630
|
}
|
|
31618
31631
|
}
|
|
31619
|
-
function checkFilterFields(filters,
|
|
31632
|
+
function checkFilterFields(filters, path16, contextEntity, fieldByEntity, errors, checkField) {
|
|
31620
31633
|
if (filters.node_type === "group") {
|
|
31621
31634
|
for (const child of filters.children) {
|
|
31622
|
-
checkFilterFields(child,
|
|
31635
|
+
checkFilterFields(child, path16, contextEntity, fieldByEntity, errors, checkField);
|
|
31623
31636
|
}
|
|
31624
31637
|
return;
|
|
31625
31638
|
}
|
|
31626
31639
|
if (filters.node_type === "traversal") {
|
|
31627
|
-
checkTraversalFilter(filters,
|
|
31640
|
+
checkTraversalFilter(filters, path16, contextEntity, fieldByEntity, errors);
|
|
31628
31641
|
return;
|
|
31629
31642
|
}
|
|
31630
31643
|
if (filters.type === "current_member") return;
|
|
@@ -31632,7 +31645,7 @@ function checkFilterFields(filters, path15, contextEntity, fieldByEntity, errors
|
|
|
31632
31645
|
checkField(filters.field_key);
|
|
31633
31646
|
if (contextEntity === null) return;
|
|
31634
31647
|
const field = fieldByEntity.get(contextEntity)?.get(filters.field_key);
|
|
31635
|
-
if (field !== void 0) checkConditionShape(filters, contextEntity, field,
|
|
31648
|
+
if (field !== void 0) checkConditionShape(filters, contextEntity, field, path16, fieldByEntity, errors);
|
|
31636
31649
|
}
|
|
31637
31650
|
function findDuplicates(aliases) {
|
|
31638
31651
|
const seen = /* @__PURE__ */ new Set();
|
|
@@ -31647,19 +31660,19 @@ function checkUniqueTuples(entity, fields) {
|
|
|
31647
31660
|
const errors = [];
|
|
31648
31661
|
const seen = /* @__PURE__ */ new Set();
|
|
31649
31662
|
for (const [index, tuple2] of (entity.unique ?? []).entries()) {
|
|
31650
|
-
const
|
|
31663
|
+
const path16 = `entities.${entity.alias}.unique.${index}`;
|
|
31651
31664
|
const key = [...new Set(tuple2)].sort().join("+");
|
|
31652
31665
|
if (new Set(tuple2).size !== tuple2.length) {
|
|
31653
|
-
errors.push({ severity: "error", rule: "entity.unique", path:
|
|
31666
|
+
errors.push({ severity: "error", rule: "entity.unique", path: path16, message: `names a field twice \u2014 a set of fields names each once` });
|
|
31654
31667
|
}
|
|
31655
31668
|
if (seen.has(key)) {
|
|
31656
|
-
errors.push({ severity: "error", rule: "entity.unique", path:
|
|
31669
|
+
errors.push({ severity: "error", rule: "entity.unique", path: path16, message: `states the set ${tuple2.join(" + ")} again \u2014 once is the rule` });
|
|
31657
31670
|
}
|
|
31658
31671
|
seen.add(key);
|
|
31659
31672
|
for (const alias2 of tuple2) {
|
|
31660
31673
|
const field = fields.get(alias2);
|
|
31661
31674
|
if (field === void 0) {
|
|
31662
|
-
errors.push({ severity: "error", rule: "entity.unique", path:
|
|
31675
|
+
errors.push({ severity: "error", rule: "entity.unique", path: path16, message: `names "${alias2}", which is not a field of "${entity.alias}"` });
|
|
31663
31676
|
continue;
|
|
31664
31677
|
}
|
|
31665
31678
|
const admitted = field.type === "select" && field.multi !== true || field.type === "select_record_link" && field.cardinality === "one" || field.type === "text" || field.type === "number" || field.type === "date";
|
|
@@ -31667,14 +31680,14 @@ function checkUniqueTuples(entity, fields) {
|
|
|
31667
31680
|
errors.push({
|
|
31668
31681
|
severity: "error",
|
|
31669
31682
|
rule: "entity.unique",
|
|
31670
|
-
path:
|
|
31683
|
+
path: path16,
|
|
31671
31684
|
message: `names "${alias2}", which holds no single value to compare (a ${field.type}) \u2014 a unique set takes text, number, date, a single select, or a link of cardinality "one"`
|
|
31672
31685
|
});
|
|
31673
31686
|
}
|
|
31674
31687
|
}
|
|
31675
31688
|
const [only] = tuple2;
|
|
31676
31689
|
if (tuple2.length === 1 && fields.get(only)?.type === "text") {
|
|
31677
|
-
errors.push({ severity: "error", rule: "entity.unique", path:
|
|
31690
|
+
errors.push({ severity: "error", rule: "entity.unique", path: path16, message: `is "${only}" alone \u2014 declare \`unique: true\` on the text field itself` });
|
|
31678
31691
|
}
|
|
31679
31692
|
}
|
|
31680
31693
|
return errors;
|
|
@@ -32368,7 +32381,11 @@ var MODEL_RULES = {
|
|
|
32368
32381
|
sentence: "An add (`create`) asks every field its entity requires that nothing else fills \u2014 a `default`, `starts`, the record or folder it files the row under, or a `default_from` over a link it fills \u2014 or the server refuses every add."
|
|
32369
32382
|
},
|
|
32370
32383
|
"app.books": {
|
|
32371
|
-
sentence: "A register app's `books` each name another entity once \u2014 never the app's own \u2014 whose fields line up with the app's by alias, else by `fields` (a field the app reads, or one a report prints \u2192 one of the book's): read as one column type (a day apart from a moment), one value or several alike, a link to the same entity. Each book holds the title, the status, the folder (`scope`), what `where` keeps and every check locking the rows; the register is a table or cards. Two books holding a field of their own under one alias hold it as one kind of value. A report's `books` each name the app's entity or one of its books, once, and one of them holds each field its `where` and `per` narrow by."
|
|
32384
|
+
sentence: "A register app's `books` each name another entity once \u2014 never the app's own \u2014 whose fields line up with the app's by alias, else by `fields` (a field the app reads, or one a report prints \u2192 one of the book's): read as one column type (a day apart from a moment), one value or several alike, a link to the same entity. Each book holds the title, the status, the folder (`scope`), what `where` keeps and every check locking the rows; the register is a table or cards. Two books holding a field of their own under one alias hold it as one kind of value. A report's `books` each name the app's entity or one of its books, once, and one of them holds each field its `where` and `per` narrow by. `register.book` reads a register that has books."
|
|
32385
|
+
},
|
|
32386
|
+
"app.book-option-kin": {
|
|
32387
|
+
sentence: "A book's option labelled as one of the app's, under another alias, reads apart from it: the register lists both and counts each on its own.",
|
|
32388
|
+
note: true
|
|
32372
32389
|
},
|
|
32373
32390
|
"app.book-lacks": {
|
|
32374
32391
|
sentence: "A book lacking a field the app's record, an act or an add reads: what reads it drops from the book's rows \u2014 an act, a block, a section, the add. A field only a report prints, held by a book as another kind of value, prints blank on its rows.",
|
|
@@ -32563,11 +32580,11 @@ var MODEL_RULES = {
|
|
|
32563
32580
|
"check.resolve": { sentence: "`resolve` names an act of this app on the rows the check stands on, pressed on one row, not refused by the check, and drawn as a button \u2014 never a correction." },
|
|
32564
32581
|
"check.restates-meter": { sentence: "A warning reading a gated meter's figure against its bound or pass mark is refused \u2014 the meter draws it." }
|
|
32565
32582
|
};
|
|
32566
|
-
var error51 = (rule,
|
|
32567
|
-
var note = (rule,
|
|
32583
|
+
var error51 = (rule, path16, message2) => ({ severity: "error", path: path16, message: message2, rule });
|
|
32584
|
+
var note = (rule, path16, message2) => ({ severity: "note", path: path16, message: message2, rule });
|
|
32568
32585
|
|
|
32569
32586
|
// ../shared/src/schemas/app_spec_version.ts
|
|
32570
|
-
var APP_SPEC_VERSION =
|
|
32587
|
+
var APP_SPEC_VERSION = 13;
|
|
32571
32588
|
|
|
32572
32589
|
// ../shared/src/schemas/app_query_aggregate.ts
|
|
32573
32590
|
var AGGREGATE_MAX_BY = 3;
|
|
@@ -32912,6 +32929,8 @@ var specTabSchema = zod_default.object({
|
|
|
32912
32929
|
var specRegisterSchema = zod_default.object({
|
|
32913
32930
|
columns: zod_default.array(specColumnAliasSchema),
|
|
32914
32931
|
filters: zod_default.array(specFilterSchema),
|
|
32932
|
+
/** With `books`: a column naming the book each row is of, and a filter by it. */
|
|
32933
|
+
book: zod_default.literal(true).optional(),
|
|
32915
32934
|
/** The columns the search box matches, a link by its display — each line of a pasted list on its own. Absent, the list read's `search` param. */
|
|
32916
32935
|
search: zod_default.array(specColumnAliasSchema).min(1).optional(),
|
|
32917
32936
|
/** The keys rows open ordered by, most significant first, each breaking the ties of the ones before it. */
|
|
@@ -33450,6 +33469,7 @@ var registerSchema = zod_default.object({
|
|
|
33450
33469
|
filters: lineFilters("a register").optional().describe(
|
|
33451
33470
|
`The fields the reader narrows the rows by every day, in that order \u2014 each earns its place by that daily use, so none to ${LINE_FILTERS} is normal and ${LINE_FILTERS} is a cap, never a quota (a select, a member, a link, a date, a yes/no, a number as a range, or its \`tiers\` where the business narrows by those bands daily) \u2014 each on a desk's line, in one Filters sheet on a phone. Never the status (its chips); each a field the rows show \u2014 a column, or a part of the row. A threshold the business names ("large orders") is a formula yes/no here; a column's header sorts by a day or an amount`
|
|
33452
33471
|
),
|
|
33472
|
+
book: zod_default.literal(true).optional().describe("With `books`: a column naming the table each row is of, and a filter by it"),
|
|
33453
33473
|
search: zod_default.array(fieldAlias).min(1).optional().describe(
|
|
33454
33474
|
"The fields the search box matches (a link by its row's title); a pasted list searches each line and names the lines no row matched. Absent, every word of the row"
|
|
33455
33475
|
),
|
|
@@ -35066,9 +35086,9 @@ function templateFields(content) {
|
|
|
35066
35086
|
}
|
|
35067
35087
|
|
|
35068
35088
|
// ../shared/src/schemas/app_model_validate_parts.ts
|
|
35069
|
-
function checkOwnRows(model, entity, field,
|
|
35089
|
+
function checkOwnRows(model, entity, field, path16) {
|
|
35070
35090
|
if (field.type !== "select_record_link" || !childList(model, entity, field)) return [];
|
|
35071
|
-
return [error51("app.own-rows",
|
|
35091
|
+
return [error51("app.own-rows", path16, `"${field.alias}" lists "${field.target_entity}" rows that each belong to one "${entity.alias}" \u2014 they are this record's own rows: read them as a block, \`{ "rows": "${field.target_entity}" }\``)];
|
|
35072
35092
|
}
|
|
35073
35093
|
function checkWhen(entities, entity, when, at2, place) {
|
|
35074
35094
|
const findings = [];
|
|
@@ -35111,21 +35131,21 @@ function checkNarrowing(entities, entity, narrowing, at2) {
|
|
|
35111
35131
|
return [...mine, ...checkWhen(entities, entity, conditions, at2, "offer")];
|
|
35112
35132
|
}
|
|
35113
35133
|
var MADE_FROM = ["html", "excel"];
|
|
35114
|
-
function checkTemplate(model, template,
|
|
35134
|
+
function checkTemplate(model, template, path16) {
|
|
35115
35135
|
if (model.templates === void 0) return [];
|
|
35116
35136
|
const declared = model.templates.find((one) => one.alias === template);
|
|
35117
|
-
if (declared === void 0) return [error51("model.names-declared",
|
|
35137
|
+
if (declared === void 0) return [error51("model.names-declared", path16, `names template "${template}", which this model does not declare`)];
|
|
35118
35138
|
if (!MADE_FROM.includes(declared.type)) {
|
|
35119
|
-
return [error51("model.template-kind",
|
|
35139
|
+
return [error51("model.template-kind", path16, `names the ${declared.type} template "${template}" \u2014 a document is made from an html or an excel one`)];
|
|
35120
35140
|
}
|
|
35121
35141
|
return [];
|
|
35122
35142
|
}
|
|
35123
|
-
function checkExportTemplate(model, entity, tabs, template,
|
|
35124
|
-
const kind = checkTemplate(model, template,
|
|
35143
|
+
function checkExportTemplate(model, entity, tabs, template, path16) {
|
|
35144
|
+
const kind = checkTemplate(model, template, path16);
|
|
35125
35145
|
if (kind.length > 0 || model.templates === void 0) return kind;
|
|
35126
35146
|
const reports = APP_EXPORT_REPORT_KEYS;
|
|
35127
35147
|
if (reports.includes(entity)) {
|
|
35128
|
-
return [error51("register.export-template",
|
|
35148
|
+
return [error51("register.export-template", path16, `lists its rows under "${entity}", a key the report fills itself (${reports.join(", ")}) \u2014 rename the entity`)];
|
|
35129
35149
|
}
|
|
35130
35150
|
const fed = [...reports, entity, ...tabs];
|
|
35131
35151
|
const content = templateContent(model, template);
|
|
@@ -35134,8 +35154,8 @@ function checkExportTemplate(model, entity, tabs, template, path15) {
|
|
|
35134
35154
|
(app) => isDashboardApp(app) ? [] : (app.acts ?? []).flatMap((act) => [act.template, ...(act.templates ?? []).map((one) => one.template)].includes(template) ? [`${app.alias}.${act.alias}`] : [])
|
|
35135
35155
|
);
|
|
35136
35156
|
return [
|
|
35137
|
-
...unfed.length === 0 ? [] : [error51("register.export-template",
|
|
35138
|
-
...acts.length === 0 ? [] : [error51("register.export-template",
|
|
35157
|
+
...unfed.length === 0 ? [] : [error51("register.export-template", path16, `reads ${unfed.map((root) => `"${root}"`).join(", ")} at its root, which an export does not fill \u2014 it fills ${fed.map((key) => `"${key}"`).join(", ")}`)],
|
|
35158
|
+
...acts.length === 0 ? [] : [error51("register.export-template", path16, `is also the document of ${acts.map((act) => `"${act}"`).join(", ")} \u2014 an act fills its template with a record's fields, an export with the report; give each its own`)]
|
|
35139
35159
|
];
|
|
35140
35160
|
}
|
|
35141
35161
|
|
|
@@ -35185,9 +35205,9 @@ function checkReading(model, reading, at2) {
|
|
|
35185
35205
|
const entity = entityOf(model, alias2);
|
|
35186
35206
|
if (entity === void 0) return [error51("model.names-declared", `${at2}.${key}`, `names entity "${alias2}", which this model does not declare`)];
|
|
35187
35207
|
const findings = [];
|
|
35188
|
-
const own = (name,
|
|
35208
|
+
const own = (name, path16) => {
|
|
35189
35209
|
const found = fieldOf(entity, name);
|
|
35190
|
-
if (found === void 0) findings.push(error51("model.names-declared",
|
|
35210
|
+
if (found === void 0) findings.push(error51("model.names-declared", path16, `names "${name}", which is not a field of "${entity.alias}"`));
|
|
35191
35211
|
return found;
|
|
35192
35212
|
};
|
|
35193
35213
|
if ("value" in reading && reading.value !== void 0) {
|
|
@@ -35224,31 +35244,31 @@ function checkReading(model, reading, at2) {
|
|
|
35224
35244
|
if (refused !== void 0) findings.push(error51("reading.over", `${at2}.over`, `"${reading.over}" ${refused}`));
|
|
35225
35245
|
}
|
|
35226
35246
|
for (const [name, held] of Object.entries(reading.where ?? {})) {
|
|
35227
|
-
const
|
|
35228
|
-
const named = own(name,
|
|
35247
|
+
const path16 = `${at2}.where.${name}`;
|
|
35248
|
+
const named = own(name, path16);
|
|
35229
35249
|
if (named === void 0) continue;
|
|
35230
35250
|
if (held === "me") {
|
|
35231
|
-
if (named.type !== "select_member") findings.push(error51("reading.where",
|
|
35251
|
+
if (named.type !== "select_member") findings.push(error51("reading.where", path16, `"${name}" is ${kindOf(named, entity, model.entities)} \u2014 "me" narrows a member field to the reader`));
|
|
35232
35252
|
continue;
|
|
35233
35253
|
}
|
|
35234
35254
|
const read = readField(model, entity, named);
|
|
35235
35255
|
if (read === void 0 || read.several) {
|
|
35236
|
-
findings.push(error51("reading.where",
|
|
35256
|
+
findings.push(error51("reading.where", path16, `"${name}" is a lookup through a link holding several rows \u2014 a reading keeps a row by one value: look it up through links to one row`));
|
|
35237
35257
|
continue;
|
|
35238
35258
|
}
|
|
35239
35259
|
const { field, entity: holder } = read;
|
|
35240
35260
|
if (typeof held === "boolean") {
|
|
35241
|
-
if (!isYesNo(field, holder, model.entities)) findings.push(error51("reading.where",
|
|
35261
|
+
if (!isYesNo(field, holder, model.entities)) findings.push(error51("reading.where", path16, `"${name}" is ${kindOf(field, holder, model.entities)} \u2014 true or false keeps rows by a yes/no, stored or a formula of one`));
|
|
35242
35262
|
continue;
|
|
35243
35263
|
}
|
|
35244
35264
|
const options = contractSelectOptions(field);
|
|
35245
35265
|
if (options === void 0) {
|
|
35246
|
-
findings.push(error51("reading.where",
|
|
35266
|
+
findings.push(error51("reading.where", path16, `"${name}" is a ${field.type} field \u2014 options keep rows by a select`));
|
|
35247
35267
|
continue;
|
|
35248
35268
|
}
|
|
35249
35269
|
const declared = new Set(options.map((option) => option.alias));
|
|
35250
35270
|
for (const [index, option] of held.entries()) {
|
|
35251
|
-
if (!declared.has(option)) findings.push(error51("model.names-declared", `${
|
|
35271
|
+
if (!declared.has(option)) findings.push(error51("model.names-declared", `${path16}.${index}`, `names "${option}", which is not an option of "${name}"`));
|
|
35252
35272
|
}
|
|
35253
35273
|
}
|
|
35254
35274
|
if ("list" in reading) {
|
|
@@ -35272,18 +35292,18 @@ function checkMilestones(entities, entity, display, at2) {
|
|
|
35272
35292
|
const findings = [];
|
|
35273
35293
|
const seen = /* @__PURE__ */ new Set();
|
|
35274
35294
|
for (const [index, stage] of stages.entries()) {
|
|
35275
|
-
const
|
|
35295
|
+
const path16 = `${at2}.status.milestones.${index}`;
|
|
35276
35296
|
const date5 = fieldOf(entity, stage.field);
|
|
35277
35297
|
if (date5 === void 0) {
|
|
35278
|
-
findings.push(error51("model.names-declared",
|
|
35298
|
+
findings.push(error51("model.names-declared", path16, `names "${stage.field}", which is not a field of "${entity.alias}"`));
|
|
35279
35299
|
continue;
|
|
35280
35300
|
}
|
|
35281
35301
|
if (date5.type !== "date" || !isAsked(date5)) {
|
|
35282
|
-
findings.push(error51("records.milestones",
|
|
35302
|
+
findings.push(error51("records.milestones", path16, `"${stage.field}" is a ${date5.type === "date" ? "date the workspace stamps" : `${date5.type} field`} \u2014 a milestone is a date a person ticks`));
|
|
35283
35303
|
}
|
|
35284
|
-
if (seen.has(stage.field)) findings.push(error51("records.milestones",
|
|
35304
|
+
if (seen.has(stage.field)) findings.push(error51("records.milestones", path16, `"${stage.field}" is already a milestone \u2014 each date marks one stage`));
|
|
35285
35305
|
seen.add(stage.field);
|
|
35286
|
-
findings.push(...checkWhen(entities, entity, stage.when, `${
|
|
35306
|
+
findings.push(...checkWhen(entities, entity, stage.when, `${path16}.when`, "stage"));
|
|
35287
35307
|
}
|
|
35288
35308
|
for (const [index, closed] of (display.status?.closed ?? []).entries()) {
|
|
35289
35309
|
if (!seen.has(closed)) findings.push(error51("records.milestones", `${at2}.status.closed.${index}`, `names "${closed}", which is not one of the milestones`));
|
|
@@ -35294,11 +35314,11 @@ function checkMilestones(entities, entity, display, at2) {
|
|
|
35294
35314
|
}
|
|
35295
35315
|
return findings;
|
|
35296
35316
|
}
|
|
35297
|
-
function checkCreateMilestones(model, entity, asked,
|
|
35317
|
+
function checkCreateMilestones(model, entity, asked, path16) {
|
|
35298
35318
|
const stages = milestonesOf(recordDisplay(model, entity)) ?? [];
|
|
35299
35319
|
const later = new Set(stages.slice(1).map((stage) => stage.field));
|
|
35300
35320
|
return asked.flatMap(
|
|
35301
|
-
(name, index) => later.has(name) ? [error51("records.milestone-add", `${
|
|
35321
|
+
(name, index) => later.has(name) ? [error51("records.milestone-add", `${path16}.${index}`, `asks "${name}", a later milestone of "${entity}" \u2014 a row starts at the first, and each after it is ticked in order`)] : []
|
|
35302
35322
|
);
|
|
35303
35323
|
}
|
|
35304
35324
|
|
|
@@ -35309,9 +35329,9 @@ function checkAppRow(model, app, at2) {
|
|
|
35309
35329
|
const display = model.records?.[app.entity];
|
|
35310
35330
|
if (row === void 0 || entity === void 0 || display === void 0) return [];
|
|
35311
35331
|
const findings = [];
|
|
35312
|
-
const field = (name,
|
|
35332
|
+
const field = (name, path16) => {
|
|
35313
35333
|
const found = fieldOf(entity, name);
|
|
35314
|
-
if (found === void 0) findings.push(error51("model.names-declared",
|
|
35334
|
+
if (found === void 0) findings.push(error51("model.names-declared", path16, `names "${name}", which is not a field of "${entity.alias}"`));
|
|
35315
35335
|
return found;
|
|
35316
35336
|
};
|
|
35317
35337
|
const same = (part) => error51("app.row", `${at2}.row.${part}`, `restates \`records.${entity.alias}.${part}\` \u2014 a row reads it there already; drop it`);
|
|
@@ -35342,13 +35362,13 @@ function checkAppRow(model, app, at2) {
|
|
|
35342
35362
|
}
|
|
35343
35363
|
function checkApplies(model, entity, display, at2) {
|
|
35344
35364
|
return Object.entries(display.applies ?? {}).flatMap(([name, conditions]) => {
|
|
35345
|
-
const
|
|
35365
|
+
const path16 = `${at2}.applies.${name}`;
|
|
35346
35366
|
const applying = fieldOf(entity, name);
|
|
35347
|
-
if (applying === void 0) return [error51("model.names-declared",
|
|
35348
|
-
if (name === display.title) return [error51("records.applies",
|
|
35349
|
-
const own = applying.required === true && !hasDefault(applying) ? [error51("records.applies",
|
|
35367
|
+
if (applying === void 0) return [error51("model.names-declared", path16, `names "${name}", which is not a field of "${entity.alias}"`)];
|
|
35368
|
+
if (name === display.title) return [error51("records.applies", path16, `"${name}" is the row's title, which names it wherever it is drawn \u2014 it always applies`)];
|
|
35369
|
+
const own = applying.required === true && !hasDefault(applying) ? [error51("records.applies", path16, `"${name}" is required and starts at no \`default\` \u2014 where it does not apply no add asks it, so a row could not be made; give it a default, or make it optional`)] : [];
|
|
35350
35370
|
const held = Object.entries(conditions).flatMap(([condition, value]) => {
|
|
35351
|
-
const at3 = `${
|
|
35371
|
+
const at3 = `${path16}.${condition}`;
|
|
35352
35372
|
if (condition === name) return [error51("records.applies", at3, `"${name}" applies by itself \u2014 a field applies by another of the row's`)];
|
|
35353
35373
|
const shape = appliesCondition(model, entity, condition);
|
|
35354
35374
|
if ("refused" in shape) return [error51(shape.refused.startsWith("names") ? "model.names-declared" : "records.applies", at3, shape.refused)];
|
|
@@ -35371,9 +35391,9 @@ function checkRecords(model) {
|
|
|
35371
35391
|
findings.push(error51("model.names-declared", at2, `names entity "${alias2}", which this model does not declare`));
|
|
35372
35392
|
continue;
|
|
35373
35393
|
}
|
|
35374
|
-
const field = (name,
|
|
35394
|
+
const field = (name, path16) => {
|
|
35375
35395
|
const found = fieldOf(entity, name);
|
|
35376
|
-
if (found === void 0) findings.push(error51("model.names-declared",
|
|
35396
|
+
if (found === void 0) findings.push(error51("model.names-declared", path16, `names "${name}", which is not a field of "${alias2}"`));
|
|
35377
35397
|
return found;
|
|
35378
35398
|
};
|
|
35379
35399
|
const valueType = (found) => resolvedFieldType(found, entity, model.entities);
|
|
@@ -35443,14 +35463,14 @@ function checkRecords(model) {
|
|
|
35443
35463
|
}
|
|
35444
35464
|
}
|
|
35445
35465
|
for (const [name, start] of Object.entries(display.starts ?? {})) {
|
|
35446
|
-
const
|
|
35447
|
-
const started = field(name,
|
|
35466
|
+
const path16 = `${at2}.starts.${name}`;
|
|
35467
|
+
const started = field(name, path16);
|
|
35448
35468
|
if (started === void 0) continue;
|
|
35449
|
-
if (!isAsked(started)) findings.push(error51("records.starts",
|
|
35469
|
+
if (!isAsked(started)) findings.push(error51("records.starts", path16, `"${name}" is a ${started.type} field nobody writes, so an add never starts it`));
|
|
35450
35470
|
else if (start === "today" && (started.type !== "date" || started.format === "date_range" || started.format === "datetime_range")) {
|
|
35451
|
-
findings.push(error51("records.starts",
|
|
35471
|
+
findings.push(error51("records.starts", path16, `"${name}" is ${started.type === "date" ? "a span of days" : `a ${started.type} field`} \u2014 \`today\` starts a date or a moment`));
|
|
35452
35472
|
} else if (start === "me" && started.type !== "select_member") {
|
|
35453
|
-
findings.push(error51("records.starts",
|
|
35473
|
+
findings.push(error51("records.starts", path16, `"${name}" is a ${started.type} field \u2014 \`me\` starts a member field at the reader adding the row`));
|
|
35454
35474
|
}
|
|
35455
35475
|
}
|
|
35456
35476
|
for (const [index, name] of (display.due ?? []).entries()) {
|
|
@@ -35649,30 +35669,30 @@ function optionsWhereConditions(filter2, target) {
|
|
|
35649
35669
|
}
|
|
35650
35670
|
return out.length === 0 ? void 0 : out;
|
|
35651
35671
|
}
|
|
35652
|
-
function checkBoundRef(entity, field, ref, byAlias,
|
|
35672
|
+
function checkBoundRef(entity, field, ref, byAlias, path16) {
|
|
35653
35673
|
const entities = [...byAlias.values()];
|
|
35654
35674
|
let on = entity;
|
|
35655
35675
|
if ("link" in ref) {
|
|
35656
35676
|
const link = entity.fields.find((one) => one.alias === ref.link);
|
|
35657
35677
|
if (link === void 0 || link.type !== "select_record_link" || link.cardinality !== "one") {
|
|
35658
|
-
return [error51("write.bounds",
|
|
35678
|
+
return [error51("write.bounds", path16, `reads "${ref.link}", which is not a one-row link of ${entity.label} \u2014 a bound is read off this row or the one row a link names`)];
|
|
35659
35679
|
}
|
|
35660
35680
|
const target = byAlias.get(link.target_entity);
|
|
35661
|
-
if (target === void 0) return [error51("model.names-declared",
|
|
35681
|
+
if (target === void 0) return [error51("model.names-declared", path16, `reads "${ref.link}", whose entity "${link.target_entity}" this model does not declare`)];
|
|
35662
35682
|
on = target;
|
|
35663
35683
|
}
|
|
35664
35684
|
const source = on.fields.find((one) => one.alias === ref.field);
|
|
35665
|
-
if (source === void 0) return [error51("model.names-declared",
|
|
35666
|
-
if (!("link" in ref) && source.alias === field.alias) return [error51("write.bounds",
|
|
35685
|
+
if (source === void 0) return [error51("model.names-declared", path16, `names "${ref.field}", which is not a field of ${on.label}`)];
|
|
35686
|
+
if (!("link" in ref) && source.alias === field.alias) return [error51("write.bounds", path16, `bounds ${field.label} by itself`)];
|
|
35667
35687
|
const from = resolvedFieldType(source, on, entities);
|
|
35668
35688
|
const into = resolvedFieldType(field, entity, entities);
|
|
35669
|
-
if (from !== into) return [error51("write.bounds",
|
|
35689
|
+
if (from !== into) return [error51("write.bounds", path16, `bounds ${field.label} (${String(into)}) by "${source.label}" (${String(from)}) \u2014 a bound is of the value's own type`)];
|
|
35670
35690
|
const apart = boundUnitRefusal(field, source, entity, entities, "link" in ref ? { link: ref.link, on } : void 0);
|
|
35671
|
-
return apart === void 0 ? [] : [error51("write.bounds",
|
|
35691
|
+
return apart === void 0 ? [] : [error51("write.bounds", path16, apart)];
|
|
35672
35692
|
}
|
|
35673
|
-
function checkSame(entity, field, same, byAlias,
|
|
35693
|
+
function checkSame(entity, field, same, byAlias, path16) {
|
|
35674
35694
|
if (field.type !== "select_record_link") {
|
|
35675
|
-
return [error51("write.same",
|
|
35695
|
+
return [error51("write.same", path16, `${field.label} is a ${field.type} field \u2014 \`same\` narrows the rows a LINK points at`)];
|
|
35676
35696
|
}
|
|
35677
35697
|
const target = byAlias.get(field.target_entity);
|
|
35678
35698
|
if (target === void 0) return [];
|
|
@@ -35680,7 +35700,7 @@ function checkSame(entity, field, same, byAlias, path15) {
|
|
|
35680
35700
|
return same.flatMap((alias2, index) => {
|
|
35681
35701
|
const own = oneLink(entity, alias2);
|
|
35682
35702
|
const theirs = oneLink(target, alias2);
|
|
35683
|
-
const at2 = `${
|
|
35703
|
+
const at2 = `${path16}.${index}`;
|
|
35684
35704
|
if (own === void 0) return [error51("write.same", at2, `"${alias2}" is not a one-row link of ${entity.label}`)];
|
|
35685
35705
|
if (theirs === void 0) return [error51("write.same", at2, `"${alias2}" is not a one-row link of ${target.label} \u2014 the row ${field.label} points at names nothing to compare`)];
|
|
35686
35706
|
if (own.type !== "select_record_link" || theirs.type !== "select_record_link" || own.target_entity !== theirs.target_entity) {
|
|
@@ -35689,14 +35709,14 @@ function checkSame(entity, field, same, byAlias, path15) {
|
|
|
35689
35709
|
return [];
|
|
35690
35710
|
});
|
|
35691
35711
|
}
|
|
35692
|
-
function checkSuggest(entity, field, stated, byAlias,
|
|
35693
|
-
if (field.type !== "text") return [error51("write.suggest",
|
|
35712
|
+
function checkSuggest(entity, field, stated, byAlias, path16) {
|
|
35713
|
+
if (field.type !== "text") return [error51("write.suggest", path16, `${field.label} is a ${field.type} field \u2014 \`suggest\` offers values to a text field a person types`)];
|
|
35694
35714
|
const ref = suggestRef(stated);
|
|
35695
35715
|
const catalog = byAlias.get(ref.entity);
|
|
35696
|
-
if (catalog === void 0) return [error51("model.names-declared",
|
|
35716
|
+
if (catalog === void 0) return [error51("model.names-declared", path16, `names entity "${ref.entity}", which this model does not declare`)];
|
|
35697
35717
|
const source = catalog.fields.find((one) => one.alias === ref.field);
|
|
35698
|
-
if (source === void 0) return [error51("model.names-declared",
|
|
35699
|
-
if (source.type !== "text") return [error51("write.suggest",
|
|
35718
|
+
if (source === void 0) return [error51("model.names-declared", path16, `names "${ref.field}", which is not a field of ${catalog.label}`)];
|
|
35719
|
+
if (source.type !== "text") return [error51("write.suggest", path16, `offers the values of "${source.label}", a ${source.type} field of ${catalog.label} \u2014 the values offered are a stored text field's`)];
|
|
35700
35720
|
return [];
|
|
35701
35721
|
}
|
|
35702
35722
|
function checkNoOverlap(entity, rule, at2) {
|
|
@@ -35757,50 +35777,50 @@ function checkWriteRules(entities, rules) {
|
|
|
35757
35777
|
}
|
|
35758
35778
|
}
|
|
35759
35779
|
for (const [fieldAlias2, rule] of Object.entries(entry.fields ?? {})) {
|
|
35760
|
-
const
|
|
35780
|
+
const path16 = `${at2}.fields.${fieldAlias2}`;
|
|
35761
35781
|
const field = fieldByAlias.get(fieldAlias2);
|
|
35762
35782
|
if (field === void 0) {
|
|
35763
|
-
findings.push(error51("model.names-declared",
|
|
35783
|
+
findings.push(error51("model.names-declared", path16, `names "${fieldAlias2}", which is not a field of ${entity.label}`));
|
|
35764
35784
|
continue;
|
|
35765
35785
|
}
|
|
35766
35786
|
if (rule.default_from !== void 0) {
|
|
35767
|
-
findings.push(...checkDefaultFrom(entity, field, rule.default_from, byAlias, `${
|
|
35787
|
+
findings.push(...checkDefaultFrom(entity, field, rule.default_from, byAlias, `${path16}.default_from`));
|
|
35768
35788
|
}
|
|
35769
35789
|
const bounded = resolvedFieldType(field, entity, entities);
|
|
35770
35790
|
if ((rule.min !== void 0 || rule.max !== void 0) && !BOUNDED_TYPES.includes(bounded ?? field.type)) {
|
|
35771
|
-
findings.push(error51("write.bounds",
|
|
35791
|
+
findings.push(error51("write.bounds", path16, `${field.label} is a ${field.type} field \u2014 \`min\`/\`max\` bound a number or a date`));
|
|
35772
35792
|
}
|
|
35773
35793
|
for (const side of ["min", "max"]) {
|
|
35774
35794
|
const bound = rule[side];
|
|
35775
35795
|
if (typeof bound === "number" && bounded === "date") {
|
|
35776
|
-
findings.push(error51("write.bounds", `${
|
|
35796
|
+
findings.push(error51("write.bounds", `${path16}.${side}`, `bounds the date ${field.label} by a number \u2014 a date is bounded by a date field`));
|
|
35777
35797
|
}
|
|
35778
35798
|
const perRow = typeof bound === "number" && bound !== 0 ? contractUnitSource(field, entity, entities) : void 0;
|
|
35779
35799
|
if (perRow !== void 0) {
|
|
35780
|
-
findings.push(error51("write.bound-per-row-unit", `${
|
|
35800
|
+
findings.push(error51("write.bound-per-row-unit", `${path16}.${side}`, `bounds ${field.label} by ${String(bound)}, which is in no ${perRow.vocabulary}, and each row reads it in the ${perRow.vocabulary} its ${unitSourceName(perRow)} holds \u2014 only 0 is the same in every ${perRow.vocabulary}`));
|
|
35781
35801
|
}
|
|
35782
35802
|
const ref = boundRef(bound);
|
|
35783
|
-
if (ref !== void 0) findings.push(...checkBoundRef(entity, field, ref, byAlias, `${
|
|
35803
|
+
if (ref !== void 0) findings.push(...checkBoundRef(entity, field, ref, byAlias, `${path16}.${side}`));
|
|
35784
35804
|
}
|
|
35785
35805
|
if (typeof rule.min === "number" && typeof rule.max === "number" && rule.min > rule.max) {
|
|
35786
|
-
findings.push(error51("write.bounds", `${
|
|
35806
|
+
findings.push(error51("write.bounds", `${path16}.min`, `is above \`max\` (${rule.min} > ${rule.max}) \u2014 the bound refuses every figure`));
|
|
35787
35807
|
}
|
|
35788
|
-
if (rule.same !== void 0) findings.push(...checkSame(entity, field, rule.same, byAlias, `${
|
|
35789
|
-
if (rule.suggest !== void 0) findings.push(...checkSuggest(entity, field, rule.suggest, byAlias, `${
|
|
35808
|
+
if (rule.same !== void 0) findings.push(...checkSame(entity, field, rule.same, byAlias, `${path16}.same`));
|
|
35809
|
+
if (rule.suggest !== void 0) findings.push(...checkSuggest(entity, field, rule.suggest, byAlias, `${path16}.suggest`));
|
|
35790
35810
|
if (rule.options_where === void 0) continue;
|
|
35791
35811
|
if (field.type !== "select_record_link") {
|
|
35792
|
-
findings.push(error51("write.options-where", `${
|
|
35812
|
+
findings.push(error51("write.options-where", `${path16}.options_where`, `${field.label} is a ${field.type} field \u2014 \`options_where\` narrows the rows a LINK may point at`));
|
|
35793
35813
|
continue;
|
|
35794
35814
|
}
|
|
35795
35815
|
const target = byAlias.get(field.target_entity);
|
|
35796
35816
|
if (target === void 0) continue;
|
|
35797
35817
|
const conditions = optionsWhereConditions(rule.options_where, target);
|
|
35798
35818
|
if (conditions === void 0) {
|
|
35799
|
-
findings.push(error51("write.options-where", `${
|
|
35819
|
+
findings.push(error51("write.options-where", `${path16}.options_where`, `is not a narrowing this clause can both filter a picker by and refuse a write with: an \`and\` group of plain conditions over ${target.label}'s own fields, each \`field_key\` a field alias and each operator one of ${Object.entries(OPTIONS_WHERE_OPERATORS).map(([type, ops]) => `${type} ${ops.join("/")}`).join("; ")}`));
|
|
35800
35820
|
continue;
|
|
35801
35821
|
}
|
|
35802
35822
|
for (const condition of conditions) {
|
|
35803
|
-
const at3 = `${
|
|
35823
|
+
const at3 = `${path16}.options_where`;
|
|
35804
35824
|
const perRow = condition.type === "number" ? contractUnitSource(condition.field, target, entities) : void 0;
|
|
35805
35825
|
if (perRow !== void 0) {
|
|
35806
35826
|
findings.push(error51("write.options-where", at3, `compares "${condition.field.label}", which each row reads in the ${perRow.vocabulary} its ${unitSourceName(perRow)} holds, with a bare number \u2014 a narrowing compares a figure read in one ${perRow.vocabulary} for every row`));
|
|
@@ -35824,42 +35844,42 @@ function checkWriteRules(entities, rules) {
|
|
|
35824
35844
|
}
|
|
35825
35845
|
return findings;
|
|
35826
35846
|
}
|
|
35827
|
-
function checkDefaultFrom(entity, field, stated, byAlias,
|
|
35847
|
+
function checkDefaultFrom(entity, field, stated, byAlias, path16) {
|
|
35828
35848
|
const entities = [...byAlias.values()];
|
|
35829
35849
|
const [linkAlias, sourceAlias] = stated.split(".");
|
|
35830
35850
|
const link = entity.fields.find((one) => one.alias === linkAlias);
|
|
35831
35851
|
if (link === void 0 || link.type !== "select_record_link") {
|
|
35832
35852
|
return [
|
|
35833
|
-
error51("write.default-from",
|
|
35853
|
+
error51("write.default-from", path16, `names "${linkAlias}", which is not a link field of ${entity.label} \u2014 a default is copied from a row this one points at`)
|
|
35834
35854
|
];
|
|
35835
35855
|
}
|
|
35836
35856
|
if (link.cardinality !== "one") {
|
|
35837
35857
|
return [
|
|
35838
|
-
error51("write.default-from",
|
|
35858
|
+
error51("write.default-from", path16, `reads "${link.label}", which holds MANY rows \u2014 a default copied from a list would take whichever row came back first`)
|
|
35839
35859
|
];
|
|
35840
35860
|
}
|
|
35841
35861
|
const target = byAlias.get(link.target_entity);
|
|
35842
35862
|
const source = target?.fields.find((one) => one.alias === sourceAlias);
|
|
35843
35863
|
if (target === void 0 || source === void 0) {
|
|
35844
35864
|
return [
|
|
35845
|
-
error51("model.names-declared",
|
|
35865
|
+
error51("model.names-declared", path16, `names "${sourceAlias}", which is not a field of entity "${link.target_entity}"`)
|
|
35846
35866
|
];
|
|
35847
35867
|
}
|
|
35848
35868
|
const from = resolvedFieldType(source, target, entities);
|
|
35849
35869
|
const into = resolvedFieldType(field, entity, entities);
|
|
35850
35870
|
if (from !== into) {
|
|
35851
35871
|
return [
|
|
35852
|
-
error51("write.default-from",
|
|
35872
|
+
error51("write.default-from", path16, `copies ${target.label}'s "${source.label}" (${String(from)}) into a ${String(into)} field`)
|
|
35853
35873
|
];
|
|
35854
35874
|
}
|
|
35855
35875
|
if (source.type !== "select" || field.type !== "select") return [];
|
|
35856
35876
|
const findings = [];
|
|
35857
35877
|
if (source.multi === true && field.multi !== true) {
|
|
35858
|
-
findings.push(error51("write.default-from",
|
|
35878
|
+
findings.push(error51("write.default-from", path16, `copies ${target.label}'s "${source.label}", which holds several options, into ${field.label}, which holds one \u2014 a copy would keep whichever came first`));
|
|
35859
35879
|
}
|
|
35860
35880
|
const missing = source.options.filter((option) => !field.options.some((one) => one.alias === option.alias));
|
|
35861
35881
|
if (missing.length > 0) {
|
|
35862
|
-
findings.push(error51("write.default-from",
|
|
35882
|
+
findings.push(error51("write.default-from", path16, `copies ${target.label}'s "${source.label}" into ${field.label}, which declares no option ${missing.map((option) => `"${option.alias}"`).join(", ")} \u2014 a copied option is written as the one of the same alias`));
|
|
35863
35883
|
}
|
|
35864
35884
|
return findings;
|
|
35865
35885
|
}
|
|
@@ -36863,28 +36883,28 @@ var RECORDS_INPUT = "record_ids";
|
|
|
36863
36883
|
var TEMPLATES_INPUT = "templates";
|
|
36864
36884
|
|
|
36865
36885
|
// ../shared/src/schemas/app_model_validate_acts.ts
|
|
36866
|
-
function checkChildCheck(model, check2, children, acts,
|
|
36886
|
+
function checkChildCheck(model, check2, children, acts, path16) {
|
|
36867
36887
|
const child = check2.of === void 0 ? void 0 : entityOf(model, check2.of);
|
|
36868
36888
|
if (child === void 0 || !children.has(child.alias)) {
|
|
36869
|
-
return [error51("check.of", `${
|
|
36889
|
+
return [error51("check.of", `${path16}.of`, `names "${String(check2.of)}", which this record lists no rows of \u2014 a check of a child is of rows the record lists`)];
|
|
36870
36890
|
}
|
|
36871
36891
|
const field = fieldOf(child, check2.field);
|
|
36872
|
-
if (field === void 0) return [error51("model.names-declared", `${
|
|
36892
|
+
if (field === void 0) return [error51("model.names-declared", `${path16}.field`, `names "${check2.field}", which is not a field of "${child.alias}"`)];
|
|
36873
36893
|
const result = field.type === "formula" ? resolvedFieldType(field, child, model.entities) : void 0;
|
|
36874
36894
|
const findings = [];
|
|
36875
36895
|
if (result === void 0 || !["boolean", "text"].includes(result)) {
|
|
36876
|
-
findings.push(error51("check.field", `${
|
|
36896
|
+
findings.push(error51("check.field", `${path16}.field`, `"${check2.field}" is a ${field.type} field \u2014 a check is a yes/no or text formula that stands while true or non-empty`));
|
|
36877
36897
|
}
|
|
36878
|
-
findings.push(...checkRestatesMeter(model, child, check2,
|
|
36898
|
+
findings.push(...checkRestatesMeter(model, child, check2, path16));
|
|
36879
36899
|
if (check2.blocks !== void 0 && check2.blocks !== "all") {
|
|
36880
36900
|
for (const [index, target] of check2.blocks.entries()) {
|
|
36881
36901
|
if (target === "edit" || target === "delete" || acts.get(target) === child.alias) continue;
|
|
36882
|
-
findings.push(error51("check.blocks", `${
|
|
36902
|
+
findings.push(error51("check.blocks", `${path16}.blocks.${index}`, `names "${target}" \u2014 a check of a child row refuses its saves ("edit"), its Delete ("delete"), an act of "${child.alias}" or "all"`));
|
|
36883
36903
|
}
|
|
36884
36904
|
}
|
|
36885
36905
|
return findings;
|
|
36886
36906
|
}
|
|
36887
|
-
function checkRestatesMeter(model, entity, check2,
|
|
36907
|
+
function checkRestatesMeter(model, entity, check2, path16) {
|
|
36888
36908
|
const display = model.records?.[entity.alias];
|
|
36889
36909
|
const figure = display?.figure;
|
|
36890
36910
|
const bound = figure === void 0 ? void 0 : display?.limits?.[figure];
|
|
@@ -36897,7 +36917,7 @@ function checkRestatesMeter(model, entity, check2, path15) {
|
|
|
36897
36917
|
return [
|
|
36898
36918
|
error51(
|
|
36899
36919
|
"check.restates-meter",
|
|
36900
|
-
`${
|
|
36920
|
+
`${path16}.field`,
|
|
36901
36921
|
`"${check2.field}" reads "${figure}" against its pass mark ${pass}, which the figure's meter already draws \u2014 named under its track, amber until reached; drop the check`
|
|
36902
36922
|
)
|
|
36903
36923
|
];
|
|
@@ -36922,9 +36942,9 @@ function checkAct(model, entity, act, at2) {
|
|
|
36922
36942
|
if (act.on === "view" && (act.requires ?? []).length > 0) {
|
|
36923
36943
|
findings.push(error51("act.view", `${at2}.requires`, "runs over every row in view, and a field one row lacks would refuse them all \u2014 state a `when` the view is narrowed to, or run it on each record"));
|
|
36924
36944
|
}
|
|
36925
|
-
const own = (name,
|
|
36945
|
+
const own = (name, path16) => {
|
|
36926
36946
|
const found = fieldOf(entity, name);
|
|
36927
|
-
if (found === void 0) findings.push(error51("model.names-declared",
|
|
36947
|
+
if (found === void 0) findings.push(error51("model.names-declared", path16, `names "${name}", which is not a field of "${entity.alias}"`));
|
|
36928
36948
|
return found;
|
|
36929
36949
|
};
|
|
36930
36950
|
findings.push(...checkWhen(model.entities, entity, act.when, `${at2}.when`, "offer"));
|
|
@@ -36966,17 +36986,17 @@ function checkAct(model, entity, act, at2) {
|
|
|
36966
36986
|
}
|
|
36967
36987
|
function checkTemplates(model, entity, act, at2) {
|
|
36968
36988
|
const templates = act.templates ?? [];
|
|
36969
|
-
const
|
|
36989
|
+
const path16 = `${at2}.templates`;
|
|
36970
36990
|
const findings = [];
|
|
36971
|
-
if (act.template !== void 0) findings.push(error51("act.templates",
|
|
36972
|
-
if (act.into === void 0) findings.push(error51("act.templates",
|
|
36973
|
-
if (act.workflow !== void 0) findings.push(error51("act.makes-or-runs",
|
|
36974
|
-
if (act.of !== void 0 || (act.on ?? "record") !== "record") findings.push(error51("act.templates",
|
|
36991
|
+
if (act.template !== void 0) findings.push(error51("act.templates", path16, "states `template` too \u2014 one paper is a `template`, several are `templates`; keep one"));
|
|
36992
|
+
if (act.into === void 0) findings.push(error51("act.templates", path16, "keeps its papers in no field \u2014 name the files field of the record they are kept in (`into`)"));
|
|
36993
|
+
if (act.workflow !== void 0) findings.push(error51("act.makes-or-runs", path16, "makes papers and runs an authored workflow \u2014 the workflow makes what it needs"));
|
|
36994
|
+
if (act.of !== void 0 || (act.on ?? "record") !== "record") findings.push(error51("act.templates", path16, "makes a set of papers over several rows or a child's \u2014 a set is one record's, kept in its `into`"));
|
|
36975
36995
|
if (act.asks !== void 0) {
|
|
36976
36996
|
findings.push(error51("act.templates", `${at2}.asks`, `makes a set of papers and asks \u2014 its press is the papers ticked (sent as "${TEMPLATES_INPUT}"); place what it needs in its section and \`requires\` it`));
|
|
36977
36997
|
}
|
|
36978
36998
|
for (const [index, one] of templates.entries()) {
|
|
36979
|
-
const each = `${
|
|
36999
|
+
const each = `${path16}.${index}`;
|
|
36980
37000
|
findings.push(...checkTemplate(model, one.template, `${each}.template`), ...checkWhen(model.entities, entity, one.when, `${each}.when`, "offer"));
|
|
36981
37001
|
if (templates.findIndex((other) => other.template === one.template) !== index) findings.push(error51("act.templates", `${each}.template`, `names "${one.template}" twice \u2014 each template is one paper of the set`));
|
|
36982
37002
|
}
|
|
@@ -37077,53 +37097,53 @@ function checkIntake(model, app, act, at2) {
|
|
|
37077
37097
|
if (act.fills === void 0) findings.push(error51("act.intake", at2, "reads papers and fills nothing \u2014 name what they fill in `fills`"));
|
|
37078
37098
|
findings.push(...checkPapers(model, app, record2, act.intake, `${at2}.intake`));
|
|
37079
37099
|
for (const [index, name] of (act.fills ?? []).entries()) {
|
|
37080
|
-
const
|
|
37081
|
-
if ((act.fills ?? []).indexOf(name) !== index) findings.push(error51("act.fills",
|
|
37082
|
-
findings.push(...checkFill(model, app, record2, name,
|
|
37100
|
+
const path16 = `${at2}.fills.${index}`;
|
|
37101
|
+
if ((act.fills ?? []).indexOf(name) !== index) findings.push(error51("act.fills", path16, `names "${name}" twice \u2014 each is filled once`));
|
|
37102
|
+
findings.push(...checkFill(model, app, record2, name, path16));
|
|
37083
37103
|
}
|
|
37084
37104
|
return findings;
|
|
37085
37105
|
}
|
|
37086
|
-
function checkPapers(model, app, record2, name,
|
|
37106
|
+
function checkPapers(model, app, record2, name, path16) {
|
|
37087
37107
|
const child = entityOf(model, name);
|
|
37088
|
-
if (child === void 0) return [error51("model.names-declared",
|
|
37108
|
+
if (child === void 0) return [error51("model.names-declared", path16, `names "${name}", which this model does not declare`)];
|
|
37089
37109
|
const title = model.records?.[child.alias]?.title;
|
|
37090
37110
|
const kind = title === void 0 ? void 0 : fieldOf(child, title);
|
|
37091
37111
|
const block = recordBlocks(app.record).find((one) => isRowsBlock(one) && one.rows === child.alias && one.expect !== void 0 && one.expect === title);
|
|
37092
37112
|
if (block === void 0 || kind === void 0 || !isSingleSelect(kind)) {
|
|
37093
|
-
return [error51("act.intake",
|
|
37113
|
+
return [error51("act.intake", path16, `"${name}" is listed in no rows block that expects its title as a single select \u2014 each paper is a line of its kind under the record: \`"expect": "<its title>"\``)];
|
|
37094
37114
|
}
|
|
37095
37115
|
const findings = [];
|
|
37096
|
-
if (block.where !== void 0) findings.push(error51("act.intake",
|
|
37116
|
+
if (block.where !== void 0) findings.push(error51("act.intake", path16, `"${name}" is listed narrowed by \`where\` \u2014 a paper of any kind is filed; drop the \`where\``));
|
|
37097
37117
|
const files = child.fields.filter((field) => field.type === "files" && isAsked(field));
|
|
37098
|
-
if (files.length !== 1) findings.push(error51("act.intake",
|
|
37118
|
+
if (files.length !== 1) findings.push(error51("act.intake", path16, `"${name}" holds ${files.length} files fields \u2014 a paper's file is kept in the one it holds`));
|
|
37099
37119
|
const link = childLink(child, record2.alias, block.via);
|
|
37100
37120
|
const starts = Object.keys(model.records?.[child.alias]?.starts ?? {});
|
|
37101
37121
|
const filled = ["link" in link ? link.link : "", kind.alias, ...files.map((field) => field.alias), ...starts];
|
|
37102
37122
|
for (const needed of requiredOnCreate(model, child, filled)) {
|
|
37103
|
-
findings.push(error51("act.intake",
|
|
37123
|
+
findings.push(error51("act.intake", path16, `a paper's line is made from its kind and its file, and "${name}" requires "${needed}" too \u2014 give it a default`));
|
|
37104
37124
|
}
|
|
37105
37125
|
return findings;
|
|
37106
37126
|
}
|
|
37107
|
-
function checkFill(model, app, record2, name,
|
|
37127
|
+
function checkFill(model, app, record2, name, path16) {
|
|
37108
37128
|
const [head, tail] = name.split(".");
|
|
37109
37129
|
if (tail !== void 0) {
|
|
37110
37130
|
const link2 = fieldOf(record2, head);
|
|
37111
|
-
if (link2 === void 0) return [error51("model.names-declared",
|
|
37131
|
+
if (link2 === void 0) return [error51("model.names-declared", path16, `names "${head}", which is not a field of "${record2.alias}"`)];
|
|
37112
37132
|
if (link2.type !== "select_record_link" || link2.cardinality !== "one" || link2.required !== true) {
|
|
37113
|
-
return [error51("act.fills",
|
|
37133
|
+
return [error51("act.fills", path16, `"${head}" is not a required one-row link \u2014 papers fill the row one names only where the record always names one`)];
|
|
37114
37134
|
}
|
|
37115
37135
|
const party = entityOf(model, link2.target_entity);
|
|
37116
37136
|
if (party === void 0) return [];
|
|
37117
|
-
if (!peopleWrite(party)) return [error51("act.fills",
|
|
37118
|
-
return fillable(model, party, tail,
|
|
37137
|
+
if (!peopleWrite(party)) return [error51("act.fills", path16, `"${party.alias}" rows are written by no person, so no paper fills one`)];
|
|
37138
|
+
return fillable(model, party, tail, path16);
|
|
37119
37139
|
}
|
|
37120
37140
|
const listed = recordBlocks(app.record).filter((block2) => isRowsBlock(block2) && block2.rows === name);
|
|
37121
|
-
if (listed.length === 0) return fillable(model, record2, name,
|
|
37141
|
+
if (listed.length === 0) return fillable(model, record2, name, path16);
|
|
37122
37142
|
const child = entityOf(model, name);
|
|
37123
37143
|
const block = listed.find((one) => one.expect === void 0 && one.under === void 0 && one.where === void 0 && one.create !== false);
|
|
37124
37144
|
if (child === void 0) return [];
|
|
37125
37145
|
if (block === void 0 || !peopleWrite(child)) {
|
|
37126
|
-
return [error51("act.fills",
|
|
37146
|
+
return [error51("act.fills", path16, `"${name}" rows are added in no rows block without \`expect\`, \`under\` or \`where\` \u2014 papers add rows where the record lists them plainly`)];
|
|
37127
37147
|
}
|
|
37128
37148
|
const link = childLink(child, record2.alias, block.via);
|
|
37129
37149
|
if (!("link" in link)) return [];
|
|
@@ -37134,7 +37154,7 @@ function checkFill(model, app, record2, name, path15) {
|
|
|
37134
37154
|
});
|
|
37135
37155
|
const starts = Object.keys(model.records?.[child.alias]?.starts ?? {});
|
|
37136
37156
|
return requiredOnCreate(model, child, [link.link, ...filled, ...starts]).map(
|
|
37137
|
-
(needed) => error51("act.fills",
|
|
37157
|
+
(needed) => error51("act.fills", path16, `"${name}" rows need "${needed}", which their block does not add as a value papers fill \u2014 add it to the block's \`create\`, or give it a default`)
|
|
37138
37158
|
);
|
|
37139
37159
|
}
|
|
37140
37160
|
var RECORD_KEYS = /* @__PURE__ */ new Set(["alias", "label", "when", "requires", "record", "fills"]);
|
|
@@ -37144,25 +37164,25 @@ function checkRecord(model, app, act, at2) {
|
|
|
37144
37164
|
const record2 = entityOf(model, app.entity);
|
|
37145
37165
|
if (record2 === void 0) return [];
|
|
37146
37166
|
const findings = [];
|
|
37147
|
-
const
|
|
37167
|
+
const path16 = `${at2}.record`;
|
|
37148
37168
|
for (const key of Object.keys(act).filter((one) => !RECORD_KEYS.has(one))) {
|
|
37149
37169
|
findings.push(error51("act.record", `${at2}.${key}`, `states \`${key}\` beside \`record\` \u2014 an act recording a call files it under one record and fills what was said; drop it`));
|
|
37150
37170
|
}
|
|
37151
37171
|
const child = entityOf(model, recorded.into);
|
|
37152
|
-
if (child === void 0) return [...findings, error51("model.names-declared", `${
|
|
37172
|
+
if (child === void 0) return [...findings, error51("model.names-declared", `${path16}.into`, `names "${recorded.into}", which this model does not declare`)];
|
|
37153
37173
|
const listing = recordBlocks(app.record).filter((one) => (isRowsBlock(one) ? one.rows : "timeline" in one ? one.timeline : void 0) === child.alias);
|
|
37154
37174
|
if (listing.length === 0) {
|
|
37155
|
-
return [...findings, error51("act.record", `${
|
|
37175
|
+
return [...findings, error51("act.record", `${path16}.into`, `"${child.alias}" is drawn in no rows or timeline block of this record \u2014 a recording's row is seen where the record lists them`)];
|
|
37156
37176
|
}
|
|
37157
37177
|
const block = listing.find((one) => !isRowsBlock(one) || one.expect === void 0 && one.under === void 0 && one.where === void 0);
|
|
37158
37178
|
if (block === void 0) {
|
|
37159
|
-
return [...findings, error51("act.record", `${
|
|
37179
|
+
return [...findings, error51("act.record", `${path16}.into`, `"${child.alias}" is listed only with \`expect\`, \`under\` or \`where\` \u2014 a recording's row is drawn among every one the record lists`)];
|
|
37160
37180
|
}
|
|
37161
37181
|
const link = childLink(child, record2.alias, "via" in block ? block.via : void 0);
|
|
37162
|
-
if ("refused" in link) return [...findings, error51("act.record", `${
|
|
37182
|
+
if ("refused" in link) return [...findings, error51("act.record", `${path16}.into`, link.refused)];
|
|
37163
37183
|
const back = fieldOf(child, link.link);
|
|
37164
37184
|
if (back?.type !== "select_record_link" || back.cardinality !== "one") {
|
|
37165
|
-
findings.push(error51("act.record", `${
|
|
37185
|
+
findings.push(error51("act.record", `${path16}.into`, `"${child.alias}" links to "${record2.alias}" through "${link.link}", which holds several \u2014 a recording is filed under one record`));
|
|
37166
37186
|
}
|
|
37167
37187
|
const kinds = [
|
|
37168
37188
|
{ key: "audio", types: ["files"], what: "a files field" },
|
|
@@ -37177,16 +37197,16 @@ function checkRecord(model, app, act, at2) {
|
|
|
37177
37197
|
if (name === void 0) continue;
|
|
37178
37198
|
const field = fieldOf(child, name);
|
|
37179
37199
|
if (field === void 0) {
|
|
37180
|
-
findings.push(error51("model.names-declared", `${
|
|
37200
|
+
findings.push(error51("model.names-declared", `${path16}.${key}`, `names "${name}", which is not a field of "${child.alias}"`));
|
|
37181
37201
|
continue;
|
|
37182
37202
|
}
|
|
37183
|
-
if (!types.includes(field.type)) findings.push(error51("act.record", `${
|
|
37184
|
-
if (written.includes(name)) findings.push(error51("act.record", `${
|
|
37203
|
+
if (!types.includes(field.type)) findings.push(error51("act.record", `${path16}.${key}`, `"${name}" is a ${field.type} field \u2014 the recording's ${key} is kept in ${what}`));
|
|
37204
|
+
if (written.includes(name)) findings.push(error51("act.record", `${path16}.${key}`, `"${name}" is written twice by the recording \u2014 each is a field of its own`));
|
|
37185
37205
|
written.push(name);
|
|
37186
37206
|
}
|
|
37187
37207
|
const starts = Object.keys(model.records?.[child.alias]?.starts ?? {});
|
|
37188
37208
|
for (const needed of requiredOnCreate(model, child, [...written, ...starts])) {
|
|
37189
|
-
findings.push(error51("act.record", `${
|
|
37209
|
+
findings.push(error51("act.record", `${path16}.into`, `a recording's row is made from what was recorded, and "${child.alias}" requires "${needed}" too \u2014 give it a default`));
|
|
37190
37210
|
}
|
|
37191
37211
|
for (const [index, name] of (act.fills ?? []).entries()) {
|
|
37192
37212
|
const filled = `${at2}.fills.${index}`;
|
|
@@ -37199,11 +37219,11 @@ function checkRecord(model, app, act, at2) {
|
|
|
37199
37219
|
}
|
|
37200
37220
|
return findings;
|
|
37201
37221
|
}
|
|
37202
|
-
function fillable(model, entity, name,
|
|
37222
|
+
function fillable(model, entity, name, path16) {
|
|
37203
37223
|
const field = fieldOf(entity, name);
|
|
37204
|
-
if (field === void 0) return [error51("model.names-declared",
|
|
37224
|
+
if (field === void 0) return [error51("model.names-declared", path16, `names "${name}", which is not a field of "${entity.alias}"`)];
|
|
37205
37225
|
const why = unfillable(model, entity, field);
|
|
37206
|
-
return why === void 0 ? [] : [error51("act.fills",
|
|
37226
|
+
return why === void 0 ? [] : [error51("act.fills", path16, `"${entity.alias}.${name}" is ${why}`)];
|
|
37207
37227
|
}
|
|
37208
37228
|
function noteIntake(model, app, resolved, at2) {
|
|
37209
37229
|
const record2 = entityOf(model, app.entity);
|
|
@@ -37226,12 +37246,12 @@ function noteIntake(model, app, resolved, at2) {
|
|
|
37226
37246
|
if (intake === void 0) return [];
|
|
37227
37247
|
const children = new Set(intake.rows.map((one) => one.entity));
|
|
37228
37248
|
return (app.acts?.[index]?.fills ?? []).flatMap((name, order) => {
|
|
37229
|
-
const
|
|
37249
|
+
const path16 = `${at2}.acts.${index}.fills.${order}`;
|
|
37230
37250
|
if (children.has(name)) {
|
|
37231
37251
|
const keyed = intake.rows.find((one) => one.entity === name)?.key.length ?? 0;
|
|
37232
|
-
return keyed > 0 ? [] : [note("act.fills-new",
|
|
37252
|
+
return keyed > 0 ? [] : [note("act.fills-new", path16, `"${name}" rows are only added: the papers fill no natural key to find one already listed \u2014 state \`natural_key\` on "${name}" of fields its block adds`)];
|
|
37233
37253
|
}
|
|
37234
|
-
return drawn(name) ? [] : [note("act.fills-drawn",
|
|
37254
|
+
return drawn(name) ? [] : [note("act.fills-drawn", path16, `"${name}" is drawn nowhere on the record \u2014 the review shows what it becomes, the record does not`)];
|
|
37235
37255
|
});
|
|
37236
37256
|
});
|
|
37237
37257
|
}
|
|
@@ -37243,7 +37263,7 @@ function offeredTogether2(one, other) {
|
|
|
37243
37263
|
return held.some((option) => theirs.includes(option));
|
|
37244
37264
|
});
|
|
37245
37265
|
}
|
|
37246
|
-
function checkFootNeeds(model, entity, section, drawn, act,
|
|
37266
|
+
function checkFootNeeds(model, entity, section, drawn, act, path16) {
|
|
37247
37267
|
const asked = new Set((act.asks ?? []).filter((ask) => typeof ask === "string"));
|
|
37248
37268
|
const writers = registerApps(model).flatMap((app) => (app.acts ?? []).filter((one) => actEntity(app, one) === entity.alias));
|
|
37249
37269
|
const writtenBy = (one) => [...Object.keys(one.set ?? {}), ...(one.asks ?? []).filter((ask) => typeof ask === "string"), ...one.into === void 0 ? [] : [one.into]];
|
|
@@ -37251,7 +37271,7 @@ function checkFootNeeds(model, entity, section, drawn, act, path15) {
|
|
|
37251
37271
|
return (act.requires ?? []).flatMap((name, index) => {
|
|
37252
37272
|
const field = fieldOf(entity, name);
|
|
37253
37273
|
if (asked.has(name) || field === void 0 || !isAsked(field) && !owned.has(name)) return [];
|
|
37254
|
-
const at2 = `${
|
|
37274
|
+
const at2 = `${path16}.requires.${index}`;
|
|
37255
37275
|
if (!drawn.has(name)) {
|
|
37256
37276
|
return [error51("act.requires-drawn", at2, `"${act.alias}" needs "${name}", which "${section.title}" does not draw \u2014 its line would name a field the reader cannot see there; place it in the section, or have the act ask it`)];
|
|
37257
37277
|
}
|
|
@@ -38005,6 +38025,7 @@ function resolveModelApp(given, app) {
|
|
|
38005
38025
|
register: {
|
|
38006
38026
|
columns: app.register?.columns ?? [],
|
|
38007
38027
|
filters: app.register?.filters ?? [],
|
|
38028
|
+
...app.register?.book === void 0 ? {} : { book: app.register.book },
|
|
38008
38029
|
...app.register?.search === void 0 ? {} : { search: app.register.search },
|
|
38009
38030
|
...sort === void 0 ? {} : { sort },
|
|
38010
38031
|
...app.register?.group === void 0 ? {} : { group: app.register.group },
|
|
@@ -38404,47 +38425,58 @@ function expectedColumns(model, child, columns) {
|
|
|
38404
38425
|
// ../shared/src/schemas/app_model_validate_books.ts
|
|
38405
38426
|
function checkBookEntities(model, app, at2) {
|
|
38406
38427
|
const seen = /* @__PURE__ */ new Set();
|
|
38407
|
-
|
|
38408
|
-
|
|
38428
|
+
const bookless = app.register?.book === true && (app.books ?? []).length === 0 ? [error51("app.books", `${at2}.register.book`, "names the book of each row, but the app reads no `books`")] : [];
|
|
38429
|
+
return [...bookless, ...(app.books ?? []).flatMap((book, index) => {
|
|
38430
|
+
const path16 = `${at2}.books.${index}.entity`;
|
|
38409
38431
|
const findings = [];
|
|
38410
|
-
if (entityOf(model, book.entity) === void 0) findings.push(error51("model.names-declared",
|
|
38411
|
-
if (book.entity === app.entity) findings.push(error51("app.books",
|
|
38412
|
-
if (seen.has(book.entity)) findings.push(error51("app.books",
|
|
38432
|
+
if (entityOf(model, book.entity) === void 0) findings.push(error51("model.names-declared", path16, `names entity "${book.entity}", which this model does not declare`));
|
|
38433
|
+
if (book.entity === app.entity) findings.push(error51("app.books", path16, `names "${book.entity}", the app's own entity \u2014 a book is another entity's rows`));
|
|
38434
|
+
if (seen.has(book.entity)) findings.push(error51("app.books", path16, `names "${book.entity}" twice \u2014 each book is read once`));
|
|
38413
38435
|
seen.add(book.entity);
|
|
38414
38436
|
return findings;
|
|
38415
|
-
});
|
|
38437
|
+
})];
|
|
38416
38438
|
}
|
|
38417
38439
|
function checkBookFields(model, app, book, reads, at2) {
|
|
38418
38440
|
const main2 = entityOf(model, app.entity);
|
|
38419
38441
|
const entity = entityOf(model, book.entity);
|
|
38420
38442
|
const stated = app.books?.[book.index];
|
|
38421
38443
|
if (main2 === void 0 || entity === void 0 || stated === void 0) return [];
|
|
38422
|
-
const
|
|
38444
|
+
const path16 = `${at2}.books.${book.index}`;
|
|
38423
38445
|
const read = /* @__PURE__ */ new Set([...reads, ...book.named]);
|
|
38424
38446
|
const mappable = /* @__PURE__ */ new Set([...read, ...exportsBooks(app) ? exportPrinted(model, main2.alias) : []]);
|
|
38425
38447
|
const findings = [];
|
|
38426
38448
|
for (const [alias2, held] of Object.entries(stated.fields ?? {})) {
|
|
38427
|
-
if (fieldOf(main2, alias2) === void 0) findings.push(error51("model.names-declared", `${
|
|
38428
|
-
else if (!mappable.has(alias2)) findings.push(error51("app.books", `${
|
|
38429
|
-
if (fieldOf(entity, held) === void 0) findings.push(error51("model.names-declared", `${
|
|
38449
|
+
if (fieldOf(main2, alias2) === void 0) findings.push(error51("model.names-declared", `${path16}.fields.${alias2}`, `maps "${alias2}", which is not a field of "${main2.alias}"`));
|
|
38450
|
+
else if (!mappable.has(alias2)) findings.push(error51("app.books", `${path16}.fields.${alias2}`, `maps "${alias2}", which the app does not read`));
|
|
38451
|
+
if (fieldOf(entity, held) === void 0) findings.push(error51("model.names-declared", `${path16}.fields.${alias2}`, `maps "${alias2}" to "${held}", which is not a field of "${entity.alias}"`));
|
|
38430
38452
|
}
|
|
38431
38453
|
for (const alias2 of read) {
|
|
38432
38454
|
const own = fieldOf(main2, alias2);
|
|
38433
38455
|
const lined = book.lineUp(alias2);
|
|
38434
38456
|
const field = lined === void 0 ? void 0 : fieldOf(entity, lined);
|
|
38435
38457
|
const apart = own === void 0 || field === void 0 ? void 0 : fieldMisfit(model, { entity: main2, field: own }, { entity, field });
|
|
38436
|
-
if (apart !== void 0) findings.push(error51("app.books",
|
|
38458
|
+
if (apart !== void 0) findings.push(error51("app.books", path16, `reads "${alias2}" as "${entity.alias}.${field?.alias ?? alias2}", which ${apart} \u2014 a row of either stands in one column`));
|
|
38459
|
+
const options = own === void 0 ? void 0 : contractSelectOptions(own);
|
|
38460
|
+
for (const option of field === void 0 ? [] : contractSelectOptions(field) ?? []) {
|
|
38461
|
+
const kin = options?.find((one) => one.alias !== option.alias && sameLabel(one.label, option.label));
|
|
38462
|
+
if (kin !== void 0 && !options?.some((one) => one.alias === option.alias)) {
|
|
38463
|
+
findings.push(note("app.book-option-kin", path16, `holds "${option.label}" of "${alias2}" as "${option.alias}", which "${main2.alias}" holds as "${kin.alias}"`));
|
|
38464
|
+
}
|
|
38465
|
+
}
|
|
38437
38466
|
}
|
|
38438
38467
|
return findings;
|
|
38439
38468
|
}
|
|
38440
|
-
function
|
|
38469
|
+
function sameLabel(a, b) {
|
|
38470
|
+
return a.trim().toLocaleLowerCase() === b.trim().toLocaleLowerCase();
|
|
38471
|
+
}
|
|
38472
|
+
function checkReportBooks(model, app, report, path16) {
|
|
38441
38473
|
const named = report.books;
|
|
38442
38474
|
const main2 = entityOf(model, app.entity);
|
|
38443
38475
|
if (named === void 0 || main2 === void 0) return [];
|
|
38444
38476
|
const findings = [];
|
|
38445
38477
|
const seen = /* @__PURE__ */ new Set();
|
|
38446
38478
|
for (const [index, name] of named.entries()) {
|
|
38447
|
-
const at2 = `${
|
|
38479
|
+
const at2 = `${path16}.books.${index}`;
|
|
38448
38480
|
if (name !== app.entity && !(app.books ?? []).some((book) => book.entity === name)) findings.push(error51("app.books", at2, `names "${name}", which is neither "${app.entity}" nor one of the app's books`));
|
|
38449
38481
|
if (seen.has(name)) findings.push(error51("app.books", at2, `names "${name}" twice \u2014 each book is read once`));
|
|
38450
38482
|
seen.add(name);
|
|
@@ -38456,7 +38488,7 @@ function checkReportBooks(model, app, report, path15) {
|
|
|
38456
38488
|
return stated !== void 0 && entity !== void 0 && bookField(main2, entity, stated, alias2) !== void 0;
|
|
38457
38489
|
};
|
|
38458
38490
|
for (const alias2 of [...Object.keys(report.where ?? {}), ...report.per === void 0 ? [] : [report.per]]) {
|
|
38459
|
-
if (!named.some((name) => holds(name, alias2))) findings.push(error51("app.books",
|
|
38491
|
+
if (!named.some((name) => holds(name, alias2))) findings.push(error51("app.books", path16, `narrows by "${alias2}", which none of ${named.map((one) => `"${one}"`).join(", ")} holds \u2014 no row of the report would stand in its chip`));
|
|
38460
38492
|
}
|
|
38461
38493
|
return findings;
|
|
38462
38494
|
}
|
|
@@ -38551,18 +38583,18 @@ function checkRegisterApp(model, app, at2) {
|
|
|
38551
38583
|
function noteTasks(model, resolved, at2) {
|
|
38552
38584
|
return Object.entries(resolved.tasks).flatMap(([entity, task]) => taskNote(model, entity, task, at2));
|
|
38553
38585
|
}
|
|
38554
|
-
function taskNote(model, entity, task,
|
|
38586
|
+
function taskNote(model, entity, task, path16) {
|
|
38555
38587
|
if (task.close === void 0) {
|
|
38556
38588
|
const status = model.records?.[entity]?.status?.field ?? "";
|
|
38557
38589
|
return [
|
|
38558
38590
|
note(
|
|
38559
38591
|
"app.tasks",
|
|
38560
|
-
|
|
38592
|
+
path16,
|
|
38561
38593
|
`"${entity}" rows are drawn as tasks and no write of this app moves one to a closed option \u2014 their ring stands disabled: add an act of "${entity}" setting "${status}" to one, or let people write it`
|
|
38562
38594
|
)
|
|
38563
38595
|
];
|
|
38564
38596
|
}
|
|
38565
|
-
return task.close.act !== void 0 && task.reopen === void 0 ? [note("app.tasks",
|
|
38597
|
+
return task.close.act !== void 0 && task.reopen === void 0 ? [note("app.tasks", path16, `"${entity}" rows ticked done stay done: no act of this app moves one back from a closed option`)] : [];
|
|
38566
38598
|
}
|
|
38567
38599
|
function checkReadsRows(model, reads, entities, at2) {
|
|
38568
38600
|
const findings = [];
|
|
@@ -38627,7 +38659,7 @@ function checkCreates(model, resolved, at2) {
|
|
|
38627
38659
|
])
|
|
38628
38660
|
)
|
|
38629
38661
|
];
|
|
38630
|
-
return lists.flatMap(({ entity, asked, fills: filed, path:
|
|
38662
|
+
return lists.flatMap(({ entity, asked, fills: filed, path: path16 }) => {
|
|
38631
38663
|
const declared = entityOf(model, entity);
|
|
38632
38664
|
if (declared === void 0) return [];
|
|
38633
38665
|
const fills = [...filed, ...Object.keys(model.records?.[entity]?.starts ?? {})];
|
|
@@ -38637,16 +38669,16 @@ function checkCreates(model, resolved, at2) {
|
|
|
38637
38669
|
return by === void 0 ? void 0 : `apps.${by.app}.acts.${by.act}.set.${name}`;
|
|
38638
38670
|
};
|
|
38639
38671
|
return [
|
|
38640
|
-
...checkCreateMilestones(model, entity, asked,
|
|
38672
|
+
...checkCreateMilestones(model, entity, asked, path16),
|
|
38641
38673
|
// An act's outcome is the act's to write: an add stating it would decide what only the act decides. What the
|
|
38642
38674
|
// add fills itself (the record, a document's lines) is no reader's statement.
|
|
38643
38675
|
...asked.filter((name) => !fills.includes(name)).flatMap((name) => {
|
|
38644
38676
|
const act = setter(name);
|
|
38645
|
-
return act === void 0 ? [] : [error51("act.set-not-asked",
|
|
38677
|
+
return act === void 0 ? [] : [error51("act.set-not-asked", path16, `asks "${name}", which ${act} sets \u2014 the act writes it, so an add never states it; drop it here`)];
|
|
38646
38678
|
}),
|
|
38647
38679
|
...requiredOnCreate(model, declared, fills).filter((name) => !asked.includes(name)).map((name) => {
|
|
38648
38680
|
const act = setter(name);
|
|
38649
|
-
return act === void 0 ? error51("app.create-required",
|
|
38681
|
+
return act === void 0 ? error51("app.create-required", path16, `leaves out "${name}", which "${entity}" requires and nothing fills \u2014 ask it, or give it a default`) : error51("act.set-not-asked", path16, `leaves out "${name}", which "${entity}" requires and ${act} sets \u2014 give it a default`);
|
|
38650
38682
|
})
|
|
38651
38683
|
];
|
|
38652
38684
|
});
|
|
@@ -38659,9 +38691,9 @@ function checkApp(model, app, at2) {
|
|
|
38659
38691
|
if (display === void 0) {
|
|
38660
38692
|
return [error51("app.records-entry", `${at2}.entity`, `lists "${entity.alias}", which has no \`records\` entry \u2014 say how its row is recognised first`)];
|
|
38661
38693
|
}
|
|
38662
|
-
const own = (name,
|
|
38694
|
+
const own = (name, path16) => {
|
|
38663
38695
|
const found = fieldOf(entity, name);
|
|
38664
|
-
if (found === void 0) findings.push(error51("model.names-declared",
|
|
38696
|
+
if (found === void 0) findings.push(error51("model.names-declared", path16, `names "${name}", which is not a field of "${entity.alias}"`));
|
|
38665
38697
|
return found;
|
|
38666
38698
|
};
|
|
38667
38699
|
const stages = new Set((milestonesOf(display) ?? []).map((stage) => stage.field));
|
|
@@ -38674,41 +38706,41 @@ function checkApp(model, app, at2) {
|
|
|
38674
38706
|
findings.push(error51("app.scope", `${at2}.scope`, `"${scope}" is not required, so a row holding none belongs to no folder and is never listed \u2014 make it required`));
|
|
38675
38707
|
}
|
|
38676
38708
|
}
|
|
38677
|
-
const scoped = (name,
|
|
38678
|
-
if (name === scope) findings.push(error51("app.scope",
|
|
38709
|
+
const scoped = (name, path16, where) => {
|
|
38710
|
+
if (name === scope) findings.push(error51("app.scope", path16, `"${name}" is the folder the app is inside, the same on every row ${where} \u2014 drop it here`));
|
|
38679
38711
|
};
|
|
38680
38712
|
const rowParts = partsOf(display);
|
|
38681
38713
|
const registerBounds = meterBounds(display, app.register?.columns ?? []);
|
|
38682
38714
|
const columns = /* @__PURE__ */ new Map();
|
|
38683
38715
|
for (const [index, name] of (app.register?.columns ?? []).entries()) {
|
|
38684
|
-
const
|
|
38685
|
-
const column = own(name,
|
|
38716
|
+
const path16 = `${at2}.register.columns.${index}`;
|
|
38717
|
+
const column = own(name, path16);
|
|
38686
38718
|
const part = rowParts.get(name);
|
|
38687
|
-
if (column !== void 0 && part !== void 0) findings.push(error51("register.column-drawn",
|
|
38719
|
+
if (column !== void 0 && part !== void 0) findings.push(error51("register.column-drawn", path16, `"${name}" is already drawn by the row itself, as ${part} \u2014 drop the column`));
|
|
38688
38720
|
const meter = registerBounds.get(name);
|
|
38689
|
-
if (meter !== void 0) findings.push(error51("register.column-drawn",
|
|
38690
|
-
scoped(name,
|
|
38721
|
+
if (meter !== void 0) findings.push(error51("register.column-drawn", path16, `"${name}" is the bound of the meter "${meter}" on the same row, which states it \u2014 drop the column`));
|
|
38722
|
+
scoped(name, path16, "it lists");
|
|
38691
38723
|
const first2 = columns.get(name);
|
|
38692
|
-
if (first2 !== void 0) findings.push(error51("register.column-drawn",
|
|
38724
|
+
if (first2 !== void 0) findings.push(error51("register.column-drawn", path16, `"${name}" is already column ${first2} \u2014 a field is one column`));
|
|
38693
38725
|
else columns.set(name, index);
|
|
38694
38726
|
}
|
|
38695
38727
|
const splits = new Set((app.register?.readings ?? []).flatMap((reading) => "metric" in reading ? [] : pressFields(modelPressed(reading))));
|
|
38696
38728
|
for (const [index, stated] of (app.register?.filters ?? []).entries()) {
|
|
38697
|
-
const
|
|
38698
|
-
findings.push(...checkFilter(model, entity, stated,
|
|
38699
|
-
scoped(filterField(stated),
|
|
38729
|
+
const path16 = `${at2}.register.filters.${index}`;
|
|
38730
|
+
findings.push(...checkFilter(model, entity, stated, path16, { owner: "a register", chips: display.status?.field, splits }));
|
|
38731
|
+
scoped(filterField(stated), path16, "it lists");
|
|
38700
38732
|
}
|
|
38701
38733
|
for (const [index, name] of (app.register?.search ?? []).entries()) {
|
|
38702
|
-
const
|
|
38703
|
-
findings.push(...checkSearch(model, entity, name,
|
|
38704
|
-
scoped(name,
|
|
38734
|
+
const path16 = `${at2}.register.search.${index}`;
|
|
38735
|
+
findings.push(...checkSearch(model, entity, name, path16));
|
|
38736
|
+
scoped(name, path16, "it lists");
|
|
38705
38737
|
}
|
|
38706
38738
|
findings.push(...checkOrder(entity, app.register?.sort, `${at2}.register.sort`));
|
|
38707
38739
|
const tabs = app.register?.tabs ?? [];
|
|
38708
|
-
const dated = (name,
|
|
38709
|
-
const field = own(name,
|
|
38740
|
+
const dated = (name, path16) => {
|
|
38741
|
+
const field = own(name, path16);
|
|
38710
38742
|
const type = field === void 0 ? void 0 : resolvedFieldType(field, entity, model.entities) ?? field.type;
|
|
38711
|
-
if (field !== void 0 && type !== "date") findings.push(error51("register.tabs",
|
|
38743
|
+
if (field !== void 0 && type !== "date") findings.push(error51("register.tabs", path16, `"${name}" is a ${field.type} field \u2014 a tab places its rows in the period by a date`));
|
|
38712
38744
|
};
|
|
38713
38745
|
if (tabs.length > 0 && !["table", "cards"].includes(app.register?.layout ?? "table")) {
|
|
38714
38746
|
findings.push(error51("register.tabs", `${at2}.register.tabs`, `a ${app.register?.layout} reads its own window \u2014 tabs stand over a table or cards`));
|
|
@@ -38719,24 +38751,24 @@ function checkApp(model, app, at2) {
|
|
|
38719
38751
|
const named = /* @__PURE__ */ new Set();
|
|
38720
38752
|
const reportKeys = APP_EXPORT_REPORT_KEYS;
|
|
38721
38753
|
for (const [index, tab] of tabs.entries()) {
|
|
38722
|
-
const
|
|
38723
|
-
if (named.has(tab.tab)) findings.push(error51("register.tabs", `${
|
|
38754
|
+
const path16 = `${at2}.register.tabs.${index}`;
|
|
38755
|
+
if (named.has(tab.tab)) findings.push(error51("register.tabs", `${path16}.tab`, `"${tab.tab}" names another tab already`));
|
|
38724
38756
|
if (reportKeys.includes(tab.tab) || tab.tab === entity.alias) {
|
|
38725
|
-
findings.push(error51("register.tabs", `${
|
|
38757
|
+
findings.push(error51("register.tabs", `${path16}.tab`, `"${tab.tab}" is a key a report already holds \u2014 name the tab otherwise`));
|
|
38726
38758
|
}
|
|
38727
38759
|
named.add(tab.tab);
|
|
38728
|
-
if (tab.in !== void 0) dated(tab.in, `${
|
|
38760
|
+
if (tab.in !== void 0) dated(tab.in, `${path16}.in`);
|
|
38729
38761
|
if (tab.open !== void 0) {
|
|
38730
|
-
dated(tab.open.from, `${
|
|
38731
|
-
dated(tab.open.to, `${
|
|
38732
|
-
if (tab.open.from === tab.open.to) findings.push(error51("register.tabs", `${
|
|
38762
|
+
dated(tab.open.from, `${path16}.open.from`);
|
|
38763
|
+
dated(tab.open.to, `${path16}.open.to`);
|
|
38764
|
+
if (tab.open.from === tab.open.to) findings.push(error51("register.tabs", `${path16}.open`, `opens and closes on "${tab.open.from}" \u2014 a row is open between two dates`));
|
|
38733
38765
|
}
|
|
38734
|
-
findings.push(...checkNarrowing(model.entities, entity, tab.where, `${
|
|
38766
|
+
findings.push(...checkNarrowing(model.entities, entity, tab.where, `${path16}.where`));
|
|
38735
38767
|
const status = display?.status?.field;
|
|
38736
38768
|
if (status !== void 0 && tab.where?.[status] !== void 0) {
|
|
38737
|
-
findings.push(error51("register.tabs", `${
|
|
38769
|
+
findings.push(error51("register.tabs", `${path16}.where.${status}`, `narrows by "${status}", the status \u2014 its chips split the rows by it, each with its count; a tab splits them by the period or another field`));
|
|
38738
38770
|
}
|
|
38739
|
-
findings.push(...checkOrder(entity, tab.sort, `${
|
|
38771
|
+
findings.push(...checkOrder(entity, tab.sort, `${path16}.sort`));
|
|
38740
38772
|
}
|
|
38741
38773
|
const group = app.register?.group;
|
|
38742
38774
|
if (group !== void 0 && group === app.scope) {
|
|
@@ -38756,37 +38788,37 @@ function checkApp(model, app, at2) {
|
|
|
38756
38788
|
}
|
|
38757
38789
|
if (!peopleWrite(entity)) findings.push(error51("app.create-asks", `${at2}.register.create`, `adds rows to "${entity.alias}", which no person writes`));
|
|
38758
38790
|
for (const [index, name] of create.entries()) {
|
|
38759
|
-
const
|
|
38760
|
-
const field = own(name,
|
|
38761
|
-
if (field !== void 0 && !isAsked(field)) findings.push(error51("app.asked-written",
|
|
38762
|
-
if (field !== void 0) findings.push(...checkOwnRows(model, entity, field,
|
|
38763
|
-
if (name === scope) findings.push(error51("app.scope",
|
|
38791
|
+
const path16 = `${at2}.register.create.${index}`;
|
|
38792
|
+
const field = own(name, path16);
|
|
38793
|
+
if (field !== void 0 && !isAsked(field)) findings.push(error51("app.asked-written", path16, `asks "${name}", a ${field.type} field nobody writes`));
|
|
38794
|
+
if (field !== void 0) findings.push(...checkOwnRows(model, entity, field, path16));
|
|
38795
|
+
if (name === scope) findings.push(error51("app.scope", path16, `asks "${name}", the folder the app is inside, which an add files the row under itself \u2014 drop it`));
|
|
38764
38796
|
}
|
|
38765
38797
|
}
|
|
38766
38798
|
findings.push(...checkReports(model, entity, app, display.status?.field, at2));
|
|
38767
38799
|
for (const [index, reading] of (app.register?.readings ?? []).entries()) {
|
|
38768
|
-
const
|
|
38800
|
+
const path16 = `${at2}.register.readings.${index}`;
|
|
38769
38801
|
if (readingEntity(reading) !== entity.alias) {
|
|
38770
|
-
findings.push(error51("register.readings-own",
|
|
38802
|
+
findings.push(error51("register.readings-own", path16, `reads "${readingEntity(reading)}" \u2014 a register's readings are over its own rows, "${entity.alias}"; put another entity's on a dashboard`));
|
|
38771
38803
|
continue;
|
|
38772
38804
|
}
|
|
38773
|
-
findings.push(...checkReading(model, reading,
|
|
38805
|
+
findings.push(...checkReading(model, reading, path16));
|
|
38774
38806
|
const tab = "tab" in reading ? reading.tab : void 0;
|
|
38775
38807
|
if (tab !== void 0 && !named.has(tab)) {
|
|
38776
|
-
findings.push(error51("register.tabs", `${
|
|
38808
|
+
findings.push(error51("register.tabs", `${path16}.tab`, named.size === 0 ? `reads the tab "${tab}", and the register has no tabs` : `reads the tab "${tab}", which is none of the register's: ${[...named].join(", ")}`));
|
|
38777
38809
|
}
|
|
38778
38810
|
if ("metric" in reading && reading.value !== void 0 && reading.value === display.figure && reading.where === void 0) {
|
|
38779
|
-
findings.push(error51("register.readings-restate",
|
|
38811
|
+
findings.push(error51("register.readings-restate", path16, `sums "${reading.value}", the register's figure, over the rows in view \u2014 the foot states that total; give it a \`where\` the foot does not carry, or drop it`));
|
|
38780
38812
|
}
|
|
38781
38813
|
if ("metric" in reading && reading.value === void 0 && reading.where === void 0 && display.status !== void 0) {
|
|
38782
|
-
findings.push(error51("register.readings-restate",
|
|
38814
|
+
findings.push(error51("register.readings-restate", path16, "counts the rows in view \u2014 the status chip states that count; give it a `where` or drop it"));
|
|
38783
38815
|
}
|
|
38784
38816
|
}
|
|
38785
38817
|
const placed = /* @__PURE__ */ new Map();
|
|
38786
|
-
const place = (name, where,
|
|
38818
|
+
const place = (name, where, path16) => {
|
|
38787
38819
|
const first2 = placed.get(name);
|
|
38788
38820
|
if (first2 === HEADER_IMAGE && where === FILES_BLOCK) placed.set(name, where);
|
|
38789
|
-
else if (first2 !== void 0) findings.push(error51("record.placed-once",
|
|
38821
|
+
else if (first2 !== void 0) findings.push(error51("record.placed-once", path16, `places "${name}" a second time on the record (it is already in ${first2})`));
|
|
38790
38822
|
else placed.set(name, where);
|
|
38791
38823
|
};
|
|
38792
38824
|
place(display.title, "the header's title", `${at2}.record`);
|
|
@@ -38805,21 +38837,21 @@ function checkApp(model, app, at2) {
|
|
|
38805
38837
|
}
|
|
38806
38838
|
const inFacts = /* @__PURE__ */ new Map();
|
|
38807
38839
|
for (const [index, section] of sections.entries()) {
|
|
38808
|
-
const
|
|
38809
|
-
findings.push(...checkWhen(model.entities, entity, section.when, `${
|
|
38840
|
+
const path16 = `${at2}.record.sections.${index}`;
|
|
38841
|
+
findings.push(...checkWhen(model.entities, entity, section.when, `${path16}.when`, "offer"));
|
|
38810
38842
|
if (section.fields === void 0 && section.blocks === void 0 && section.acts === void 0) {
|
|
38811
|
-
findings.push(error51("record.section-holds",
|
|
38843
|
+
findings.push(error51("record.section-holds", path16, `"${section.title}" holds no fields, blocks or acts \u2014 a section is where the reader fills, reads or does something; give it one, or drop it`));
|
|
38812
38844
|
}
|
|
38813
|
-
findings.push(...checkSectionAt(entity, display, section.at, `${
|
|
38845
|
+
findings.push(...checkSectionAt(entity, display, section.at, `${path16}.at`));
|
|
38814
38846
|
for (const [order, name] of (section.fields ?? []).entries()) {
|
|
38815
|
-
const fieldPath = `${
|
|
38847
|
+
const fieldPath = `${path16}.fields.${order}`;
|
|
38816
38848
|
const fact = own(name, fieldPath);
|
|
38817
38849
|
if (fact !== void 0) place(name, `the section "${section.title}"`, fieldPath);
|
|
38818
38850
|
if (fact !== void 0) inFacts.set(name, fieldPath);
|
|
38819
38851
|
if (fact !== void 0) findings.push(...checkOwnRows(model, entity, fact, fieldPath));
|
|
38820
38852
|
}
|
|
38821
38853
|
}
|
|
38822
|
-
for (const [name,
|
|
38854
|
+
for (const [name, path16] of inFacts) {
|
|
38823
38855
|
const fact = fieldOf(entity, name);
|
|
38824
38856
|
if (fact?.type !== "lookup" || !(display.subtitle ?? []).includes(fact.source_field_alias)) continue;
|
|
38825
38857
|
const link = fieldOf(entity, fact.source_field_alias);
|
|
@@ -38827,9 +38859,9 @@ function checkApp(model, app, at2) {
|
|
|
38827
38859
|
const figure = target?.figure;
|
|
38828
38860
|
const bound = figure === void 0 ? void 0 : target?.limits?.[figure];
|
|
38829
38861
|
if (fact.lookup_field_alias !== figure && fact.lookup_field_alias !== bound) continue;
|
|
38830
|
-
findings.push(error51("record.fact-restates",
|
|
38862
|
+
findings.push(error51("record.fact-restates", path16, `"${name}" looks up the ${fact.lookup_field_alias === figure ? "figure" : "bound"} of "${fact.source_field_alias}", which the header's line already reads with its meter \u2014 drop it from the section`));
|
|
38831
38863
|
}
|
|
38832
|
-
for (const [name,
|
|
38864
|
+
for (const [name, path16] of inFacts) {
|
|
38833
38865
|
const fact = fieldOf(entity, name);
|
|
38834
38866
|
if (fact?.type !== "lookup" || !inFacts.has(fact.source_field_alias)) continue;
|
|
38835
38867
|
const link = fieldOf(entity, fact.source_field_alias);
|
|
@@ -38837,22 +38869,22 @@ function checkApp(model, app, at2) {
|
|
|
38837
38869
|
if (target === void 0) continue;
|
|
38838
38870
|
const part = fact.lookup_field_alias === target.title ? "title" : (target.subtitle ?? []).includes(fact.lookup_field_alias) ? "line under the title" : void 0;
|
|
38839
38871
|
if (part === void 0) continue;
|
|
38840
|
-
findings.push(error51("record.fact-restates",
|
|
38872
|
+
findings.push(error51("record.fact-restates", path16, `"${name}" looks up the ${part} of "${fact.source_field_alias}", whose row already reads it where the link stands \u2014 drop it from the section`));
|
|
38841
38873
|
}
|
|
38842
38874
|
const blocksPlaced = /* @__PURE__ */ new Map();
|
|
38843
38875
|
for (const [index, section] of sections.entries()) {
|
|
38844
38876
|
for (const [order, block] of (section.blocks ?? []).entries()) {
|
|
38845
|
-
const
|
|
38846
|
-
findings.push(...checkWhen(model.entities, entity, block.when, `${
|
|
38877
|
+
const path16 = `${at2}.record.sections.${index}.blocks.${order}`;
|
|
38878
|
+
findings.push(...checkWhen(model.entities, entity, block.when, `${path16}.when`, "offer"));
|
|
38847
38879
|
const listed = "timeline" in block ? block.timeline : isRowsBlock(block) ? block.rows : isAgendaBlock(block) ? block.agenda : void 0;
|
|
38848
38880
|
if (listed !== void 0 && listed === display.status?.history) {
|
|
38849
|
-
findings.push(error51("record.history",
|
|
38881
|
+
findings.push(error51("record.history", path16, `reads "${listed}", the record's status history \u2014 never a section: \`record.history\` draws it beside the record; drop the block`));
|
|
38850
38882
|
}
|
|
38851
38883
|
const identity = blockIdentity(block);
|
|
38852
38884
|
const first2 = identity === void 0 ? void 0 : blocksPlaced.get(identity);
|
|
38853
|
-
if (first2 !== void 0) findings.push(error51("record.placed-once",
|
|
38885
|
+
if (first2 !== void 0) findings.push(error51("record.placed-once", path16, `places the same block a second time on the record (it is already in ${first2}) \u2014 a block is read once`));
|
|
38854
38886
|
else if (identity !== void 0) blocksPlaced.set(identity, `the section "${section.title}"`);
|
|
38855
|
-
findings.push(...checkBlock(model, entity, block,
|
|
38887
|
+
findings.push(...checkBlock(model, entity, block, path16, place, app.reads !== "shared", app.checks ?? []));
|
|
38856
38888
|
if (!isRowsBlock(block)) continue;
|
|
38857
38889
|
const child = entityOf(model, block.rows);
|
|
38858
38890
|
const link = child === void 0 ? void 0 : childLink(child, entity.alias, block.via);
|
|
@@ -38875,34 +38907,34 @@ function checkApp(model, app, at2) {
|
|
|
38875
38907
|
const children = new Set(recordChildren(model, entity.alias, recordBlocks(app.record)));
|
|
38876
38908
|
const acts = /* @__PURE__ */ new Map();
|
|
38877
38909
|
for (const [index, act] of (app.acts ?? []).entries()) {
|
|
38878
|
-
const
|
|
38879
|
-
if (acts.has(act.alias)) findings.push(error51("app.alias-unique", `${
|
|
38910
|
+
const path16 = `${at2}.acts.${index}`;
|
|
38911
|
+
if (acts.has(act.alias)) findings.push(error51("app.alias-unique", `${path16}.alias`, `"${act.alias}" names two acts of this app \u2014 an act alias is unique`));
|
|
38880
38912
|
acts.set(act.alias, act.of);
|
|
38881
38913
|
if (act.of !== void 0 && !children.has(act.of)) {
|
|
38882
|
-
findings.push(error51("act.of", `${
|
|
38914
|
+
findings.push(error51("act.of", `${path16}.of`, `names "${act.of}", which this record lists no rows of \u2014 an act of a child works on rows the record lists`));
|
|
38883
38915
|
continue;
|
|
38884
38916
|
}
|
|
38885
38917
|
if (act.of !== void 0 && act.on === "view") {
|
|
38886
|
-
findings.push(error51("act.of", `${
|
|
38918
|
+
findings.push(error51("act.of", `${path16}.on`, `is "${act.on}", and an act of a child works on the one child row it is pressed on \u2014 drop it`));
|
|
38887
38919
|
}
|
|
38888
38920
|
if (act.of !== void 0 && act.on === "rows" && !listsRows(app, act.of)) {
|
|
38889
|
-
findings.push(error51("act.of", `${
|
|
38921
|
+
findings.push(error51("act.of", `${path16}.on`, `is "rows", and the record lists no "${act.of}" rows to pick together \u2014 list them in a rows block without \`expect\``));
|
|
38890
38922
|
}
|
|
38891
38923
|
const on = entityOf(model, actEntity(app, act));
|
|
38892
38924
|
if (on === void 0) continue;
|
|
38893
|
-
findings.push(...checkAct(model, on, act,
|
|
38925
|
+
findings.push(...checkAct(model, on, act, path16), ...checkIntake(model, app, act, path16), ...checkRecord(model, app, act, path16));
|
|
38894
38926
|
for (const expected of expectedKeys(model, on.alias)) {
|
|
38895
38927
|
const writes = [...Object.keys(act.set ?? {}), ...(act.asks ?? []).filter((ask) => typeof ask === "string")];
|
|
38896
38928
|
for (const name of [expected.via, expected.key].filter((one) => writes.includes(one))) {
|
|
38897
|
-
findings.push(error51("act.writes-line-key",
|
|
38929
|
+
findings.push(error51("act.writes-line-key", path16, `writes "${name}", which names the line a "${on.alias}" row is under its record \u2014 a line's key is stated by filling it`));
|
|
38898
38930
|
}
|
|
38899
38931
|
}
|
|
38900
|
-
findings.push(...checkHistoryCopies(model, on, act,
|
|
38932
|
+
findings.push(...checkHistoryCopies(model, on, act, path16));
|
|
38901
38933
|
if (act.template !== void 0 && act.workflow !== void 0) {
|
|
38902
|
-
findings.push(error51("act.makes-or-runs", `${
|
|
38934
|
+
findings.push(error51("act.makes-or-runs", `${path16}.template`, "makes a document and runs an authored workflow \u2014 the workflow makes what it needs"));
|
|
38903
38935
|
}
|
|
38904
38936
|
if (act.on === "view" && act.template === void 0 && act.set === void 0 && act.workflow === void 0) {
|
|
38905
|
-
findings.push(error51("act.view", `${
|
|
38937
|
+
findings.push(error51("act.view", `${path16}.on`, "runs over every row in view and neither writes nor makes a document \u2014 give it `set`, a `template` or a `workflow`"));
|
|
38906
38938
|
}
|
|
38907
38939
|
}
|
|
38908
38940
|
const actsPlaced = /* @__PURE__ */ new Map();
|
|
@@ -38920,20 +38952,20 @@ function checkApp(model, app, at2) {
|
|
|
38920
38952
|
for (const [index, section] of sections.entries()) {
|
|
38921
38953
|
const drawn = /* @__PURE__ */ new Set([...drawnBy(section), ...section.at === void 0 ? unstaged : []]);
|
|
38922
38954
|
for (const [order, name] of (section.acts ?? []).entries()) {
|
|
38923
|
-
const
|
|
38955
|
+
const path16 = `${at2}.record.sections.${index}.acts.${order}`;
|
|
38924
38956
|
const act = (app.acts ?? []).find((one) => one.alias === name);
|
|
38925
38957
|
const home = act === void 0 ? void 0 : actHome(model, app, act);
|
|
38926
|
-
if (act === void 0) findings.push(error51("model.names-declared",
|
|
38927
|
-
else if (act.of !== void 0) findings.push(error51("record.section-acts",
|
|
38928
|
-
else if ((act.on ?? "record") !== "record") findings.push(error51("record.section-acts",
|
|
38929
|
-
else if (home !== void 0 && home.section !== index) findings.push(error51("record.move-home",
|
|
38930
|
-
else if (home !== void 0 || isCorrectionAct(model, app, act)) findings.push(...checkFootNeeds(model, entity, section, drawn, act,
|
|
38931
|
-
else findings.push(...checkStagedAct(display, section, act,
|
|
38958
|
+
if (act === void 0) findings.push(error51("model.names-declared", path16, `names "${name}", which is not an act of this app`));
|
|
38959
|
+
else if (act.of !== void 0) findings.push(error51("record.section-acts", path16, `names "${name}", an act of each "${act.of}" row \u2014 it stands on that row, never at a section's foot`));
|
|
38960
|
+
else if ((act.on ?? "record") !== "record") findings.push(error51("record.section-acts", path16, `names "${name}", which runs over the register's rows \u2014 a section's foot holds the acts on this record`));
|
|
38961
|
+
else if (home !== void 0 && home.section !== index) findings.push(error51("record.move-home", path16, misplacedMove(act, sections[home.section]?.title ?? "", home.entry)));
|
|
38962
|
+
else if (home !== void 0 || isCorrectionAct(model, app, act)) findings.push(...checkFootNeeds(model, entity, section, drawn, act, path16));
|
|
38963
|
+
else findings.push(...checkStagedAct(display, section, act, path16), ...checkFootNeeds(model, entity, section, drawn, act, path16));
|
|
38932
38964
|
if (act !== void 0 && hiddenWhenOffered(section.when, act.when)) {
|
|
38933
|
-
findings.push(error51("record.act-hidden",
|
|
38965
|
+
findings.push(error51("record.act-hidden", path16, `"${name}" is offered only while "${section.title}" is hidden by its \`when\` \u2014 its button is never drawn; drop the section's \`when\`, or stand the act elsewhere`));
|
|
38934
38966
|
}
|
|
38935
38967
|
const first2 = actsPlaced.get(name);
|
|
38936
|
-
if (first2 !== void 0) findings.push(error51("record.placed-once",
|
|
38968
|
+
if (first2 !== void 0) findings.push(error51("record.placed-once", path16, `places "${name}" a second time on the record (it is already at the foot of ${first2})`));
|
|
38937
38969
|
else actsPlaced.set(name, `the section "${section.title}"`);
|
|
38938
38970
|
}
|
|
38939
38971
|
}
|
|
@@ -38954,27 +38986,27 @@ function checkApp(model, app, at2) {
|
|
|
38954
38986
|
);
|
|
38955
38987
|
}
|
|
38956
38988
|
for (const [index, check2] of (app.checks ?? []).entries()) {
|
|
38957
|
-
const
|
|
38958
|
-
findings.push(...checkResolve(model, app, check2, `${
|
|
38989
|
+
const path16 = `${at2}.checks.${index}`;
|
|
38990
|
+
findings.push(...checkResolve(model, app, check2, `${path16}.resolve`));
|
|
38959
38991
|
if (check2.of !== void 0) {
|
|
38960
|
-
findings.push(...checkChildCheck(model, check2, children, acts,
|
|
38992
|
+
findings.push(...checkChildCheck(model, check2, children, acts, path16));
|
|
38961
38993
|
continue;
|
|
38962
38994
|
}
|
|
38963
|
-
const field = own(check2.field, `${
|
|
38995
|
+
const field = own(check2.field, `${path16}.field`);
|
|
38964
38996
|
const result = field?.type === "formula" ? resolvedFieldType(field, entity, model.entities) : void 0;
|
|
38965
38997
|
if (field !== void 0 && (result === void 0 || !["boolean", "text"].includes(result))) {
|
|
38966
|
-
findings.push(error51("check.field", `${
|
|
38998
|
+
findings.push(error51("check.field", `${path16}.field`, `"${check2.field}" is a ${field.type} field \u2014 a check is a yes/no or text formula that stands while true or non-empty`));
|
|
38967
38999
|
}
|
|
38968
39000
|
const drawn = placed.get(check2.field);
|
|
38969
39001
|
if (drawn !== void 0) {
|
|
38970
|
-
findings.push(error51("check.not-placed", `${
|
|
39002
|
+
findings.push(error51("check.not-placed", `${path16}.field`, `"${check2.field}" is a check, whose line under the header says it, and it is also in ${drawn} \u2014 drop it there`));
|
|
38971
39003
|
}
|
|
38972
|
-
findings.push(...checkRestatesMeter(model, entity, check2,
|
|
39004
|
+
findings.push(...checkRestatesMeter(model, entity, check2, path16));
|
|
38973
39005
|
if (check2.blocks === void 0 || check2.blocks === "all") continue;
|
|
38974
39006
|
for (const [at22, target] of check2.blocks.entries()) {
|
|
38975
39007
|
if (target === "edit" || target === "delete") continue;
|
|
38976
|
-
if (!acts.has(target)) findings.push(error51("check.blocks", `${
|
|
38977
|
-
else if (acts.get(target) !== void 0) findings.push(error51("check.blocks", `${
|
|
39008
|
+
if (!acts.has(target)) findings.push(error51("check.blocks", `${path16}.blocks.${at22}`, `names "${target}", which is not an act of this app, "edit", "delete" or "all"`));
|
|
39009
|
+
else if (acts.get(target) !== void 0) findings.push(error51("check.blocks", `${path16}.blocks.${at22}`, `names "${target}", an act of "${acts.get(target) ?? ""}" rows \u2014 a check of that child refuses it`));
|
|
38978
39010
|
}
|
|
38979
39011
|
}
|
|
38980
39012
|
return findings;
|
|
@@ -38983,14 +39015,14 @@ function noteChildLists(model, app, at2) {
|
|
|
38983
39015
|
const placed = (app.record?.sections ?? []).flatMap(
|
|
38984
39016
|
(section, index) => (section.blocks ?? []).flatMap((block, order) => isRowsBlock(block) ? [{ block, path: `${at2}.record.sections.${index}.blocks.${order}` }] : [])
|
|
38985
39017
|
);
|
|
38986
|
-
const planned = placed.flatMap(({ block, path:
|
|
39018
|
+
const planned = placed.flatMap(({ block, path: path16 }) => {
|
|
38987
39019
|
const child = entityOf(model, block.rows);
|
|
38988
39020
|
const axis = child === void 0 ? void 0 : agendaAxis(model, child);
|
|
38989
39021
|
if (axis === void 0 || "refused" in axis || axis.time === void 0 && axis.part === void 0) return [];
|
|
38990
39022
|
const within = axis.time === void 0 ? `in a "${axis.part ?? ""}"` : "at an hour";
|
|
38991
|
-
return [note("block.agenda-note",
|
|
39023
|
+
return [note("block.agenda-note", path16, `lists "${block.rows}" rows, each on a day ("${axis.day}") ${within} \u2014 a table reads them as rows; an \`agenda\` draws them by day`)];
|
|
38992
39024
|
});
|
|
38993
|
-
const covering = placed.flatMap(({ block, path:
|
|
39025
|
+
const covering = placed.flatMap(({ block, path: path16 }) => {
|
|
38994
39026
|
if (!(entityOf(model, block.rows)?.fields.some((field) => field.type === "files") ?? false)) return [];
|
|
38995
39027
|
if (Array.isArray(block.create)) return [];
|
|
38996
39028
|
const covers = placed.find((other) => {
|
|
@@ -39003,7 +39035,7 @@ function noteChildLists(model, app, at2) {
|
|
|
39003
39035
|
return !("refused" in shape) && shape.entity === block.rows;
|
|
39004
39036
|
});
|
|
39005
39037
|
});
|
|
39006
|
-
return covers === void 0 ? [] : [note("block.under-note",
|
|
39038
|
+
return covers === void 0 ? [] : [note("block.under-note", path16, `"${block.rows}" rows cover "${covers.block.rows}" lines listed in ${covers.path} \u2014 draw them over the lines they cover: \`"under"\` on that block`)];
|
|
39007
39039
|
});
|
|
39008
39040
|
return [...planned, ...covering];
|
|
39009
39041
|
}
|
|
@@ -39017,10 +39049,10 @@ function checkDocumentsListed(model, entity, sections, at2) {
|
|
|
39017
39049
|
return shape === void 0 || "refused" in shape ? [] : [[shape.entity, block.title ?? child?.label ?? block.rows]];
|
|
39018
39050
|
})
|
|
39019
39051
|
);
|
|
39020
|
-
return placed.flatMap(({ block, path:
|
|
39052
|
+
return placed.flatMap(({ block, path: path16 }) => {
|
|
39021
39053
|
if (!isRowsBlock(block)) return [];
|
|
39022
39054
|
const lines = over.get(block.rows);
|
|
39023
|
-
return lines === void 0 ? [] : [error51("block.under-once",
|
|
39055
|
+
return lines === void 0 ? [] : [error51("block.under-once", path16, `lists "${block.rows}" rows, which stand over their lines in "${lines}" (\`under\`) \u2014 a second list says them twice; drop it`)];
|
|
39024
39056
|
});
|
|
39025
39057
|
}
|
|
39026
39058
|
function hiddenWhenOffered(shown, offered) {
|
|
@@ -39031,20 +39063,20 @@ function hiddenWhenOffered(shown, offered) {
|
|
|
39031
39063
|
return !held.some((option) => options.includes(option));
|
|
39032
39064
|
});
|
|
39033
39065
|
}
|
|
39034
|
-
function checkFilter(model, entity, stated,
|
|
39066
|
+
function checkFilter(model, entity, stated, path16, around) {
|
|
39035
39067
|
const name = filterField(stated);
|
|
39036
39068
|
const filter2 = fieldOf(entity, name);
|
|
39037
|
-
if (filter2 === void 0) return [error51("model.names-declared", typeof stated === "string" ?
|
|
39069
|
+
if (filter2 === void 0) return [error51("model.names-declared", typeof stated === "string" ? path16 : `${path16}.field`, `names "${name}", which is not a field of "${entity.alias}"`)];
|
|
39038
39070
|
const type = resolvedFieldType(filter2, entity, model.entities) ?? filter2.type;
|
|
39039
|
-
if (name === around.chips) return [error51("register.filter",
|
|
39071
|
+
if (name === around.chips) return [error51("register.filter", path16, `"${name}" is the status, already the register's chips \u2014 drop it`)];
|
|
39040
39072
|
if (!FILTER_TYPES.has(type)) {
|
|
39041
39073
|
const read = filter2.type === "lookup" ? readField(model, entity, filter2) : void 0;
|
|
39042
39074
|
const kind = read === void 0 ? `a ${filter2.type} field` : `a lookup of a ${read.field.type} field`;
|
|
39043
|
-
return [error51("register.filter",
|
|
39075
|
+
return [error51("register.filter", path16, `"${name}" is ${kind} \u2014 ${around.owner} narrows by a select, a member, a link, a date, a yes/no or a number`)];
|
|
39044
39076
|
}
|
|
39045
|
-
if (typeof stated !== "string") return checkTiers(model, entity, filter2, type, `${
|
|
39077
|
+
if (typeof stated !== "string") return checkTiers(model, entity, filter2, type, `${path16}.tiers`);
|
|
39046
39078
|
if (around.splits.has(name) && type !== "select_member") {
|
|
39047
|
-
return [note("register.filter-split",
|
|
39079
|
+
return [note("register.filter-split", path16, `"${name}" is also what a picture above the rows splits them by \u2014 a press on its part narrows by it`)];
|
|
39048
39080
|
}
|
|
39049
39081
|
return [];
|
|
39050
39082
|
}
|
|
@@ -39052,36 +39084,36 @@ function checkOrder(entity, sort, base) {
|
|
|
39052
39084
|
const findings = [];
|
|
39053
39085
|
const sorted = /* @__PURE__ */ new Set();
|
|
39054
39086
|
for (const [index, key] of registerSortKeys(sort).entries()) {
|
|
39055
|
-
const
|
|
39087
|
+
const path16 = `${base}${Array.isArray(sort) ? `.${index}` : ""}.field`;
|
|
39056
39088
|
const field = fieldOf(entity, key.field);
|
|
39057
|
-
if (field === void 0) findings.push(error51("model.names-declared",
|
|
39058
|
-
else if (!isSortableFieldType(field.type)) findings.push(error51("register.sort",
|
|
39059
|
-
if (sorted.has(key.field)) findings.push(error51("register.sort",
|
|
39089
|
+
if (field === void 0) findings.push(error51("model.names-declared", path16, `names "${key.field}", which is not a field of "${entity.alias}"`));
|
|
39090
|
+
else if (!isSortableFieldType(field.type)) findings.push(error51("register.sort", path16, `"${key.field}" is a ${field.type} field, which holds no order`));
|
|
39091
|
+
if (sorted.has(key.field)) findings.push(error51("register.sort", path16, `"${key.field}" is already a key of this order \u2014 a field orders the rows once`));
|
|
39060
39092
|
sorted.add(key.field);
|
|
39061
39093
|
}
|
|
39062
39094
|
return findings;
|
|
39063
39095
|
}
|
|
39064
|
-
function checkSearch(model, entity, name,
|
|
39096
|
+
function checkSearch(model, entity, name, path16) {
|
|
39065
39097
|
const field = fieldOf(entity, name);
|
|
39066
|
-
if (field === void 0) return [error51("model.names-declared",
|
|
39098
|
+
if (field === void 0) return [error51("model.names-declared", path16, `names "${name}", which is not a field of "${entity.alias}"`)];
|
|
39067
39099
|
const type = resolvedFieldType(field, entity, model.entities) ?? field.type;
|
|
39068
|
-
return SEARCH_TYPES.has(type) ? [] : [error51("register.search",
|
|
39100
|
+
return SEARCH_TYPES.has(type) ? [] : [error51("register.search", path16, `"${name}" is a ${field.type} field \u2014 a search matches words: text, a code, or a link by its row's title`)];
|
|
39069
39101
|
}
|
|
39070
|
-
function checkGroup(model, entity, display, name,
|
|
39102
|
+
function checkGroup(model, entity, display, name, path16, around) {
|
|
39071
39103
|
const field = fieldOf(entity, name);
|
|
39072
|
-
if (field === void 0) return [error51("model.names-declared",
|
|
39104
|
+
if (field === void 0) return [error51("model.names-declared", path16, `names "${name}", which is not a field of "${entity.alias}"`)];
|
|
39073
39105
|
const type = resolvedFieldType(field, entity, model.entities) ?? field.type;
|
|
39074
39106
|
const read = readField(model, entity, field);
|
|
39075
39107
|
const held = read?.field ?? field;
|
|
39076
39108
|
const several = read?.several === true || (held.type === "select" || held.type === "select_member") && held.multi === true;
|
|
39077
39109
|
const many = held.type === "select_record_link" && held.cardinality !== "one";
|
|
39078
39110
|
if (several || many || !GROUP_TYPES.has(type)) {
|
|
39079
|
-
return [error51("register.group",
|
|
39111
|
+
return [error51("register.group", path16, `"${name}" is a ${several || many ? `${field.type} field holding several values` : `${field.type} field`} \u2014 a row stands in one group: a single select, a one-row link, one member, a date, a yes/no or text`)];
|
|
39080
39112
|
}
|
|
39081
|
-
if (name === around.chips) return [error51("register.group",
|
|
39082
|
-
if (around.layout !== "table" && around.layout !== "gantt") return [error51("register.group",
|
|
39113
|
+
if (name === around.chips) return [error51("register.group", path16, `"${name}" is the status, whose chips already split the rows \u2014 group by another field`)];
|
|
39114
|
+
if (around.layout !== "table" && around.layout !== "gantt") return [error51("register.group", path16, `groups a ${around.layout} register \u2014 rows are grouped in a table, and a gantt's lanes`)];
|
|
39083
39115
|
if (name === display.title && (display.subtitle ?? []).length === 0) {
|
|
39084
|
-
return [error51("register.group",
|
|
39116
|
+
return [error51("register.group", path16, `groups by "${name}", the row's title, so each row leads with its first subtitle \u2014 state \`records.${entity.alias}.subtitle\` (the person, the party)`)];
|
|
39085
39117
|
}
|
|
39086
39118
|
return [];
|
|
39087
39119
|
}
|
|
@@ -39103,16 +39135,16 @@ function likelySection(model, app, act, drawn) {
|
|
|
39103
39135
|
const best = Math.max(0, ...scores);
|
|
39104
39136
|
return best === 0 ? void 0 : scores.indexOf(best);
|
|
39105
39137
|
}
|
|
39106
|
-
function checkResolve(model, app, check2,
|
|
39138
|
+
function checkResolve(model, app, check2, path16) {
|
|
39107
39139
|
if (check2.resolve === void 0) return [];
|
|
39108
39140
|
const act = (app.acts ?? []).find((one) => one.alias === check2.resolve);
|
|
39109
|
-
if (act === void 0) return [error51("model.names-declared",
|
|
39141
|
+
if (act === void 0) return [error51("model.names-declared", path16, `names "${check2.resolve}", which is not an act of this app`)];
|
|
39110
39142
|
if (act.of !== check2.of) {
|
|
39111
|
-
return [error51("check.resolve",
|
|
39143
|
+
return [error51("check.resolve", path16, `"${act.alias}" works on "${act.of ?? app.entity}" rows and the check stands on "${check2.of ?? app.entity}" rows \u2014 its way out acts on the rows it stands on`)];
|
|
39112
39144
|
}
|
|
39113
|
-
if ((act.on ?? "record") !== "record") return [error51("check.resolve",
|
|
39114
|
-
if (checkRefuses(check2, act.alias)) return [error51("check.resolve",
|
|
39115
|
-
if (isCorrectionAct(model, app, act)) return [error51("check.resolve",
|
|
39145
|
+
if ((act.on ?? "record") !== "record") return [error51("check.resolve", path16, `"${act.alias}" runs over the register's rows \u2014 a check's way out acts on the one row it stands on`)];
|
|
39146
|
+
if (checkRefuses(check2, act.alias)) return [error51("check.resolve", path16, `"${act.alias}" is an act this check refuses \u2014 its way out is one it lets through`)];
|
|
39147
|
+
if (isCorrectionAct(model, app, act)) return [error51("check.resolve", path16, `"${act.alias}" corrects what is done and waits in a \u22EF, where the check's link cannot point \u2014 its way out is an act drawn as a button`)];
|
|
39116
39148
|
return [];
|
|
39117
39149
|
}
|
|
39118
39150
|
function checkPages(door, sections, at2) {
|
|
@@ -39120,16 +39152,16 @@ function checkPages(door, sections, at2) {
|
|
|
39120
39152
|
const addressed = /* @__PURE__ */ new Map();
|
|
39121
39153
|
for (const [index, section] of sections.entries()) {
|
|
39122
39154
|
if (section.page !== true) continue;
|
|
39123
|
-
const
|
|
39155
|
+
const path16 = `${at2}.sections.${index}.page`;
|
|
39124
39156
|
if (door === "drawer" || door === "beside") {
|
|
39125
39157
|
const has = door === "drawer" ? "a drawer" : "a record beside the list";
|
|
39126
|
-
findings.push(error51("record.page",
|
|
39158
|
+
findings.push(error51("record.page", path16, `"${section.title}" is a page of its own, and ${has} has no pages; drop \`page\`, or make the door a page`));
|
|
39127
39159
|
}
|
|
39128
|
-
if (section.at !== void 0) findings.push(error51("record.page",
|
|
39160
|
+
if (section.at !== void 0) findings.push(error51("record.page", path16, `"${section.title}" is a step of the record's path, read in its scroll \u2014 drop \`page\`, or \`at\``));
|
|
39129
39161
|
const segment = pageSegment(section.title);
|
|
39130
39162
|
const first2 = addressed.get(segment);
|
|
39131
|
-
if (segment === "") findings.push(error51("record.page",
|
|
39132
|
-
else if (first2 !== void 0) findings.push(error51("record.page",
|
|
39163
|
+
if (segment === "") findings.push(error51("record.page", path16, `"${section.title}" holds no letter or digit to name its page's address \u2014 retitle it`));
|
|
39164
|
+
else if (first2 !== void 0) findings.push(error51("record.page", path16, `"${section.title}" and "${first2}" make one page address, "${segment}" \u2014 retitle one`));
|
|
39133
39165
|
else addressed.set(segment, section.title);
|
|
39134
39166
|
}
|
|
39135
39167
|
if (sections.length > 0 && sections.every((section) => section.page === true)) {
|
|
@@ -39156,20 +39188,20 @@ function checkRecordLayout(app, at2) {
|
|
|
39156
39188
|
}
|
|
39157
39189
|
return findings;
|
|
39158
39190
|
}
|
|
39159
|
-
function checkSectionAt(entity, display, at2,
|
|
39191
|
+
function checkSectionAt(entity, display, at2, path16) {
|
|
39160
39192
|
if (at2 === void 0) return [];
|
|
39161
39193
|
const status = display.status;
|
|
39162
|
-
if (status === void 0) return [error51("record.at",
|
|
39194
|
+
if (status === void 0) return [error51("record.at", path16, `stages the section by the status, and "${entity.alias}" has none \u2014 state its \`records\` status, or drop \`at\``)];
|
|
39163
39195
|
const stages = milestonesOf(display);
|
|
39164
39196
|
if (stages !== void 0) {
|
|
39165
39197
|
const named = new Set(stages.map((stage) => stage.field));
|
|
39166
|
-
return at2.flatMap((name) => named.has(name) ? [] : [error51("record.at",
|
|
39198
|
+
return at2.flatMap((name) => named.has(name) ? [] : [error51("record.at", path16, `names "${name}", which is not one of "${entity.alias}"'s milestones (${[...named].join(", ")})`)]);
|
|
39167
39199
|
}
|
|
39168
39200
|
const field = status.field === void 0 ? void 0 : fieldOf(entity, status.field);
|
|
39169
39201
|
const options = new Set(field?.type === "select" ? (field.options ?? []).map((option) => option.alias) : []);
|
|
39170
|
-
return at2.flatMap((name) => options.has(name) ? [] : [error51("record.at",
|
|
39202
|
+
return at2.flatMap((name) => options.has(name) ? [] : [error51("record.at", path16, `names "${name}", which is not an option of the status "${status.field ?? ""}"`)]);
|
|
39171
39203
|
}
|
|
39172
|
-
function checkStagedAct(display, section, act,
|
|
39204
|
+
function checkStagedAct(display, section, act, path16) {
|
|
39173
39205
|
const at2 = section.at;
|
|
39174
39206
|
const status = display.status;
|
|
39175
39207
|
if (at2 === void 0 || status === void 0) return [];
|
|
@@ -39182,16 +39214,16 @@ function checkStagedAct(display, section, act, path15) {
|
|
|
39182
39214
|
return before !== void 0 && at2.includes(before);
|
|
39183
39215
|
});
|
|
39184
39216
|
if (inStep) return [];
|
|
39185
|
-
return [error51("record.act-staged",
|
|
39217
|
+
return [error51("record.act-staged", path16, `"${act.alias}" stamps no step after ${at2.map((one) => `"${one}"`).join(" or ")}, so it is offered while "${section.title}" is not the work \u2014 ${elsewhere}`)];
|
|
39186
39218
|
}
|
|
39187
39219
|
const field = status.field;
|
|
39188
39220
|
if (field === void 0) return [];
|
|
39189
39221
|
const held = heldOptions(act.when, field);
|
|
39190
39222
|
if (held === void 0) {
|
|
39191
|
-
return [error51("record.act-staged",
|
|
39223
|
+
return [error51("record.act-staged", path16, `"${act.alias}" is offered whatever "${field}" holds, and "${section.title}" is the work only while it is ${at2.join(" or ")} \u2014 narrow its \`when\` to that, or ${elsewhere}`)];
|
|
39192
39224
|
}
|
|
39193
39225
|
const outside = held.filter((option) => !at2.includes(option));
|
|
39194
|
-
return outside.length === 0 ? [] : [error51("record.act-staged",
|
|
39226
|
+
return outside.length === 0 ? [] : [error51("record.act-staged", path16, `"${act.alias}" is offered while "${field}" is ${outside.join(" or ")}, when "${section.title}" is not the work \u2014 ${elsewhere}`)];
|
|
39195
39227
|
}
|
|
39196
39228
|
function blockIdentity(block) {
|
|
39197
39229
|
if (isRowsBlock(block)) {
|
|
@@ -39205,20 +39237,20 @@ function blockIdentity(block) {
|
|
|
39205
39237
|
}
|
|
39206
39238
|
function checkWhere(child, block, at2) {
|
|
39207
39239
|
return Object.entries(block.where ?? {}).flatMap(([name, options]) => {
|
|
39208
|
-
const
|
|
39240
|
+
const path16 = `${at2}.where.${name}`;
|
|
39209
39241
|
const field = fieldOf(child, name);
|
|
39210
|
-
if (field === void 0) return [error51("model.names-declared",
|
|
39242
|
+
if (field === void 0) return [error51("model.names-declared", path16, `names "${name}", which is not a field of "${child.alias}"`)];
|
|
39211
39243
|
if (field.type !== "select" || field.multi === true) {
|
|
39212
|
-
return [error51("block.where",
|
|
39244
|
+
return [error51("block.where", path16, `"${name}" is a ${field.type === "select" ? "multi-select" : `${field.type} field`} \u2014 a block narrows its rows by a single select, each row holding one option`)];
|
|
39213
39245
|
}
|
|
39214
39246
|
if (field.required !== true) {
|
|
39215
|
-
return [error51("block.where",
|
|
39247
|
+
return [error51("block.where", path16, `"${name}" is optional on "${child.alias}" \u2014 a row holding none would stand in no block while every unnarrowed total counts it; make it required`)];
|
|
39216
39248
|
}
|
|
39217
39249
|
const held = new Set((field.options ?? []).map((option) => option.alias));
|
|
39218
39250
|
const stray = options.filter((option) => !held.has(option));
|
|
39219
39251
|
return [
|
|
39220
|
-
...stray.length === 0 ? [] : [error51("block.where",
|
|
39221
|
-
...options.length === 1 && (block.columns ?? []).includes(name) ? [error51("block.where",
|
|
39252
|
+
...stray.length === 0 ? [] : [error51("block.where", path16, `names ${stray.map((one) => `"${one}"`).join(", ")}, which "${name}" has no option of`)],
|
|
39253
|
+
...options.length === 1 && (block.columns ?? []).includes(name) ? [error51("block.where", path16, `holds every row at "${options[0] ?? ""}", so its column says one thing on every row \u2014 drop the column`)] : []
|
|
39222
39254
|
];
|
|
39223
39255
|
});
|
|
39224
39256
|
}
|
|
@@ -39229,19 +39261,19 @@ function checkChildColumns(model, child, names, back, expecting, at2) {
|
|
|
39229
39261
|
const bounds = meterBounds(display, names);
|
|
39230
39262
|
const columns = /* @__PURE__ */ new Map();
|
|
39231
39263
|
for (const [index, name] of names.entries()) {
|
|
39232
|
-
const
|
|
39264
|
+
const path16 = `${at2}.columns.${index}`;
|
|
39233
39265
|
if (fieldOf(child, name) === void 0) {
|
|
39234
|
-
findings.push(error51("model.names-declared",
|
|
39266
|
+
findings.push(error51("model.names-declared", path16, `names "${name}", which is not a field of "${child.alias}"`));
|
|
39235
39267
|
continue;
|
|
39236
39268
|
}
|
|
39237
39269
|
const part = rowParts.get(name);
|
|
39238
39270
|
const inPlace = expecting && name === display?.figure && expectedColumns(model, child, [name]).length > 0;
|
|
39239
|
-
if (part !== void 0 && !inPlace) findings.push(error51("block.column-drawn",
|
|
39271
|
+
if (part !== void 0 && !inPlace) findings.push(error51("block.column-drawn", path16, `"${name}" is already drawn by the "${child.alias}" row itself, as ${part} \u2014 drop the column`));
|
|
39240
39272
|
const meter = bounds.get(name);
|
|
39241
|
-
if (meter !== void 0) findings.push(error51("block.column-drawn",
|
|
39242
|
-
if (name === back) findings.push(error51("block.column-drawn",
|
|
39273
|
+
if (meter !== void 0) findings.push(error51("block.column-drawn", path16, `"${name}" is the bound of the meter "${meter}" on the same row, which states it \u2014 drop the column`));
|
|
39274
|
+
if (name === back) findings.push(error51("block.column-drawn", path16, `"${name}" is the link back to this record, the same on every row of the block \u2014 drop the column`));
|
|
39243
39275
|
const first2 = columns.get(name);
|
|
39244
|
-
if (first2 !== void 0) findings.push(error51("block.column-drawn",
|
|
39276
|
+
if (first2 !== void 0) findings.push(error51("block.column-drawn", path16, `"${name}" is already column ${first2} \u2014 a field is one column`));
|
|
39245
39277
|
else columns.set(name, index);
|
|
39246
39278
|
}
|
|
39247
39279
|
return findings;
|
|
@@ -39259,9 +39291,9 @@ function checkChildCreate(model, child, create, via, at2) {
|
|
|
39259
39291
|
}
|
|
39260
39292
|
return findings;
|
|
39261
39293
|
}
|
|
39262
|
-
function checkUnder(model, entity, child, block,
|
|
39294
|
+
function checkUnder(model, entity, child, block, path16) {
|
|
39263
39295
|
if (block.under === void 0) return [];
|
|
39264
|
-
const at2 = `${
|
|
39296
|
+
const at2 = `${path16}.under`;
|
|
39265
39297
|
const shape = documentUnder(model, entity.alias, child, block.under);
|
|
39266
39298
|
if ("refused" in shape) return [error51("block.under", at2, shape.refused)];
|
|
39267
39299
|
const findings = [];
|
|
@@ -39273,11 +39305,11 @@ function checkUnder(model, entity, child, block, path15) {
|
|
|
39273
39305
|
for (const [index, name] of (block.columns ?? []).entries()) {
|
|
39274
39306
|
const column = fieldOf(child, name);
|
|
39275
39307
|
if (column?.type === "text" && column.format === "markdown") {
|
|
39276
|
-
findings.push(error51("block.under", `${
|
|
39308
|
+
findings.push(error51("block.under", `${path16}.columns.${index}`, `"${name}" is long text, drawn whole on a card per row, and lines under a document are a table of short facts \u2014 drop the column or \`under\``));
|
|
39277
39309
|
continue;
|
|
39278
39310
|
}
|
|
39279
39311
|
if (name !== block.under && !(column?.type === "lookup" && column.source_field_alias === block.under)) continue;
|
|
39280
|
-
findings.push(error51("block.under", `${
|
|
39312
|
+
findings.push(error51("block.under", `${path16}.columns.${index}`, `"${name}" reads the "${shape.entity}" each line stands under, which its heading already reads \u2014 drop the column`));
|
|
39281
39313
|
}
|
|
39282
39314
|
return findings;
|
|
39283
39315
|
}
|
|
@@ -39295,22 +39327,22 @@ function checkRoster(model, entity, app, at2) {
|
|
|
39295
39327
|
const layout = app.register?.layout;
|
|
39296
39328
|
const roster = app.register?.roster;
|
|
39297
39329
|
const expect = app.register?.expect;
|
|
39298
|
-
const
|
|
39330
|
+
const path16 = `${at2}.register.roster`;
|
|
39299
39331
|
if (roster === void 0) {
|
|
39300
39332
|
return [
|
|
39301
|
-
...layout === "roster" ? [error51("register.roster",
|
|
39333
|
+
...layout === "roster" ? [error51("register.roster", path16, `a roster fills each day with the rows of another entity \u2014 name it in \`roster\` (a shift, a trip, an attendance)`)] : [],
|
|
39302
39334
|
...expect === void 0 ? [] : [error51("register.roster", `${at2}.register.expect`, "names the columns of a roster's rows, and the register states no `roster` \u2014 name the rows first")],
|
|
39303
39335
|
...app.register?.of === void 0 || expect !== void 0 ? [] : [error51("register.roster", `${at2}.register.of`, "narrows a roster's expected columns, and the register expects none \u2014 state `expect` first")]
|
|
39304
39336
|
];
|
|
39305
39337
|
}
|
|
39306
|
-
if (layout !== "roster") return [error51("register.roster",
|
|
39338
|
+
if (layout !== "roster") return [error51("register.roster", path16, `names the rows a roster's days hold, and the register is ${layout === void 0 ? "a table" : `\`${layout}\``} \u2014 state \`layout: "roster"\`, or drop it`)];
|
|
39307
39339
|
if ((app.register?.columns ?? []).length > 0) return [error51("register.roster", `${at2}.register.columns`, `a roster's width is its days, so it draws no columns \u2014 drop \`columns\`; a fact of each row stands in its record`)];
|
|
39308
39340
|
if (app.register?.of !== void 0 && expect === void 0) return [error51("register.roster", `${at2}.register.of`, "narrows a roster's expected columns, and the register expects none \u2014 state `expect` first")];
|
|
39309
39341
|
const shape = rosterShape(model, entity.alias, roster, expect === void 0);
|
|
39310
|
-
if ("refused" in shape) return [error51("register.roster",
|
|
39342
|
+
if ("refused" in shape) return [error51("register.roster", path16, shape.refused)];
|
|
39311
39343
|
const child = entityOf(model, shape.entity);
|
|
39312
39344
|
if (child === void 0) return [];
|
|
39313
|
-
return [...checkRosterColumns(entity, child, app, at2), ...app.reads === "shared" ? [] : unruledChild(model, entity, child, shape.via,
|
|
39345
|
+
return [...checkRosterColumns(entity, child, app, at2), ...app.reads === "shared" ? [] : unruledChild(model, entity, child, shape.via, path16)];
|
|
39314
39346
|
}
|
|
39315
39347
|
function checkRosterColumns(entity, child, app, at2) {
|
|
39316
39348
|
const expect = app.register?.expect;
|
|
@@ -39335,16 +39367,16 @@ function checkLanes(model, entity, app, at2) {
|
|
|
39335
39367
|
const layout = app.register?.layout;
|
|
39336
39368
|
const lanes = app.register?.lanes;
|
|
39337
39369
|
const loads = app.register?.loads;
|
|
39338
|
-
const
|
|
39370
|
+
const path16 = `${at2}.register.lanes`;
|
|
39339
39371
|
if (lanes === void 0) {
|
|
39340
39372
|
return [
|
|
39341
|
-
...layout === "lanes" ? [error51("register.lanes",
|
|
39373
|
+
...layout === "lanes" ? [error51("register.lanes", path16, "a lanes board stands each row in the lane its one-link names \u2014 name that link in `lanes` (a chair, a machine, a room)")] : [],
|
|
39342
39374
|
...loads === void 0 ? [] : [error51("register.loads", `${at2}.register.loads`, "names what a lane carries, and the register has no `lanes` \u2014 state the one-link its rows stand in, or drop it")]
|
|
39343
39375
|
];
|
|
39344
39376
|
}
|
|
39345
|
-
if (layout !== "lanes") return [error51("register.lanes",
|
|
39377
|
+
if (layout !== "lanes") return [error51("register.lanes", path16, `names the lanes a board stands its rows in, and the register is ${layout === void 0 ? "a table" : `\`${layout}\``} \u2014 state \`layout: "lanes"\`, or drop it`)];
|
|
39346
39378
|
const shape = lanesShape(model, entity.alias, lanes);
|
|
39347
|
-
if ("refused" in shape) return [error51("register.lanes",
|
|
39379
|
+
if ("refused" in shape) return [error51("register.lanes", path16, shape.refused)];
|
|
39348
39380
|
if (loads === void 0) return [];
|
|
39349
39381
|
const count2 = Object.keys(loads).length;
|
|
39350
39382
|
if (count2 === 0 || count2 > LANE_LOADS) return [error51("register.loads", `${at2}.register.loads`, `names ${count2} loads \u2014 a lane reads one or ${LANE_LOADS}, the ones it runs out of first (its weight, its volume)`)];
|
|
@@ -39353,22 +39385,22 @@ function checkLanes(model, entity, app, at2) {
|
|
|
39353
39385
|
function checkGantt(model, entity, app, at2) {
|
|
39354
39386
|
const layout = app.register?.layout;
|
|
39355
39387
|
const gantt = app.register?.gantt;
|
|
39356
|
-
const
|
|
39388
|
+
const path16 = `${at2}.register.gantt`;
|
|
39357
39389
|
if (gantt === void 0) {
|
|
39358
|
-
return layout === "gantt" ? [error51("register.gantt",
|
|
39390
|
+
return layout === "gantt" ? [error51("register.gantt", path16, "a gantt draws each row as a bar between two dates \u2014 name them in `gantt` (`start`, and `end` where the entity states no `due`)")] : [];
|
|
39359
39391
|
}
|
|
39360
|
-
if (layout !== "gantt") return [error51("register.gantt",
|
|
39392
|
+
if (layout !== "gantt") return [error51("register.gantt", path16, `names the dates a gantt's bars run between, and the register is ${layout === void 0 ? "a table" : `\`${layout}\``} \u2014 state \`layout: "gantt"\`, or drop it`)];
|
|
39361
39393
|
const shape = ganttShape(model, entity.alias, gantt);
|
|
39362
|
-
return "refused" in shape ? shape.refused.map((one) => error51("register.gantt", one.at === "" ?
|
|
39394
|
+
return "refused" in shape ? shape.refused.map((one) => error51("register.gantt", one.at === "" ? path16 : `${path16}.${one.at}`, one.reason)) : [];
|
|
39363
39395
|
}
|
|
39364
|
-
function checkTiers(model, entity, field, type,
|
|
39365
|
-
if (type !== "number") return [error51("register.tiers",
|
|
39396
|
+
function checkTiers(model, entity, field, type, path16) {
|
|
39397
|
+
if (type !== "number") return [error51("register.tiers", path16, `"${field.alias}" is a ${field.type} field \u2014 tiers band a number; name it bare`)];
|
|
39366
39398
|
const select = contractFigureSelect(field, entity, model.entities);
|
|
39367
39399
|
if (select === void 0) return [];
|
|
39368
39400
|
return [
|
|
39369
39401
|
error51(
|
|
39370
39402
|
"register.tiers",
|
|
39371
|
-
|
|
39403
|
+
path16,
|
|
39372
39404
|
`"${field.alias}" is read in each row's own ${select.vocabulary} ("${select.alias}"), so a breakpoint is a different amount on each row \u2014 name it bare: its range is typed in the ${select.vocabulary} the reader picks`
|
|
39373
39405
|
)
|
|
39374
39406
|
];
|
|
@@ -39378,29 +39410,29 @@ function checkReports(model, entity, app, status, at2) {
|
|
|
39378
39410
|
if (reports === void 0 || reports === true) return [];
|
|
39379
39411
|
const named = /* @__PURE__ */ new Map();
|
|
39380
39412
|
return reports.flatMap((report, index) => {
|
|
39381
|
-
const
|
|
39413
|
+
const path16 = `${at2}.register.export.${index}`;
|
|
39382
39414
|
const first2 = named.get(report.label);
|
|
39383
39415
|
if (first2 === void 0) named.set(report.label, index);
|
|
39384
|
-
const folder = Object.keys(report.where ?? {}).includes(app.scope ?? "") ? [error51("app.scope", `${
|
|
39416
|
+
const folder = Object.keys(report.where ?? {}).includes(app.scope ?? "") ? [error51("app.scope", `${path16}.where.${app.scope}`, `"${app.scope}" is the folder the app is inside \u2014 it already narrows every read; drop it here`)] : [];
|
|
39385
39417
|
return [
|
|
39386
|
-
...first2 === void 0 ? [] : [error51("register.export-report", `${
|
|
39387
|
-
...report.template === void 0 ? [] : checkExportTemplate(model, entity.alias, (app.register?.tabs ?? []).map((tab) => tab.tab), report.template, `${
|
|
39388
|
-
...checkNarrowing(model.entities, entity, report.where, `${
|
|
39418
|
+
...first2 === void 0 ? [] : [error51("register.export-report", `${path16}.label`, `names "${report.label}", as report ${first2} does \u2014 a menu entry is one report`)],
|
|
39419
|
+
...report.template === void 0 ? [] : checkExportTemplate(model, entity.alias, (app.register?.tabs ?? []).map((tab) => tab.tab), report.template, `${path16}.template`),
|
|
39420
|
+
...checkNarrowing(model.entities, entity, report.where, `${path16}.where`),
|
|
39389
39421
|
...folder,
|
|
39390
|
-
...report.per === void 0 ? [] : checkPer(model, entity, report.per, { status, scope: app.scope, fixed: report.where?.[report.per] !== void 0 }, `${
|
|
39391
|
-
...checkReportBooks(model, app, report,
|
|
39422
|
+
...report.per === void 0 ? [] : checkPer(model, entity, report.per, { status, scope: app.scope, fixed: report.where?.[report.per] !== void 0 }, `${path16}.per`),
|
|
39423
|
+
...checkReportBooks(model, app, report, path16)
|
|
39392
39424
|
];
|
|
39393
39425
|
});
|
|
39394
39426
|
}
|
|
39395
|
-
function checkPer(model, entity, name, around,
|
|
39427
|
+
function checkPer(model, entity, name, around, path16) {
|
|
39396
39428
|
const field = fieldOf(entity, name);
|
|
39397
|
-
if (field === void 0) return [error51("model.names-declared",
|
|
39429
|
+
if (field === void 0) return [error51("model.names-declared", path16, `names "${name}", which is not a field of "${entity.alias}"`)];
|
|
39398
39430
|
const type = resolvedFieldType(field, entity, model.entities) ?? field.type;
|
|
39399
|
-
if (name === around.status) return [error51("register.export-report",
|
|
39400
|
-
if (name === around.scope) return [error51("app.scope",
|
|
39401
|
-
if (type !== "select" && type !== "select_member" && type !== "select_record_link") return [error51("register.export-report",
|
|
39402
|
-
if (field.type === "select_record_link" && field.cardinality === "many") return [error51("register.export-report",
|
|
39403
|
-
return around.fixed ? [error51("register.export-report",
|
|
39431
|
+
if (name === around.status) return [error51("register.export-report", path16, `picks a value of "${name}", the status \u2014 its chips pick one; name it in \`where\``)];
|
|
39432
|
+
if (name === around.scope) return [error51("app.scope", path16, `"${name}" is the folder the app is inside \u2014 it already narrows every read; drop it here`)];
|
|
39433
|
+
if (type !== "select" && type !== "select_member" && type !== "select_record_link") return [error51("register.export-report", path16, `picks a value of "${name}", a ${type} field \u2014 one value is an option, a person or a linked row`)];
|
|
39434
|
+
if (field.type === "select_record_link" && field.cardinality === "many") return [error51("register.export-report", path16, `picks a value of "${name}", a link holding several rows \u2014 a file of one value names one row per row of the report`)];
|
|
39435
|
+
return around.fixed ? [error51("register.export-report", path16, `picks a value of "${name}", which its \`where\` already fixes \u2014 drop one`)] : [];
|
|
39404
39436
|
}
|
|
39405
39437
|
function checkRegisterNarrowing(model, entity, app, at2) {
|
|
39406
39438
|
const where = app.register?.where;
|
|
@@ -39408,9 +39440,9 @@ function checkRegisterNarrowing(model, entity, app, at2) {
|
|
|
39408
39440
|
const filters = (app.register?.filters ?? []).map(filterField);
|
|
39409
39441
|
const findings = [...checkWhen(model.entities, entity, where, `${at2}.register.where`, "offer"), ...checkNarrowing(model.entities, entity, opens, `${at2}.register.opens`)];
|
|
39410
39442
|
for (const [name, kept] of Object.entries(where ?? {})) {
|
|
39411
|
-
const
|
|
39412
|
-
if (filters.includes(name)) findings.push(error51("register.where-opens",
|
|
39413
|
-
if (name === app.scope) findings.push(error51("app.scope",
|
|
39443
|
+
const path16 = `${at2}.register.where.${name}`;
|
|
39444
|
+
if (filters.includes(name)) findings.push(error51("register.where-opens", path16, `"${name}" is fixed by \`where\`, so a chip on it narrows nothing the reader can change \u2014 drop it from \`filters\``));
|
|
39445
|
+
if (name === app.scope) findings.push(error51("app.scope", path16, `"${name}" is the folder the app is inside \u2014 it already narrows every read; drop it here`));
|
|
39414
39446
|
const opened = opens?.[name];
|
|
39415
39447
|
if (opened === void 0) continue;
|
|
39416
39448
|
const stray = Array.isArray(opened) && Array.isArray(kept) ? opened.filter((option) => !kept.includes(option)) : void 0;
|
|
@@ -39438,10 +39470,10 @@ function checkFiltersShown(model, entity, app, at2) {
|
|
|
39438
39470
|
...(register.filters ?? []).map((stated, index) => ({ name: filterField(stated), path: `${at2}.register.filters.${index}`, says: unshown })),
|
|
39439
39471
|
...Object.entries(register.opens ?? {}).flatMap(([name, held]) => held === NARROW_ME ? [] : [{ name, path: `${at2}.register.opens.${name}`, says: unshown }]),
|
|
39440
39472
|
...(register.readings ?? []).flatMap((reading, index) => {
|
|
39441
|
-
const
|
|
39442
|
-
if (readingEntity(reading) !== entity.alias || checkReading(model, reading,
|
|
39473
|
+
const path16 = `${at2}.register.readings.${index}`;
|
|
39474
|
+
if (readingEntity(reading) !== entity.alias || checkReading(model, reading, path16).length > 0) return [];
|
|
39443
39475
|
const part = "metric" in reading ? "figure" : "pivot" in reading ? "cell" : "part";
|
|
39444
|
-
return pressFields(modelPressed(reading)).map((name) => ({ name, path:
|
|
39476
|
+
return pressFields(modelPressed(reading)).map((name) => ({ name, path: path16, says: pressed(part) }));
|
|
39445
39477
|
})
|
|
39446
39478
|
];
|
|
39447
39479
|
return unshownFilters(model, entity, narrowed, shown, fixed);
|
|
@@ -39461,11 +39493,11 @@ function rowShows(model, entity, display, columns, more) {
|
|
|
39461
39493
|
]);
|
|
39462
39494
|
}
|
|
39463
39495
|
function unshownFilters(model, entity, narrowed, shown, fixed) {
|
|
39464
|
-
return narrowed.flatMap(({ name, path:
|
|
39496
|
+
return narrowed.flatMap(({ name, path: path16, says }) => {
|
|
39465
39497
|
const field = fieldOf(entity, name);
|
|
39466
39498
|
const type = field === void 0 ? void 0 : resolvedFieldType(field, entity, model.entities) ?? field.type;
|
|
39467
39499
|
if (shown.has(name) || fixed.has(name) || type === void 0 || !FILTER_TYPES.has(type)) return [];
|
|
39468
|
-
return [error51("register.filter-unshown",
|
|
39500
|
+
return [error51("register.filter-unshown", path16, says(name))];
|
|
39469
39501
|
});
|
|
39470
39502
|
}
|
|
39471
39503
|
function checkBlock(model, entity, block, at2, place, scoped, checks) {
|
|
@@ -39536,11 +39568,11 @@ function checkBlock(model, entity, block, at2, place, scoped, checks) {
|
|
|
39536
39568
|
}
|
|
39537
39569
|
if ("files" in block) {
|
|
39538
39570
|
for (const [index, name] of block.files.entries()) {
|
|
39539
|
-
const
|
|
39571
|
+
const path16 = `${at2}.files.${index}`;
|
|
39540
39572
|
const field = fieldOf(entity, name);
|
|
39541
|
-
if (field === void 0) findings.push(error51("model.names-declared",
|
|
39542
|
-
else if (field.type !== "files") findings.push(error51("block.field-kind",
|
|
39543
|
-
else place(name, FILES_BLOCK,
|
|
39573
|
+
if (field === void 0) findings.push(error51("model.names-declared", path16, `names "${name}", which is not a field of "${entity.alias}"`));
|
|
39574
|
+
else if (field.type !== "files") findings.push(error51("block.field-kind", path16, `"${name}" is a ${field.type} field \u2014 a files block holds files fields`));
|
|
39575
|
+
else place(name, FILES_BLOCK, path16);
|
|
39544
39576
|
}
|
|
39545
39577
|
return findings;
|
|
39546
39578
|
}
|
|
@@ -39562,19 +39594,19 @@ function checkBlockNarrowing(model, child, block, back, checks, at2) {
|
|
|
39562
39594
|
}
|
|
39563
39595
|
const chips = isTaskBlock(model, block) ? display.status?.field : void 0;
|
|
39564
39596
|
const fixed = /* @__PURE__ */ new Set([...back === void 0 ? [] : [back], ...Object.entries(block.where ?? {}).flatMap(([field, options]) => options.length === 1 ? [field] : [])]);
|
|
39565
|
-
const same = (name,
|
|
39597
|
+
const same = (name, path16) => fixed.has(name) ? [error51("block.narrow", path16, `"${name}" is ${name === back ? "the link back to this record" : "held at one option by the block's `where`"}, the same on every row it narrows \u2014 drop it here`)] : [];
|
|
39566
39598
|
for (const [index, stated] of filters.entries()) {
|
|
39567
|
-
const
|
|
39568
|
-
findings.push(...checkFilter(model, child, stated,
|
|
39599
|
+
const path16 = `${at2}.filters.${index}`;
|
|
39600
|
+
findings.push(...checkFilter(model, child, stated, path16, { owner: "a rows block", chips, splits: /* @__PURE__ */ new Set() }), ...same(filterField(stated), path16));
|
|
39569
39601
|
}
|
|
39570
39602
|
for (const [index, name] of (block.search ?? []).entries()) findings.push(...checkSearch(model, child, name, `${at2}.search.${index}`));
|
|
39571
39603
|
findings.push(...checkOrder(child, block.sort, `${at2}.sort`));
|
|
39572
39604
|
if (block.group !== void 0) {
|
|
39573
|
-
const
|
|
39574
|
-
const fixedGroup = same(block.group,
|
|
39575
|
-
if (block.under !== void 0) findings.push(error51("block.narrow",
|
|
39605
|
+
const path16 = `${at2}.group`;
|
|
39606
|
+
const fixedGroup = same(block.group, path16);
|
|
39607
|
+
if (block.under !== void 0) findings.push(error51("block.narrow", path16, `stands its lines under their documents ("${block.under}"), which already group them \u2014 drop \`group\``));
|
|
39576
39608
|
else if (fixedGroup.length > 0) findings.push(...fixedGroup);
|
|
39577
|
-
else findings.push(...checkGroup(model, child, display, block.group,
|
|
39609
|
+
else findings.push(...checkGroup(model, child, display, block.group, path16, { chips, layout: "table" }));
|
|
39578
39610
|
}
|
|
39579
39611
|
const shown = rowShows(model, child, display, block.columns ?? [], [
|
|
39580
39612
|
// A child row's line says the warnings standing on it.
|
|
@@ -39592,15 +39624,15 @@ function checkBlockEdits(model, child, block, at2) {
|
|
|
39592
39624
|
const figure = recordDisplay(model, child.alias)?.figure;
|
|
39593
39625
|
const shown = /* @__PURE__ */ new Set([...block.columns ?? [], ...figure === void 0 ? [] : [figure]]);
|
|
39594
39626
|
return edits.flatMap((name, index) => {
|
|
39595
|
-
const
|
|
39627
|
+
const path16 = `${at2}.edits.${index}`;
|
|
39596
39628
|
const field = fieldOf(child, name);
|
|
39597
|
-
if (field === void 0) return [error51("model.names-declared",
|
|
39598
|
-
if (!shown.has(name)) return [error51("block.edits",
|
|
39599
|
-
if (!isAsked(field)) return [error51("block.edits",
|
|
39629
|
+
if (field === void 0) return [error51("model.names-declared", path16, `names "${name}", which is not a field of "${child.alias}"`)];
|
|
39630
|
+
if (!shown.has(name)) return [error51("block.edits", path16, `"${name}" is drawn nowhere on the line \u2014 make it a column, or drop it here`)];
|
|
39631
|
+
if (!isAsked(field)) return [error51("block.edits", path16, `"${name}" is a ${field.type} field nobody writes \u2014 a line writes a value a person keys`)];
|
|
39600
39632
|
const long = field.type === "text" && field.format === "markdown";
|
|
39601
39633
|
const several = field.type === "select" && field.multi === true;
|
|
39602
39634
|
if (!LINE_EDIT_TYPES.has(field.type) || long || several) {
|
|
39603
|
-
return [error51("block.edits",
|
|
39635
|
+
return [error51("block.edits", path16, `"${name}" is a ${long ? "long text" : several ? "multi-select" : `${field.type}`} field \u2014 a line holds a number, a date, a few words or one option as its control; the row opens to write the rest`)];
|
|
39604
39636
|
}
|
|
39605
39637
|
return [];
|
|
39606
39638
|
});
|
|
@@ -39679,14 +39711,14 @@ var workspaceModelFileSchema = workspaceModelContractSchema.extend({
|
|
|
39679
39711
|
}).strict();
|
|
39680
39712
|
function modelIssueFindings(issues) {
|
|
39681
39713
|
const unfold = (issue2, at2) => {
|
|
39682
|
-
const
|
|
39683
|
-
if (issue2.code !== "invalid_union") return [{ path:
|
|
39714
|
+
const path16 = [...at2, ...issue2.path];
|
|
39715
|
+
if (issue2.code !== "invalid_union") return [{ path: path16, message: issue2.message }];
|
|
39684
39716
|
const meant = issue2.errors.filter((arm) => !arm.some((one) => one.path.length === 0 && one.code === "invalid_type"));
|
|
39685
39717
|
const fewest = Math.min(...meant.map((arm) => arm.length));
|
|
39686
39718
|
const closest = meant.filter((arm) => arm.length === fewest);
|
|
39687
|
-
return closest.length === 1 ? closest[0].flatMap((one) => unfold(one,
|
|
39719
|
+
return closest.length === 1 ? closest[0].flatMap((one) => unfold(one, path16)) : [{ path: path16, message: issue2.message }];
|
|
39688
39720
|
};
|
|
39689
|
-
return issues.flatMap((issue2) => unfold(issue2, [])).map(({ path:
|
|
39721
|
+
return issues.flatMap((issue2) => unfold(issue2, [])).map(({ path: path16, message: message2 }) => error51("model.schema", path16.length === 0 ? "the file" : path16.map(String).join("."), message2));
|
|
39690
39722
|
}
|
|
39691
39723
|
var RETIRED_MODEL_KEYS = {
|
|
39692
39724
|
field_roles: "`records` replaced it \u2014 state how each entity's row is recognised (title, subtitle, image, status, figure, due) once per entity; docs/app_system.md",
|
|
@@ -39756,13 +39788,13 @@ function validateModelRows(model, rows) {
|
|
|
39756
39788
|
}
|
|
39757
39789
|
seenRefs.add(row.ref);
|
|
39758
39790
|
for (const [fieldAlias2, value] of Object.entries(row.fields)) {
|
|
39759
|
-
const
|
|
39791
|
+
const path16 = `rows.${alias2}.${row.ref}.${fieldAlias2}`;
|
|
39760
39792
|
const field = fieldByAlias.get(fieldAlias2);
|
|
39761
39793
|
if (field === void 0) {
|
|
39762
|
-
errors.push(error51("model.names-declared",
|
|
39794
|
+
errors.push(error51("model.names-declared", path16, `is not a field of entity "${alias2}" \u2014 a row's keys are field aliases (${fieldByAlias.size > 0 ? [...fieldByAlias.keys()].join(", ") : "this entity declares none"})`));
|
|
39763
39795
|
continue;
|
|
39764
39796
|
}
|
|
39765
|
-
checkValue(field, value,
|
|
39797
|
+
checkValue(field, value, path16, refs, errors);
|
|
39766
39798
|
}
|
|
39767
39799
|
for (const field of entity.fields) {
|
|
39768
39800
|
if (field.required !== true || COMPUTED_FIELD_TYPES2.has(field.type)) continue;
|
|
@@ -39774,13 +39806,13 @@ function validateModelRows(model, rows) {
|
|
|
39774
39806
|
return errors;
|
|
39775
39807
|
}
|
|
39776
39808
|
var COMPUTED_FIELD_TYPES2 = /* @__PURE__ */ new Set(["formula", "rollup", "lookup", "autonumber"]);
|
|
39777
|
-
function checkValue(field, value,
|
|
39809
|
+
function checkValue(field, value, path16, refs, errors) {
|
|
39778
39810
|
switch (field.type) {
|
|
39779
39811
|
case "select": {
|
|
39780
39812
|
const declared = new Set(field.options.map((option) => option.alias));
|
|
39781
39813
|
for (const raw of asList(value)) {
|
|
39782
39814
|
if (typeof raw !== "string" || !declared.has(raw)) {
|
|
39783
|
-
errors.push(error51("rows.value",
|
|
39815
|
+
errors.push(error51("rows.value", path16, `${quote(raw)} is not an option of this field \u2014 use an option alias (${declared.size > 0 ? [...declared].join(", ") : "this field declares none"})`));
|
|
39784
39816
|
}
|
|
39785
39817
|
}
|
|
39786
39818
|
return;
|
|
@@ -39788,30 +39820,30 @@ function checkValue(field, value, path15, refs, errors) {
|
|
|
39788
39820
|
case "select_record_link": {
|
|
39789
39821
|
for (const raw of asList(value)) {
|
|
39790
39822
|
if (typeof raw !== "string" || !refs.has(raw)) {
|
|
39791
|
-
errors.push(error51("rows.value",
|
|
39823
|
+
errors.push(error51("rows.value", path16, `${quote(raw)} names no row in this request \u2014 use "<entity-alias>:<ref>"`));
|
|
39792
39824
|
continue;
|
|
39793
39825
|
}
|
|
39794
39826
|
const named = raw.slice(0, raw.indexOf(":"));
|
|
39795
39827
|
if (named === field.target_entity) continue;
|
|
39796
|
-
errors.push(error51("rows.value",
|
|
39828
|
+
errors.push(error51("rows.value", path16, `names a row of entity "${named}", but this field links to "${field.target_entity}"`));
|
|
39797
39829
|
}
|
|
39798
39830
|
return;
|
|
39799
39831
|
}
|
|
39800
39832
|
case "select_member": {
|
|
39801
39833
|
if (value !== "self") {
|
|
39802
|
-
errors.push(error51("rows.value",
|
|
39834
|
+
errors.push(error51("rows.value", path16, `${quote(value)} is not a member this model can name \u2014 use "self"`));
|
|
39803
39835
|
}
|
|
39804
39836
|
return;
|
|
39805
39837
|
}
|
|
39806
39838
|
case "files": {
|
|
39807
39839
|
for (const raw of asList(value)) {
|
|
39808
39840
|
if (typeof raw !== "string" || raw === "") {
|
|
39809
|
-
errors.push(error51("rows.value",
|
|
39841
|
+
errors.push(error51("rows.value", path16, `${quote(raw)} is not a document \u2014 a path beside this file, or a fil_ id already uploaded here`));
|
|
39810
39842
|
continue;
|
|
39811
39843
|
}
|
|
39812
39844
|
if (isStoredFileId(raw)) continue;
|
|
39813
39845
|
if (raw.startsWith("/") || raw.split(/[\\/]/).includes("..")) {
|
|
39814
|
-
errors.push(error51("rows.value",
|
|
39846
|
+
errors.push(error51("rows.value", path16, `"${raw}" must be a relative path beside this file, with no ".."`));
|
|
39815
39847
|
}
|
|
39816
39848
|
}
|
|
39817
39849
|
return;
|
|
@@ -39820,23 +39852,23 @@ function checkValue(field, value, path15, refs, errors) {
|
|
|
39820
39852
|
case "rollup":
|
|
39821
39853
|
case "lookup":
|
|
39822
39854
|
case "autonumber": {
|
|
39823
|
-
errors.push(error51("rows.value",
|
|
39855
|
+
errors.push(error51("rows.value", path16, `is a ${field.type} field, which the platform computes \u2014 drop the value`));
|
|
39824
39856
|
return;
|
|
39825
39857
|
}
|
|
39826
39858
|
default: {
|
|
39827
39859
|
if (!isFixtureScalarValue(value)) {
|
|
39828
|
-
errors.push(error51("rows.value",
|
|
39860
|
+
errors.push(error51("rows.value", path16, `must be a string, number, boolean or null on a ${field.type} field`));
|
|
39829
39861
|
return;
|
|
39830
39862
|
}
|
|
39831
39863
|
const expression = field.type === "date" ? parseFixtureDateExpression(value) : void 0;
|
|
39832
39864
|
if (expression?.kind === "invalid") {
|
|
39833
|
-
errors.push(error51("rows.value",
|
|
39865
|
+
errors.push(error51("rows.value", path16, `${quote(value)} is not a date \u2014 use ${FIXTURE_DATE_GRAMMAR}`));
|
|
39834
39866
|
} else if (expression?.kind === "relative" && field.type === "date") {
|
|
39835
39867
|
const clock = field.format === "datetime";
|
|
39836
39868
|
if (clock && expression.time === void 0) {
|
|
39837
|
-
errors.push(error51("rows.value",
|
|
39869
|
+
errors.push(error51("rows.value", path16, `${quote(value)} places a date that holds its hour at no hour \u2014 state it after the day: "${String(value)} 09:00"`));
|
|
39838
39870
|
} else if (!clock && expression.time !== void 0) {
|
|
39839
|
-
errors.push(error51("rows.value",
|
|
39871
|
+
errors.push(error51("rows.value", path16, `${quote(value)} states an hour on a date that holds none \u2014 drop the hour`));
|
|
39840
39872
|
}
|
|
39841
39873
|
}
|
|
39842
39874
|
}
|
|
@@ -40109,17 +40141,17 @@ function validateWorkspaceModel(model, rows) {
|
|
|
40109
40141
|
errors.push(...checkModelSingulars(contract.entities, apps ?? []));
|
|
40110
40142
|
return [...contractErrors, ...errors];
|
|
40111
40143
|
}
|
|
40112
|
-
function unknownModelKeys(raw, kept,
|
|
40144
|
+
function unknownModelKeys(raw, kept, path16 = "") {
|
|
40113
40145
|
if (Array.isArray(raw)) {
|
|
40114
40146
|
if (!Array.isArray(kept)) return [];
|
|
40115
|
-
return raw.flatMap((item, i) => unknownModelKeys(item, kept[i],
|
|
40147
|
+
return raw.flatMap((item, i) => unknownModelKeys(item, kept[i], path16 === "" ? String(i) : `${path16}.${i}`));
|
|
40116
40148
|
}
|
|
40117
40149
|
if (!isPlainObject2(raw) || !isPlainObject2(kept)) return [];
|
|
40118
40150
|
if (isBareFilterNode(raw) && kept.node_type === "group" && Array.isArray(kept.children) && kept.children.length === 1) {
|
|
40119
|
-
return unknownModelKeys(raw, kept.children[0],
|
|
40151
|
+
return unknownModelKeys(raw, kept.children[0], path16);
|
|
40120
40152
|
}
|
|
40121
40153
|
return Object.keys(raw).flatMap((key) => {
|
|
40122
|
-
const here =
|
|
40154
|
+
const here = path16 === "" ? key : `${path16}.${key}`;
|
|
40123
40155
|
if (!(key in kept)) return [error51("model.schema", here, "unknown key \u2014 not part of the model")];
|
|
40124
40156
|
return unknownModelKeys(raw[key], kept[key], here);
|
|
40125
40157
|
});
|
|
@@ -40232,8 +40264,8 @@ function resultSideEffects(result) {
|
|
|
40232
40264
|
}
|
|
40233
40265
|
|
|
40234
40266
|
// src/version.ts
|
|
40235
|
-
var VERSION = "0.
|
|
40236
|
-
var APP_SDK_VERSION = "0.
|
|
40267
|
+
var VERSION = "0.281.0";
|
|
40268
|
+
var APP_SDK_VERSION = "0.108.0";
|
|
40237
40269
|
|
|
40238
40270
|
// src/timezone.ts
|
|
40239
40271
|
function machineTimezone() {
|
|
@@ -40249,7 +40281,7 @@ function machineTimezone() {
|
|
|
40249
40281
|
import { spawn as spawn2 } from "node:child_process";
|
|
40250
40282
|
|
|
40251
40283
|
// src/model_reference.md
|
|
40252
|
-
var model_reference_default = '# The Lotics workspace model (`model.json`)\n\nOne JSON file describing a workspace: its tables, fields, options, views, roles,\nfirst rows, how a row of each table is recognised, and the apps over them. Each \xA7\nis its own page at `model/<section>` \u2014 `lotics docs`, or the `docs` tool. Complete models of several trades, to read as\nworked examples: `https://lotics.ai/presets/index.json`.\n\n**What a model composes with**\n\n- **Entities and fields** (\xA7 Entity, \xA7 Field) \u2014 the tables, their columns, options and links.\n- **Records** (\xA7 Records) \u2014 how a row of each entity is RECOGNISED: its title, the line\n under it, its picture, its status, the one number it stands for. Stated once per entity\n and read by every surface that draws one of its rows.\n- **Write rules** (\xA7 Write rules) \u2014 what a write finds, copies, bounds and picks among.\n- **Apps** (\xA7 Apps) \u2014 one register over one entity and the record each row opens: which\n fields go where, the acts, and the checks that guard them \u2014 or a dashboard of readings.\n How each thing LOOKS is the runtime\'s, one treatment per concept; no key here changes it.\n A screen the job needs and no key states is a `lotics report` \u2014 the job, what the model\n drew, the word wanted \u2014 never keys bent to approximate it.\n\n**The working order**\n\n1. Name the people and each one\'s JOB \u2014 the work they alone decide or write.\n2. The entities and fields those jobs touch, and a `records` entry for every entity an\n app lists, opens or picks.\n3. One app per job in `apps[]`, each one register over one entity.\n4. `lotics model apply model.json` \u2014 the file is checked first, every problem in one run;\n then the tables, then a new version of every app, live\n (`lotics setup model.json --email you@company.com` where no account exists yet).\n5. Change the file and apply it again; `lotics model pull` writes what the workspace holds.\n Rolling an app back (`lotics run rollback_app`) restores its earlier version \u2014 table\n changes and data writes stay.\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 applied \u2014 apply the rest in a second call.\n- **An existing table is adopted.** `lotics model apply` binds an entity whose `label`\n already names a table in the workspace, and adds the fields, options and\n views it is missing. No stored value is ever changed or deleted: a field it\n adopts takes the model\'s `default`, and the `format` and `unit` it reads in,\n where that only relabels \u2014 a date\'s format and a unit converting its figures are\n left. Applying the same model twice changes nothing the second time.\n- **Renaming a field is `lotics run update_table`**, then the same label in this\n file; deleting is `lotics run delete_table`. Neither goes through the file\n (\xA7 What a run remembers).\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 `model apply` 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- **Rows land only where every bound table is empty.** One table already holding\n records and no rows are written anywhere: sample rows landing among a\n customer\'s real ones cannot be told apart from them.\n- **`lotics model apply` checks all of it before anything is written**, and\n reports every problem in one run rather than the first. Each section of this\n page ends its keys with the rules the check enforces there, one sentence each\n under an id; a finding names its rule\'s id (`[register.filter]`), and\n `model/<rule id>` is that one rule\'s page.\n\n<!-- generated:start rules-model -->\n\n#### Rules the check enforces\n\n| Rule | |\n|---|---|\n| `model.schema` | Every key is one its table lists, holding the type its row gives, and every required key is stated. |\n| `model.retired` | `field_roles`, `table_workflows` and `connections` are no longer part of a model: `records` states how a row is recognised, and an app\'s own writes do what a table automation did. |\n| `model.names-declared` | Every alias a key names is declared: an entity of this model, a field of the entity the key reads, an option of the select it names, an act of the app, a template or an app of this model. |\n| `model.tables` | A model declares at least one table and at most 50; apply the rest as a second model. |\n| `model.rows-cap` | Rows are a sample: at most 200 per entity, 2000 per model and 2000 documents attached \u2014 a real data set belongs in an import. |\n| `model.language` | *Noted, never refused:* A label or description written in the other language than the model\'s majority \u2014 with or without diacritics. |\n| `model.alias-unique` | An alias is unique where it is named: entities, roles and templates in the model, fields and views in their entity, options in their select. |\n| `model.label-unique` | A label is unique where apply finds it by label: entities, roles and templates in the model, fields and views in their entity, options in their select \u2014 and no view takes its entity\'s own label, which names the whole-table grid apply makes. |\n| `model.template-sha` | A template\'s `content_sha256`, where stated, is the 64-character lowercase hex sha256 of its content. |\n| `model.template-kind` | An act\'s paper and a register\'s `export` are made from an html or an excel template, never an email one. |\n| `model.filter` | A filter \u2014 a view\'s, a rollup\'s \u2014 tests fields of the entity it reads, through links as `entity.field` hops each standing on the entity the last lands on, by options its select declares. |\n\n<!-- generated:end rules-model -->\n\n## Top level\n\nEvery table of keys on this page is generated from the schema `model apply`\nparses the file with, so it is the whole of what a key may hold.\n\n<!-- generated:start top-level -->\n\n#### Model file\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `entities` | list of [Entity](#entity) | yes | The tables this model creates, with their fields, options and views |\n| `roles` | list of [Role](#role) | no | Workspace groups to create; members are added to them afterwards |\n| `templates` | list of [Template](#template) | no | Document templates: html and email inline, excel made from an uploaded workbook |\n| `rows` | map of alias \u2192 list of [Row](#row) | no | First records, keyed by entity alias \u2014 written only where every table they land in is empty |\n| `records` | map of alias \u2192 [Record](#record) | no | How a row of each entity is recognised, keyed by entity alias. Every entity an app lists, opens or picks has one |\n| `write_rules` | map of alias \u2192 [Entity write rules](#entity-write-rules) | no | Entity alias \u2192 what a create of that entity finds, copies and refuses |\n| `apps` | list of ([App](#app) \\| [Dashboard app](#dashboard-app)) | no | The apps this workspace will have \u2014 each one register over an entity, or a dashboard of readings |\n\n<!-- generated:end top-level -->\n\n**A model carries no** `fixtures`, `knowledge` or `knowledge_expects`, and no\n`word` / `pdf-form` template: file content is uploaded to the workspace, never\nstated in a model \u2014 an `excel` template names its uploaded workbook by `file_id`. `apps` here is what an agent states to make an app,\nnever built code. An unknown top-level key is an error, never ignored.\n\n### Aliases\n\nEvery `alias` is a lowercase slug \u2014 a letter, then letters, digits and\nunderscores (`unit_price`, `so_1001`). Aliases are how the file cross-references\nitself; they are never shown to anyone. `label` is what a person sees.\n\nLabels must be unique within their namespace \u2014 two entities, two fields on one\nentity, two options on one field, two views on one entity, two roles or two\ntemplates cannot share a label, because `apply` matches by label.\n\n## Entity\n\n<!-- generated:start entity -->\n\n#### Entity\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `alias` | alias | yes | Stable entity alias, unique within the contract |\n| `label` | text | yes | The table\'s name in the workspace, which apply names it |\n| `singular` | text | no | One row of this table, in the business\'s own words \u2014 what a create\'s button and panel name |\n| `description` | text | no | The table\'s description, written onto the table in the workspace |\n| `writes` | `false` | no | false: no person writes this table\'s rows \u2014 only the writes that keep it do, and no app opens or edits one |\n| `fields` | list of [Field](#field) (at least one) | yes | The table\'s columns |\n| `read_scope` | [Read scope](#read-scope) | no | Which rows a member reads. Absent, every member with access to the table reads every row. |\n| `unique` | list of list of alias (at least one) (at least one) | no | Sets of fields whose values no two live rows share \u2014 each a list of field aliases (text, number, date, a single select, or a link of cardinality "one"). A create or update landing a second row with the same values is refused. |\n| `views` | list of [View](#view) | no | Saved views, in the order they are listed; with none, the table still opens on its default grid |\n\n#### Read scope\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `any` | list of ([Read scope by role](#read-scope-by-role) \\| [Read scope by member](#read-scope-by-member) \\| [Read scope by option](#read-scope-by-option)) (at least one) | yes | A row is readable when ANY of these holds |\n\n#### Read scope by role\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `member_of` | alias | yes | A role alias: whoever is in the group it binds to reads the row |\n\n#### Read scope by member\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `through` | list of alias (1\u20133) | no | select_record_link aliases from this entity outward, each of cardinality "one" \u2014 the clause\'s field is on the entity the last hop lands on |\n| `field` | alias | yes | A select_member field alias on this entity, or on the entity `through` lands on |\n| `is` | `"self"` | yes | The members this column names on a row read that row |\n\n#### Read scope by option\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `through` | list of alias (1\u20133) | no | select_record_link aliases from this entity outward, each of cardinality "one" \u2014 the clause\'s field is on the entity the last hop lands on |\n| `field` | alias | yes | A single-select field alias on this entity, or on the entity `through` lands on |\n| `is` | list of alias (at least one) | yes | Its option aliases whose rows are readable \u2014 naming none would hide every row |\n\n<!-- generated:end entity -->\n\n<!-- generated:start rules-entity -->\n\n#### Rules the check enforces\n\n| Rule | |\n|---|---|\n| `entity.singular` | An entity a create is mounted over \u2014 a register that adds, a rows block \u2014 states its `singular`: one row of it, in the business\'s words. |\n| `entity.required-cycle` | Required links never wait on each other in a loop: no row of the loop could be created first. |\n| `entity.link-format` | A text field of `format: "link"` holds web addresses; one whose rows hold a phone or a mail address states no format. |\n| `entity.read-scope` | A `read_scope` clause names a declared role (`member_of`) or a field of its entity reached through one-row links: `"self"` a member field, options a single select declares. |\n| `entity.unique` | A `unique` set names two fields or more of its entity once each, each holding one value to compare \u2014 text, a number, a date, a single select, a one-row link \u2014 and is stated once; one field alone is `unique: true` on it. |\n\n<!-- generated:end rules-entity -->\n\n**`unique` is a set of values no two live rows share.** Each entry names fields\nof this entity holding ONE value \u2014 text, number, date, a single select, a link\nof cardinality `"one"` \u2014 and a create or update landing a second row with the\nsame values is refused. A set of one text field is that field\'s own `unique:\ntrue`, so it is refused here. A create carries each set in the names its panel\nsends, so the panel can name the duplicate before the write does.\n\n**`singular` is what a create says** \u2014 `New Order`, `Add Claim line`, `H\u1ED3 s\u01A1\nm\u1EDBi` \u2014 while `label` names the table, so without it the button reads `New\nOrders`. Nothing derives it: English plurals are irregular, and no language is\nexempt. In a language without plural forms it is usually the label itself,\nless any word for the collection. `model apply` REFUSES a model where a table\nsome create opens \u2014 a register\'s own, or a record section\'s add \u2014 states none.\n\n**`writes: false` says only the workspace\'s automations write these rows** \u2014 a\nlog of what the system sent, a copy of what another system holds. No app opens,\nedits or files one: a record listing them keeps the section and opens each row\nat rest, with no Add; a screen over the entity operates at most the rows its\nrecord owns; a party of it is picked, never found or minted; and the generator\nwrites no create and no update for it, and never notes it as created nowhere.\nA `lifecycle` on it is refused \u2014 a row walked\nthrough stages is worked by a person \u2014 and so is a publish desk over it. Absent,\npeople write the rows; `true` is not a value.\n\n**`read_scope` is a ROW rule, enforced by the platform.** It is resolved at\napply into the table\'s own row filters, so an app, a workflow reading for a\nviewer, and the API all answer the same rows \u2014 a per-record visibility field the\napp merely honours is a convention, not a gate. A `"self"` clause reads a column\nof one member or several, and every role alias and option alias a clause names\nmust be one this model declares. `apply` writes the rule onto a table it\nCREATES; a table it adopted that ALREADY CARRIES a rule keeps that one, because\nthe rule is the workspace\'s own statement about its rows \u2014 and a run whose model\nstates a different rule reports the entity rather than leaving the claim silent.\n\n**An app may state that its sharing is its read gate: `"reads": "shared"`.** An\napp reads as its owner, so the rule reaches a viewer only as the predicate every\nquery and editor guard of every app over the entity carries \u2014 right for a desk of\none\'s own rows, wrong for a desk whose audience its sharing already decides, where\nwidening the rule meant a role group nobody remembers to fill. Stated on an APP,\nits queries, pickers and guards carry no entity\'s rule and whoever the app is\nshared with reads and writes every row it draws; every other app and the table\'s\nown filters keep the rule. Share it deliberately. Refused on an app none of whose\ntables states a `read_scope`.\n\n**The rows under a private record INHERIT its rule.** An entity that states no\n`read_scope` and hangs under one that does \u2014 through its `parent` link, over one\nhop or several \u2014 is read by the ancestor\'s rule, answered through that link, on\nits table\'s own filters and in every query and guard alike; nothing is restated,\nso a child needs none of the ancestor\'s columns. Stating a rule on the child\nkeeps that one instead, an ancestor with no rule passes nothing down, and a row\nhanging further under the scoped one than a row filter reaches is refused by\nname \u2014 state a rule on it. So is a hop over a link that names more than one row:\nthe rule would admit a reader any one of them admits while the editor\'s guard\nreads the first, so give the link `"cardinality": "one"` or state a rule on the\nchild.\n\n**ONLY the `parent` role is walked.** A register a scoped record reaches by any\nother link \u2014 the rows that NAME it \u2014 is read by that record\'s id with no rule\ntravelling to it, so it is refused until it states one of its own.\n\n## Field\n\nEvery field carries the keys below, and its `type`\'s section adds the rest; a\ntype whose section names no `default` takes none. `label` may not contain `{` or\n`}` (formulas reference fields by label at the platform level). Every row in `rows` states each\n`required` field it carries (a default is not applied to them), and a required\nLINK is written with its row: the entity it names is created first, and entities\nwhose required links name each other are refused, since none of their rows could\never be created.\n\n<!-- generated:start field -->\n\n#### Field\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `type` | `"text"` \\| `"number"` \\| `"date"` \\| `"boolean"` \\| `"select"` \\| `"select_member"` \\| `"select_record_link"` \\| `"files"` \\| `"formula"` \\| `"rollup"` \\| `"lookup"` \\| `"autonumber"` | yes | What the field holds \u2014 each type takes the further keys its own section lists |\n| `alias` | alias | yes | Stable local alias, unique within the entity |\n| `label` | text | yes | The field\'s name in the workspace, which apply names it |\n| `description` | text | no | The field\'s description, written onto the field in the workspace |\n| `required` | boolean | no | Refuse a record whose cell for this field is empty. Apply writes it onto the field, and every write path \u2014 create, update, an agent\'s tool call, a workflow\'s set \u2014 refuses the row by field name. |\n\n<!-- generated:end field -->\n\n### `text`\n\n<!-- generated:start field-text -->\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `default` | text | no | Value pre-filled into a new record when none is supplied for this field. Applied on create only \u2014 existing records are never backfilled. |\n| `unique` | boolean | no | Unique values required |\n| `format` | `"text"` \\| `"link"` \\| `"markdown"` | no | How the words are drawn \u2014 plain, as a link that opens, or as markdown |\n\n<!-- generated:end field-text -->\n\n### `number`\n\n<!-- generated:start field-number -->\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `default` | number | no | Value pre-filled into a new record when none is supplied for this field. Applied on create only \u2014 existing records are never backfilled. |\n| `format` | `"number"` \\| `"currency"` \\| `"percentage"` | no | What the figure is \u2014 a plain number, money in `currency`, or a percent |\n| `currency` | text | no | ISO 4217 code |\n| `unit` | text | no | What a plain figure counts or measures, drawn after it: a measured code (g, kg, t, l, m3, cbm, mm, cm, m, km, m2, min, h, day), which converts and scales within its dimension, or any other noun of at most 12 characters (ki\u1EC7n, pallet, TEU), which never does. Only beside format "number". |\n| `unit_field` | alias | no | A single select on the same row, or a lookup of one through a one-link, whose chosen option is that row\'s unit: every option label is a unit as `unit` takes one. In place of `unit`; only beside format "number". |\n| `currency_field` | alias | no | A single select on the same row, or a lookup of one through a one-link, whose chosen option is that row\'s currency: every option label is an ISO 4217 code. In place of `currency`; only beside format "currency". |\n\n<!-- generated:end field-number -->\n\n`format` is what the number IS, and every surface reads it: `currency` prints as\nmoney in the code the row or the field states, `percentage` as a whole percent\nwith its sign. An ABSENT number is drawn absent \u2014 the one exception is a\n`sum` or count rollup the plan reads as a **`measure`**: that is the thing\naccumulated toward a bound, so nothing accumulated yet is zero and the meter\ndraws it. The same rollup read as an `amount` keeps its blank, and so does every\nother role: nothing added to what a row is WORTH means unpriced, not free. A\nformula reading only such sums and counts, and reading zero where each does\n(`{received} - {refunded}`), is one too. A `min`, an `avg`, a percentage of\nnothing and every other formula stay blank in any role, because none of them has\nan answer to give. This is why the pair on one\nscreen reads two ways \u2014 what has come in against what is owed \u2014 and why a\nmeasure\'s own LIMIT, an amount, leaves an unquoted row out of the count rather\nthan reporting it as nothing collected. **AND WHERE THAT LIMIT IS ABSENT \u2014 OR\nZERO \u2014 THERE IS NO LEVEL AT ALL**: a level is a reading AGAINST a bound, so a row\nthat states no bound, or a bound of nothing, draws nothing \u2014 cell, fact and all \u2014\nrather than a numerator whose whole meaning was the comparison. "Collected 0"\nbeside a blank total reads as money against a job worth nothing, and "0 of 0"\nagainst a count of nothing owed claims a comparison nobody can make. A measure the model gives no limit is a plain figure\nand is unaffected. A share is stored in percent units \u2014 68.1 is 68.1 % \u2014 and the\ncolumn, the fact behind it, the meter it is judged by and the figure over the\nregister all say so.\n\n`unit` is what a plain figure counts or measures, and stands only beside\n`format: "number"`. A **measured** unit is a code of one catalog \u2014 mass `g`\n`kg` `t`, volume `l` `m3` `cbm` (a cubic metre under freight\'s name), length\n`mm` `cm` `m` `km`, area `m2`, duration `min` `h` `day` \u2014 so a figure typed in\nanother unit of its dimension converts (`12,5 t` into a `kg` field is 12 500),\nand a tile or a chart reads it in the largest unit it reaches (12 500 kg as\n12,5 t\u1EA5n; CBM never scales) while a cell, a fact and a column always read in the\nfield\'s own. Any other noun of at most 12 characters (`ki\u1EC7n`, `pallet`, `TEU`) is a\n**counted** unit: a word after the figure that never converts. A measured unit is\nwritten as its code \u2014 `t\u1EA5n`, `KG` or `m\xB3` is refused, naming the code. A formula\nstates its own in `formula.unit`; a rollup that keeps the value (`sum`, `avg`,\n`median`, `min`, `max`, `range`) and a lookup carry the unit of the figure they\nread, exactly as they carry a currency, and a count carries none. A quantity is a\nnumber with its unit, never words (`3 cartons` in a text field): only a number\nsums, converts and reads down a column.\n\nA field\'s cells always hold figures in its own unit, so moving it between two\nunits of one dimension (`kg` to `t`) converts every stored figure \u2014 a change made\nin the workspace, in the field\'s settings (which say how many first) or through\n`lotics run update_table`. `model apply` never makes it: the model states the\nworkspace\'s unit until then. Any other change of unit\nrelabels.\n\nA figure whose unit or currency varies by row names a single select of its own\nrow in `unit_field` or `currency_field` (a formula in `formula.unit_field` /\n`formula.currency_field`) \u2014 or a lookup of one through a one-link, a line\nreading its shipment\'s currency. That select\'s options are the vocabulary:\neach label a unit as `unit` takes one (`chi\u1EBFc`, `kg`), or an ISO 4217 code\n(`USD`). A label outside it is refused when the figure is written and when the\nselect is \u2014 its options, its type or its deletion while a figure names it. A row\nwhose select is empty reads its figure bare. Every app reads each row\'s figure in\nits own row\'s unit, and a sum never mixes units: totals are one per unit, a\nchart draws one at a time. A rollup that keeps the value (`sum`, `avg`, `min`\u2026)\nover such a figure stands only where the child\'s select is a lookup, through\nthe rollup\'s own link, of a select on this entity \u2014 the rollup reads that\nselect\'s unit; otherwise it is refused, as is any lookup of such a figure (its\nunit lives on the other row). A count is unaffected. Moving a field between a\nfixed and a per-row unit relabels.\n\n### `date`\n\n<!-- generated:start field-date -->\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `default` | text | no | Value pre-filled into a new record when none is supplied for this field. Applied on create only \u2014 existing records are never backfilled. A date string in the field\'s format. |\n| `format` | `"date"` \\| `"datetime"` \\| `"date_range"` \\| `"datetime_range"` | no | Whether the field holds a day or a moment, alone or as a span |\n| `timezone` | text | no | IANA timezone |\n| `derive_from` | `"created_at"` \\| `"updated_at"` | no | Auto-populate from the row\'s system timestamp; the field becomes read-only. |\n\n<!-- generated:end field-date -->\n\nA `default` is refused beside `derive_from`: the platform stamps that date.\n\n### `boolean`\n\n<!-- generated:start field-boolean -->\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `default` | boolean | no | Value pre-filled into a new record when none is supplied for this field. Applied on create only \u2014 existing records are never backfilled. |\n\n<!-- generated:end field-boolean -->\n\n### `select`\n\n<!-- generated:start field-select -->\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `default` | list of alias | no | Value pre-filled into a new record when none is supplied for this field. Applied on create only \u2014 existing records are never backfilled. Option alias(es) this field declares \u2014 one for single-select. |\n| `options` | list of [Select option](#select-option) (at least one) | yes | The choices, in the order every picker and every ladder lists them |\n| `multi` | boolean | no | Allow multiple selections |\n\n#### Select option\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `alias` | alias | yes | Stable local alias, unique within the field |\n| `label` | text | yes | Display label for the option |\n| `color` | [colour](#select) | yes | The colour the option\'s badge is drawn in |\n| `mark` | [mark](#select) | no | The option\'s own mark, drawn in place of its colour dot wherever the option is shown: the brand it is ({kind: "brand", name: one of facebook, instagram, threads, meta, tiktok, google-ads, zalo, linkedin, x, google-meet, youtube, telegram, whatsapp, gmail, google-drive, outlook, kiotviet, misa, lark, payos}) or a kit glyph ({kind: "icon", name: "wrench"}). Every option of a field has one, or none does. A mark a reader does not draw falls back to the dot |\n\n<!-- generated:end field-select -->\n\n`color` is one of: `red`, `orange`, `amber`, `yellow`, `lime`, `green`,\n`emerald`, `teal`, `cyan`, `sky`, `blue`, `indigo`, `violet`, `purple`,\n`fuchsia`, `pink`, `rose`, `slate`, `gray`, `zinc`, `neutral`, `stone`.\nEvery option is drawn in its colour wherever it shows \u2014 a cell, a row\'s line,\na record, a picker, the bar a reading splits the rows by.\n\nAn option may carry its own `mark`, drawn in place of its colour dot wherever\nthe option is shown \u2014 a stage, a chip, a filter, a fact, an entry of a log, a\nreading\'s part, a lookup of the select on another entity. A select a reader scans\ndown a column \u2014 how a payment was made, the channel, the mode \u2014 states one on every\noption, since a glyph reads before its word: the brand it IS\n(`{"kind": "brand", "name": "tiktok"}` \u2014 one of `facebook`, `instagram`,\n`threads`, `meta`, `tiktok`, `google-ads`, `zalo`, `linkedin`, `x`,\n`google-meet`, `youtube`, `telegram`, `whatsapp`, `gmail`, `google-drive`,\n`outlook`, `kiotviet`, `misa`, `lark`, `payos`), or a glyph the kit draws\n(`{"kind": "icon", "name": "wrench"}`; any other name is refused). Every option of a select has one, or none does: a run of chips\nwhere one carries no mark reads as the one missing something. `apply` writes the\nmarks onto the table, sets one an adopted option lacks, and reports one it wears\ndifferently rather than overwrite it. The glyphs:\n\n<!-- generated:start field-select-glyphs -->\n\nactivity, align-center, align-left, align-right, arrow-down, arrow-down-up, arrow-down-wide-narrow, arrow-left, arrow-left-from-line, arrow-right, arrow-right-from-line, arrow-right-left, arrow-up, arrow-up-down, arrow-up-wide-narrow, ban, banknote, bed, bell, bold, bolt, book-marked, book-open, book-text, bot, box, brackets, brain, briefcase, building-2, calculator, calendar, calendar-clock, calendar-off, camera, car, chart-column, check, chevron-down, chevron-left, chevron-right, chevron-up, chevrons-down-up, chevrons-up-down, circle-alert, circle-check, clipboard-list, clock, code, code-xml, columns-3, columns-3-cog, construction, container, copy, credit-card, database, download, ellipsis, eraser, expand, external-link, eye, eye-off, facebook, file, file-csv, file-down, file-question, file-spreadsheet, file-stack, file-text, file-up, folder, folder-closed, folder-open, folder-pen, form, funnel-plus, funnel-x, gauge, globe, gpu, grip-vertical, group, hand-coins, heading, heading-1, heading-2, heading-3, history, house, image, inbox, info, instagram, italic, keyboard, languages, layout-dashboard, layout-grid, library-big, link-2, link-2-off, linkedin, list, list-checks, list-collapse, list-filter, list-filter-plus, list-ordered, loader, lock, lock-keyhole, lock-keyhole-open, lock-open, log-in, log-out, mail, map-pin, maximize-2, megaphone, menu, message-circle, message-circle-question-mark, message-square, messages-square, mic, minimize-2, minus, monitor, mouse, mouse-pointer-click, music, newspaper, notepad-text-dashed, package, paint-bucket, palette, panel-left, panel-left-close, panel-left-open, panel-right, panel-right-close, panel-right-open, paperclip, pause, pencil, phone, pin, pin-off, plane, play, plug, plus, receipt, rectangle-ellipsis, redo, refresh-cw, repeat, rotate-ccw, rotate-cw, scan, search, send, settings, share, share-2, shield, shield-alert, shield-check, shopping-cart, sliders-horizontal, smile, smile-plus, sparkles, split, square, square-check, square-pen, square-sigma, stethoscope, sticky-note, table, table-2, tag, target, text-quote, thumbs-down, thumbs-up, ticket, trash, trending-down, trending-up, triangle-alert, truck, tv-minimal, twitter, underline, undo, upload, user, user-check, user-pen, users, utensils, waypoints, workflow, wrench, x, zap\n\n<!-- generated:end field-select-glyphs -->\n\n```jsonc\n"options": [\n { "alias": "short_video", "label": "Short video", "color": "zinc", "mark": { "kind": "brand", "name": "tiktok" } },\n { "alias": "print", "label": "Print", "color": "amber", "mark": { "kind": "icon", "name": "newspaper" } }\n]\n```\n\n### `select_member`\n\nA person picker over the workspace\'s members. No default: a model cannot name\nmembers of a workspace that does not exist yet. With no role it is still drawn \u2014\nface and name, ranked as a `party` \u2014 in a screen\'s `columns` and in the register\na record draws of these rows.\n\n<!-- generated:start field-select_member -->\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `multi` | boolean | no | Allow multiple selections |\n\n<!-- generated:end field-select_member -->\n\n### `select_record_link`\n\n<!-- generated:start field-select_record_link -->\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `target_entity` | alias | yes | Alias of the entity this field links to |\n| `sync_both_ways` | boolean | no | Create a paired link field on the target entity for bidirectional sync |\n| `paired_field_alias` | alias | no | The pair edge of a bidirectional link: the field alias ON THE TARGET ENTITY that is this link\'s sync partner. Both sides of a pair carry it, each naming the other. Apply creates whichever side it reaches first WITH the pairing (the platform auto-creates the partner) and binds the partner alias to the auto-created field \u2014 without this edge the two contract fields would be created independently and collide with the auto-created partner. |\n| `cardinality` | `"one"` \\| `"many"` | no | How many linked records this field holds. Default \'many\'. \'one\' holds a single row and needs no partner; where the link IS paired, the partner side holds many. |\n| `display_field_aliases` | list of alias | no | Field aliases on the target entity shown as the link\'s display text / picker columns |\n\n<!-- generated:end field-select_record_link -->\n\nA two-way link is declared on BOTH sides, each naming the other as its\n`paired_field_alias`; the pair must be symmetric or the model is refused.\n\n**A single-valued link needs no partner.** `"cardinality": "one"` on its own is a\nlink that holds one row \u2014 one customer on an invoice, one project on a device \u2014\nand nothing is created on the target. The mirror invariant belongs to a PAIRED\nlink: pair a link when the target\'s own record should list what points at it, and\nleave it unpaired when it should not. Either way the record plan draws the\nrelation as a section on the side it points at, so an unpaired link costs the\ntarget nothing.\n\n### `files`\n\nNo keys beyond every field\'s; a row attaches documents to it (\xA7 Rows). Each file\nis drawn by its kind, with no key to choose: a picture (an image, a video) as its\nthumbnail, a document (a PDF, a sheet) as its type\'s badge and its filename.\n\n### `formula`\n\n<!-- generated:start field-formula -->\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `formula` | [Formula](#formula) | yes | Formula config. The expression references other fields on the SAME entity by alias in braces, e.g. `{quantity} * {unit_price}`. |\n\n#### Formula\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `expression` | text | yes | The expression, over fields of THIS entity by alias in braces \u2014 `{quantity} * {unit_price}` |\n| `format` | `"number"` \\| `"currency"` \\| `"percentage"` \\| `"link"` | no | Display format. \'number\' / \'currency\' / \'percentage\' for numeric results; \'link\' for text-output formulas that return a URL \u2014 renders the result as a clickable link. |\n| `currency` | text | no | ISO 4217 currency code, e.g. USD, VND, EUR |\n| `unit` | text | no | What a plain figure counts or measures, drawn after it: a measured code (g, kg, t, l, m3, cbm, mm, cm, m, km, m2, min, h, day), which converts and scales within its dimension, or any other noun of at most 12 characters (ki\u1EC7n, pallet, TEU), which never does. Only beside format "number". |\n| `unit_field` | alias | no | A single select on the same row, or a lookup of one through a one-link, whose chosen option is that row\'s unit: every option label is a unit as `unit` takes one. In place of `unit`; only beside format "number". |\n| `currency_field` | alias | no | A single select on the same row, or a lookup of one through a one-link, whose chosen option is that row\'s currency: every option label is an ISO 4217 code. In place of `currency`; only beside format "currency". |\n| `options` | list of [Select option](#select-option) (at least one) | no | The categories the formula yields, drawn as a single select\'s options are (read-only). The expression yields one of them as `{this_field:option}`, or null \u2014 `{days_idle} > 30 ? {warmth:cold} : {warmth:hot}`. Omit for a formula yielding a plain value. |\n| `output_type` | `"number"` \\| `"text"` \\| `"date"` \\| `"datetime"` \\| `"boolean"` \\| `"select"` | no | What the expression YIELDS \u2014 the kind the platform infers at write time, declared here so the offline checks can read it. `format` beside it is how that result is drawn, not what it is. Ignored on the wire (the platform re-infers it); `lotics model pull` writes the inferred value. |\n\n<!-- generated:end field-formula -->\n\n`output_type` is what lets a role or a screen clause accept a computed value: a\ncaption over a derived name (`output_type: "text"`), a period over a settled date\n(`"date"`). A formula declaring neither it nor a `format` says nothing about its\nresult, and every rule that needs one refuses it by name. A formula stating\n`options` is a computed category: read-only, and read as a single select\nwherever it is drawn \u2014 a column, a filter, a reading\'s `by` or `where`.\n\n**A select reaches a formula as the KEYS of its chosen options**, a list \u2014\nnever their labels, and never their aliases \u2014 and a model has no keys: the\nworkspace mints them when the table is made. So an option is named in a formula\nas `{field:option}`, both aliases, and the copy writes that option\'s key in its\nplace: `includes({kind}, {kind:crate})` for a select holding one or several,\n`{kind}[0] == {kind:crate}` for a single one. A select compared to its own words\n(`{kind} != "Crate"`) matches no row and computes the other branch everywhere,\nso the check refuses it and names the token. A select looked up from another\nentity is tested there, in a formula of its own, and that result looked up.\n\n**The language.** The offline check and the platform compute a formula with one\nengine, and the check refuses a call or a name it cannot run, naming what to write:\n\n<!-- generated:start field-formula-language -->\n\n- Operators: `+ - * / %`, `== != > < >= <=`, `&& || !`; `+` also joins text\n- Conditionals: a ternary only \u2014 `{amount} > 100 ? "High" : "Low"`\n- Not supported: optional chaining (`?.`), nullish coalescing (`??`), template literals, arrow functions \u2014 use `get(obj, "path", default)`, `coalesce(v1, v2)`\n- Helpers are these names, spelled exactly; a spreadsheet function (`IF`, `SUM`, `LEN`, `DATEDIF`) is none of them, and a formula calling one is refused\n- Math: round(n,decimals?), ceil(n), floor(n), abs(n), min(a,b), max(a,b), sum(arr), mean(arr), clamp(n,min,max), percentage(part,total,decimals?), pow(base,exp), sqrt(n), mod(n,divisor)\n- Strings: upper(s), lower(s), trim(s), capitalize(s), length(s), contains(s,search), join(arr,sep), split(s,sep), replace(s,search,rep), replaceAll(s,search,rep), startsWith(s,prefix), endsWith(s,suffix), substring(s,start,end?), padStart(s,len,char), padEnd(s,len,char), numberToWords(n)\n- Lists: includes(list,value), first(list), last(list), unique(list), compact(list) \u2014 length(list) counts one\n- Dates: now(), formatDate(d,fmt), addDays(d,n), subDays(d,n), addHours(d,n), subHours(d,n), addMinutes(d,n), subMinutes(d,n), startOfDay(d), endOfDay(d), differenceInCalendarDays(later,earlier), differenceInHours(later,earlier), differenceInMinutes(later,earlier), isBefore(d1,d2), isAfter(d1,d2), isSameDay(d1,d2), isToday(d), isWithinRange(d,start,end), parseDate(d)\n- Null/type: isNull(v), isEmpty(v) (also true for "" and []), coalesce(v1,v2,...), isString(v), isNumber(v), isBoolean(v), isArray(v), toNumber(v), toString(v)\n- Other: formatCurrency(amount,locale,currency), formatDecimal(value,decimals,locale) (grouped quantity, no symbol), get(obj,"path",default?)\n- Empty cells: a cell nobody filled is null inside a formula, whatever its type; one holding 0, false or "0" is not empty. Test it with `isEmpty({note})` \u2014 `{note} == ""` and `{done} == false` are false on an unset cell\n- Arithmetic over an empty cell: `+` and `-` read it as 0 beside a value (`{fee} + {surcharge}` is `{fee}` when the surcharge is empty, null when both are); `*`, `/`, `%` and a unary `-` yield null (`{price} * {qty}` is null, not 0, when the quantity is empty)\n- No helper throws on an empty cell: the math helpers and toNumber return null, the string helpers "". A value of the wrong type still errors. When EVERY field a formula reads is empty it is null \u2014 unless it reads each only as the argument of isEmpty, isNull or isNotNull (`!isEmpty({file})` is false there, not null)\n\n<!-- generated:end field-formula-language -->\n\n### `rollup`\n\nAggregates the records reached through a link on this entity.\n\n<!-- generated:start field-rollup -->\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `source_field_alias` | alias | yes | Alias of a select_record_link field on this entity to roll up from |\n| `aggregate_option` | [aggregation](#rollup) | yes | Aggregation operation. Its `field_key` names a field alias on the linked entity. |\n| `filter` | [filter](#views) group | no | Only linked records matching this filter are aggregated; one condition on its own is a group of one. Every `field_key` in it names a field alias on the linked entity, and a select condition\'s value names an option alias there. A traversal node reaches past that entity, so its `path` hops and inner `field_key` are fully-qualified `entity.field` aliases. |\n\n<!-- generated:end field-rollup -->\n\n`aggregate_option` is `{ "operation": \u2026, "field_key": \u2026 }` \u2014 `field_key` a field\nalias on the linked entity (`count` may omit it), and `operation` one of `count`,\n`sum`, `avg`, `median`, `min`, `max`, `range`, `empty`, `filled`,\n`percent_empty`, `percent_filled`, `unique`, `percent_unique`, `earliest`,\n`latest`, `date_range`, `checked`, `unchecked`, `percent_checked`,\n`percent_unchecked`. The operation must be one the aggregated field\'s type\nallows \u2014 `sum` over a number, `earliest` over a date, `filled` over any stored or\nformula field. A lookup is never rolled up: roll up the child\'s own field, or a formula\nover it.\n\n### `lookup`\n\nDisplays a field from the linked records.\n\n<!-- generated:start field-lookup -->\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `source_field_alias` | alias | yes | Alias of a select_record_link field on this entity to look up through |\n| `lookup_field_alias` | alias | yes | Alias of the field on the linked entity to display |\n| `order_by` | [Lookup order](#lookup-order) | no | Show ONE linked row\'s value \u2014 the first in this order \u2014 rather than every linked row\'s |\n\n#### Lookup order\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `field_key` | text | yes | A field alias on the linked entity the rows are ordered by |\n| `direction` | `"asc"` \\| `"desc"` | yes | Which end of that order the one row is taken from |\n\n<!-- generated:end field-lookup -->\n\nInside a formula, a lookup holding one value is that value (`{due_soon}` is\n`true`, not `[true]`); several values are a list.\n\n### `autonumber`\n\n<!-- generated:start field-autonumber -->\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `prefix` | text | no | Literal prefix prepended to every display value (e.g. \'KH-\' \u2192 \'KH-001\'). Ignored when `template` is set. |\n| `padding` | integer | no | Zero-pad the integer to this width. Default 1 (no padding). 3 \u2192 \'001\', \'012\', \'123\', \'1234\' (overflow uses the actual width). Ignored when `template` is set. |\n| `template` | text | no | Format template with placeholder tokens evaluated at insert time. Tokens: {N} (raw integer), {N:W} (zero-padded to width W, e.g. {N:3} \u2192 001), {YEAR} (4-digit year), {YEAR:2} (2-digit year), {MONTH} (2-digit month), {DAY} (2-digit day). Date tokens use the workspace timezone. Example: \'HM-{YEAR}-{N:3}\' yields \'HM-2026-001\'. Stored as the composed string; subsequent template edits do NOT re-format existing rows (date tokens would lose the original creation date). |\n\n<!-- generated:end field-autonumber -->\n\n<!-- generated:start rules-field -->\n\n#### Rules the check enforces\n\n| Rule | |\n|---|---|\n| `field.default` | A default names options its select declares \u2014 one on a single select \u2014 and a date stamped by `derive_from` states none. |\n| `field.option-mark` | An option\'s `mark` is one the kit draws, and a select\'s options mark every one or none. |\n| `field.unit-select` | A `unit_field` or `currency_field` is a single select of the same row, or a lookup of one through a one-link, whose every option label is a unit, or an ISO 4217 code. |\n| `field.link` | A link targets a declared entity: its `display_field_alias` a field of it, its `paired_field_alias` a link on it naming this one back \u2014 and of two paired links at most one reads one row. |\n| `field.formula` | A formula parses, reads fields of its own entity by alias, names an option as `{field:option}` of one a select declares, and compares a select to its options\' keys, never their words. |\n| `field.rollup` | A rollup aggregates, through a link of its entity, a field of the linked entity by an operation a model declares and that field\'s type takes \u2014 never a figure read in each row\'s own unit or currency \u2014 filtered on fields of the linked entity. |\n| `field.lookup` | A lookup reads, through a link of its entity, a field of the linked entity \u2014 never one read in each row\'s own unit or currency \u2014 ordered by a field of it. |\n| `field.computed-cycle` | Computed fields never wait on each other in a loop: each is computed after what it reads. |\n\n<!-- generated:end rules-field -->\n\n## Views\n\nSaved views live under the entity they belong to. Every field reference is a\nfield ALIAS on that entity.\n\n<!-- generated:start views -->\n\n#### View\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `alias` | alias | yes | Stable view alias, unique within the entity |\n| `label` | text | yes | Display name of the view |\n| `description` | text | no | The view\'s description, written onto the view in the workspace |\n| `columns` | list of [View column](#view-column) (at least one) | no | The columns the view shows, in this order and no others; absent, every field |\n| `filters` | [filter](#views) | no | The rows the view keeps |\n| `sort` | [sort](#views) | no | The order the view reads its rows in |\n| `summary` | map of text \u2192 text | no | Field alias \u2192 the operation its footer cell states |\n| `frozen_columns` | integer \\| `null` | no | How many leading columns stay in place while the rest scroll |\n\n#### View column\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `field_alias` | alias | yes | A field of this entity |\n| `visibility` | `"visible"` \\| `"hidden"` | yes | Field visibility state: \'visible\' = shown to everyone, \'hidden\' = not shown by default but members can toggle |\n| `width` | number | no | The column\'s width, in pixels |\n\n<!-- generated:end views -->\n\nA filter is a group \u2014 `{ "node_type": "group", "logic": "and" | "or",\n"children": [ \u2026 ] }` \u2014 or one condition on its own, `{ "node_type":\n"condition", "type": "select", "field_key": "tier", "operator": "has_any_of",\n"value": ["gold"] }`. A sort is a list of `{ "field_key": \u2026, "order": "asc" |\n"desc" | null }`. A condition\'s `type` is the field\'s type and its `operator` is\none that type admits \u2014 `has_any_of` / `has_none_of` / `has_all_of` / `is_empty` /\n`is_not_empty` for a select, `equals` / `greater_than` / `less_than` for a\nnumber, `on` / `before` / `after` / `between` for a date, `contains` /\n`is_any_of` for text. A select condition\'s `value` names option ALIASES.\n\n<!-- generated:start rules-view -->\n\n#### Rules the check enforces\n\n| Rule | |\n|---|---|\n| `view.fields` | A view\'s columns, summary, sort and filters name fields of its entity. |\n\n<!-- generated:end rules-view -->\n\n## Roles\n\nA role becomes a workspace group.\n\n<!-- generated:start roles -->\n\n#### Role\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `alias` | alias | yes | Stable role alias, bound to a workspace group when the model is applied |\n| `label` | text | yes | The group\'s name in the workspace |\n\n<!-- generated:end roles -->\n\n## Templates\n\nAn `html` or `email` template carries its content inline, and `{{name}}` in it is filled\nfrom the workflow\'s data; an `excel` template names an uploaded workbook by its `file_id`,\nfilled with the register\'s report by its `export` alone.\n\n<!-- generated:start templates -->\n\n#### Template\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `type` | `"html"` \\| `"email"` \\| `"excel"` | yes | html is a page a workflow renders to a PDF; email is a message a workflow sends; excel is a workbook a register\'s export fills |\n| `alias` | alias | yes | Stable template alias, unique within the contract |\n| `label` | text | yes | The template\'s name in the workspace |\n\n<!-- generated:end templates -->\n\n### `html` and `email`\n\n<!-- generated:start template-inline -->\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `content` | text | yes | Inline template content; placeholders reference field aliases |\n| `content_sha256` | text | no | sha256 (64-char lowercase hex) of the utf-8 content; derived where the template is written when absent |\n\n<!-- generated:end template-inline -->\n\n### `excel`\n\n<!-- generated:start template-excel -->\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `file_id` | text | yes | The uploaded .xlsx (`fil_\u2026`) the template is made from; its markers read what the export fills it with |\n\n<!-- generated:end template-excel -->\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**What an act\'s template is handed** is its record\'s own fields by alias: a\nselect, a member and a link as their words (a link as the linked row\'s title), a\nnumber, a date and a computed value that declares its kind raw (`2800000`,\n`2026-09-14`) \u2014 never a files field or a computed select. On one record it is\nhanded, too, the rows of each child the record draws as `rows` whose alias the\ntemplate names, listed under that alias for `{{#each <alias>}}` in place of the\nrecord\'s own link to them: every row filed under the record, oldest first, each\nby its own fields as the record\'s are, less its link back to the record. Over\nseveral rows the rows are listed under the entity\'s alias, for\n`{{#each <alias>}}`, without their child rows.\n\n## Rows\n\nFirst records, keyed by entity alias. Up to 200 rows per entity and 2000 across\nthe model, attaching at most 2000 documents between them \u2014 a real data set\nbelongs in an import, not a model.\n\n```jsonc\n"rows": {\n "customer": [\n { "ref": "acme", "fields": { "name": "Acme Trading", "tier": "gold" } }\n ]\n}\n```\n\n<!-- generated:start rows -->\n\n#### Row\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `ref` | text | yes | Local handle for this row, referenced by other rows\' link fields |\n| `fields` | map of text \u2192 any value | yes | Field alias \u2192 the value, read against the field\'s declared type |\n\n<!-- generated:end rows -->\n\n<!-- generated:start rules-rows -->\n\n#### Rules the check enforces\n\n| Rule | |\n|---|---|\n| `rows.ref` | A row\'s `ref` is unique within its entity. |\n| `rows.required` | A row states every field its entity requires: it is created with what it states, its fields\' defaults unapplied. |\n| `rows.distinct` | Open rows of an entity a register lists read apart: no two state the same title and the same line under it. |\n| `rows.value` | A row\'s value fits its field: an option alias for a select, `"<entity>:<ref>"` of a row of the linked entity in this file for a link, `"self"` for a member, a relative path beside the file or a `fil_` id for files, a date in the date grammar (an hour only on a datetime), a text, number, yes/no or null otherwise \u2014 and none on a computed field. |\n\n<!-- generated:end rules-rows -->\n\nA `ref` is lowercase letters, digits and underscores, and is never persisted.\n\nA `files` cell attaches documents: paths relative to this file (no `..`, never\nabsolute), which the check proves exist and `apply` uploads into the workspace\nbefore any row is written \u2014 a paperwork business seeds its papers with its\nrows. The server accepts only `fil_` ids of files this workspace owns, which is\nwhat the upload leaves behind. After a run that wrote rows, the WORKSPACE holds\nwhich record each row became, under the row\'s own `<entity>:<ref>`:\n`delete_records` over them is how a seeded set is reset, and applying again\nre-dates it.\n\n`fields` is keyed by field alias, and every value is read against the field\'s\nDECLARED type:\n\n| Field type | Value |\n|---|---|\n| `text` / `number` / `boolean` | the value itself |\n| `date` | `"2026-03-14"`, or a relative expression (below) |\n| `select` | the option ALIAS \u2014 `"gold"`, or `["gold","vip"]` for a multi-select |\n| `select_record_link` | `"<entity-alias>:<ref>"` naming another row in this file \u2014 `"customer:acme"`, or an array for several; a paired link is stated on ONE side (the child\'s link to its parent) and its partner fills itself |\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 `model apply`/`setup` before the model is sent; or `fil_` ids of files already in this workspace |\n| `formula`, `rollup`, `lookup`, `autonumber` | not allowed \u2014 the platform writes these |\n\n### Relative dates\n\nA date cell holds a literal `YYYY-MM-DD`, or an expression relative to the day\nthe model is applied, so a screen that opens on "this month" is not empty a month\nlater:\n\n- `@today` \u2014 the day of the run, in the workspace\'s timezone\n- `@month-start` \u2014 the 1st of that month\n- either with a whole-day offset: `@today-14`, `@month-start+9`\n- on a date that holds its hour (`format: "datetime"`), that hour after the day, and always stated:\n `@today 14:30`, `@today+1 06:00`\n\n`@month-start` exists because `@today-N` cannot promise a month: applied on the\n2nd, `@today-3` lands in the previous one.\n\n## Records\n\n`records` says how a row of each entity is RECOGNISED \u2014 once per entity, keyed by\nentity alias \u2014 and every surface that draws one of its rows reads the same\nstatement: the register\'s row, a picker\'s option, a link\'s chip, a child table\'s\nrow and the record\'s header. Every entity an app lists, opens or picks has one.\n\n```jsonc\n"records": {\n "visit": {\n "title": "container_no",\n "subtitle": ["customer", "arrived_on"],\n "image": "photos",\n "status": { "field": "stage", "closed": ["gone"], "history": "visit_history" },\n "figure": "total_fees",\n "due": ["free_until"]\n },\n // A release order read against what it allows: a meter wherever it shows.\n "release": { "title": "release_no", "figure": "issued", "limits": { "issued": "units" } },\n // A dossier\'s stage is the last date it reached \u2014 no stored select.\n "dossier": { "title": "applicant", "status": { "milestones": ["received_on", { "field": "appraised_on", "when": { "kind": ["loan"] } }, "signed_on"], "closed": ["signed_on"] } }\n}\n```\n\n<!-- generated:start records -->\n\n#### Record\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `title` | alias | yes | The field that names a row \u2014 the first thing every surface shows: who or what it is, a link naming it by the linked row (a job read by its customer). An autonumber is refused; a typed number is the subtitle |\n| `subtitle` | list of alias (1\u20132) | no | Up to two fields read under the title wherever a row is drawn (a code, a date, a party) |\n| `image` | alias | no | A files field that pictures the row \u2014 never one an act keeps the document it makes in |\n| `description` | alias | no | A text field saying what the row is about \u2014 a plan\'s brief, a customer\'s note: read in its record\'s head under the title\'s line, its first lines at rest and whole on a press, and written from the head\'s \u270E. Never on a row\'s line |\n| `party` | `"person"` \\| `"organization"` | no | What each row is where the rows are people (a contact, a patient) or organisations (a customer, a supplier, a carrier); absent, the rows are things (an order, an item, a paper) |\n| `status` | [Status](#status) | no | The stage a row moves through \u2014 a single select or its milestone dates \u2014 drawn as a badge beside its title |\n| `figure` | alias | no | The one number the row stands for, read at its right (a total, a quantity left) |\n| `limits` | map of alias \u2192 (alias \\| number) | no | A number read against a bound \u2014 a field of the same row or a constant \u2014 drawn as a meter wherever it shows |\n| `gates` | map of alias \u2192 (alias \\| number) | no | A pass mark on a meter `limits` bounds \u2014 a field of the same row or a constant; a percent field is its share of the bound, any other number an amount. The meter marks it and fills complete once past it |\n| `tolerance` | map of alias \u2192 number | no | How far past its `limits` bound a meter may stand, in percent of the bound: the bound is an amount expected (received against ordered), not a cap. Without one the bound is a cap, and past it is an overrun |\n| `due` | list of alias (at least one) | no | Dates that are deadlines (stored, or computed as a date): each counts down and turns overdue |\n| `frees` | alias | no | A date ending the row\'s span of days on which what the row holds is free again (a check-out, a hire\'s return): a stay from the 3rd to the 5th holds the 3rd and the 4th, and another may start on the 5th \u2014 on a lanes board, a calendar, a roster and in `no_overlap` alike. Absent, a span of days holds its last day; a span of moments always frees its end |\n| `task` | `true` | no | A row is work someone finishes: wherever rows of it stand in a table \u2014 a register, a record\'s rows, the rows filed under one \u2014 each is led by a ring ticking it done and unticking it by the one write of the app making that move. Its status is a stored select with `closed` |\n| `applies` | map of alias \u2192 map of alias \u2192 (list of alias (at least one) \\| boolean) | no | Fields that apply only while their conditions hold (a length only on a line whose tariff charges by the metre): where one does not apply no add asks it and no surface shows it. Never the title; a required one starts at its `default` |\n| `starts` | map of alias \u2192 `"today"` \\| `"me"` | no | What a field of a row being added starts at, where that start is certain: `today` a date (a moment starts now), `me` a member, the reader adding it; every other field starts empty unless the reader narrowed the register to a value or the field declares a `default` |\n\n#### Status\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `field` | alias | no | The single select that is the row\'s state \u2014 or `milestones` instead |\n| `milestones` | list of (alias \\| [Milestone](#milestone)) (at least 2) | no | Dates in the order the work reaches them, instead of a stored select: the stage is the last one filled, ticking one stamps today, and clearing one clears every later one |\n| `closed` | list of alias (at least one) | no | Options (or milestones) that end the work: a register opens on the rows not at them, and the badge reads muted |\n| `history` | alias | no | A child entity every status change appends one row to (its link to this entity, the new status, the moment and who) \u2014 written by the app\'s own writes, never a table automation; a row the model seeds opens it at its status, the day the rows land, unless the model states its rows; with `field` only |\n\n#### Milestone\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `field` | alias | yes | A date field: the stage is reached on the day it holds |\n| `when` | map of alias \u2192 (list of alias (at least one) \\| boolean) | yes | The stage applies only while each named select holds one of these options, and each named stored yes/no is this value; otherwise the row skips it |\n\n<!-- generated:end records -->\n\n<!-- generated:start rules-records -->\n\n#### Rules the check enforces\n\n| Rule | |\n|---|---|\n| `records.title` | A title names the row by what it is \u2014 never an autonumber; a code is its subtitle. |\n| `records.image` | `image` is a files field, never one an act keeps the documents it makes in (`into`). |\n| `records.description` | `description` is a text field, read in the record\'s head \u2014 never a section\'s too. |\n| `records.status` | A status is one single select, `closed` naming options of it \u2014 or its `milestones`. |\n| `records.history` | A declared status `history` has exactly one link to the row it records, one single select holding every option of the status (or a text), exactly one date and at most one member. |\n| `records.history-derived` | A `history` the model does not declare is derived and keeps one entity\'s moves, labelled by that entity ("L\u1ECBch s\u1EED <entity label>", or "<entity label> history"): no declared table holds that label, and the entity has no other field under the link it adds. |\n| `records.history-scope` | A status history reads by its record\'s `read_scope`, answered through its one-row link to the record within the links a row rule reaches \u2014 or it states its own. |\n| `records.milestones` | Milestones are dates a person ticks, each named once; `closed` names some of them, and a deadline (`due`) is never one. |\n| `records.milestone-add` | An add asks the first milestone at most \u2014 each after it is ticked in order. |\n| `records.figure` | A figure is a number. |\n| `records.applies` | `applies` holds a field of the row other than its title to conditions of the same row \u2014 a yes/no (stored or a formula) by `true` or `false`, a single select by its options, its own or looked up through a one-row link \u2014 never to itself; a required field it holds starts at a `default`. |\n| `records.limits` | `limits` bounds a number by a number field of the row, in its unit or one of the same dimension \u2014 or by a constant, where every row reads the figure in one unit. |\n| `records.gates` | `gates` marks a pass on a meter `limits` bounds: a percent field (its share of the bound), another number in the bound\'s unit, or a constant. |\n| `records.tolerance` | `tolerance` stands on a meter `limits` bounds. |\n| `records.starts` | `starts` starts a field a person writes \u2014 `today` a date or a moment (never a span), `me` a member \u2014 and an add that does not ask a field it starts writes that start. |\n| `records.due` | A deadline (`due`) is a date. |\n| `records.frees` | `frees` names a stored day, never a moment: a span of moments already frees its end. |\n| `records.task` | A task entity (`task`) states a `status` of a stored single select with `closed` options \u2014 its ring ticks a row done by moving it to one. |\n\n<!-- generated:end rules-records -->\n\n- **The title names the row** by who or what it is \u2014 a link by the linked row (a job\n read by its customer). An autonumber is refused as a title; a typed number is the\n subtitle.\n- **The status** is one single select. `closed` options end the work: a register opens\n on the rows not in them. A `history` entity gets one row per move \u2014 its link to this\n entity, the new status (a select holding the same option aliases, or text), the\n moment and who \u2014 appended by the app\'s own writes that move the status, never by a\n table automation. A `history` the model does not declare is derived, and needs no\n `records` entry of its own.\n- **Milestones** are the alternative to a stored select: dates in the order the work\n reaches them, the stage being the last one filled (never stored). A milestone with a\n `when` applies only while its select holds those options and its stored yes/no that\n value; otherwise the row skips it.\n The record draws them as one block where ticking stamps today and clearing one clears\n every later one; every save and act re-checks that a date comes after each earlier one\n that applies. `closed` names the milestones that end the work. A register\'s add may ask\n the first milestone, never a later one; an act may stamp one, never clear it.\n- **A limit** reads a number against a bound \u2014 another field of the row, or a constant.\n A `gates` entry marks a pass on that meter: a percent field is its share of the\n bound, any other number an amount.\n- **A deadline** (`due`) counts down wherever it is drawn and is overdue once past while\n the work is open. A register over the entity says how many rows in view are overdue on\n its summary line, and pressing that count narrows to them \u2014 no reading to state.\n- **A span\'s free day** (`frees`) is the end date on which what the row held is free\n again \u2014 a check-out, a hire\'s return. A stay from the 3rd to the 5th holds two nights:\n a lanes board, a calendar and a roster draw it through the 4th, and `no_overlap` lets\n the next stay start on the 5th. Without it a span of days holds its last day; a span of\n moments always ends as the next may begin.\n- **`starts`** is where an add\'s field begins, stated only where that is certain \u2014 a\n log\'s own day at `today`, a request\'s requester at `me`. Nothing else starts a field\n but its declared `default` and what the reader narrowed the register to.\n\n## Write rules\n\n`write_rules` is what a WRITE meets, keyed by entity alias; each entity\'s is held\nby its table. Every generated write of an\napp (`create_<entity>`, `update_<entity>`, each act) re-checks these on the\nserver; the screen only mirrors them.\n\n```jsonc\n"write_rules": {\n // A customer is RECOGNISED by their address. An add that names one \u2014 from the\n // customers register or from a picker\'s "new" \u2014 reuses the row it matches and\n // opens one only where nothing does, so the book never grows a second Acme.\n "customer": { "natural_key": ["email"] },\n "order_line": {\n "fields": {\n // A line of nothing is not a line. Refused on create and on update.\n "quantity": { "min": 1, "max": 9999 },\n // Shipped inside its order\'s window: a bound read off the row a link names\n // is held from both sides \u2014 moving the order\'s dates never strands a line.\n "ships_on": { "min": "order.placed_on", "max": "order.due_on" },\n // The price is fixed at the moment of ordering \u2014 COPIED off the product,\n // not looked up for ever after.\n "unit_price": { "default_from": "product.price" },\n // Nothing is sold off an empty shelf. The picker reads only the rows that\n // answer this, and the write refuses the same rows again.\n "product": {\n "options_where": {\n "node_type": "group", "logic": "and",\n "children": [{ "node_type": "condition", "type": "number",\n "field_key": "in_stock", "operator": "greater_than", "value": 0 }]\n }\n }\n }\n },\n // One booking per room at a time, while it is held: a create or a save whose\n // span overlaps another held booking of the same room is refused.\n "booking": {\n "fields": { "ends_on": { "min": "starts_on" } },\n "no_overlap": { "from": "starts_on", "to": "ends_on", "by": ["room"], "while": { "state": ["held"] } }\n }\n}\n```\n\n<!-- generated:start write-rules -->\n\n#### Entity write rules\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `natural_key` | list of alias (at least one) | no | The field aliases a row of this entity is RECOGNISED by. A write that names a row of this entity by these values reuses the row it finds, minting one only where nothing matches. |\n| `fields` | map of alias \u2192 [Field write rule](#field-write-rule) | no | Field alias \u2192 what that field\'s value is copied from, bounded by or picked among |\n| `no_overlap` | [No overlap](#no-overlap) | no | No two rows hold overlapping spans \u2014 a create or update that would is refused |\n\n#### Field write rule\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `default_from` | text | no | Copy this field\'s value from the linked row at CREATE time \u2014 `<link alias>.<field alias>`, the link being a one-row link on this entity, required or filled by the add (the record a row is added under). The value is copied rather than looked up, so the source changing later leaves the row alone. |\n| `min` | number \\| text | no | Refuse a create or update whose value is below this: a constant for a number, or a field \u2014 `<field alias>` of the same row, or `<link alias>.<field alias>` of the one row a link names \u2014 of the same type (a return date after the departure, a line\'s day inside its trip). On a figure whose unit or currency is per row (`unit_field`, `currency_field`) a constant bound is only 0; bound it by a field of the row |\n| `max` | number \\| text | no | Refuse a create or update whose value is above this: a constant for a number, or a field of the same row or of the one row a link names |\n| `options_where` | [filter](#views) | no | Which rows of the target this link may point at \u2014 an `and` group of plain conditions over the TARGET entity\'s own fields, each `field_key` a field alias. It narrows the picker\'s read and is refused again where the write lands. |\n| `same` | list of alias (at least one) | no | One-row links this row and each row this link points at must name alike \u2014 a fee\'s invoice is one of its own visit\'s, a box\'s seal one of its own shipping line\'s: each a one-row link of both entities to the same entity. It narrows the picker\'s read to the rows naming what this row names, and a write is refused wherever it lands \u2014 on the link, or on what the two rows compare. |\n| `suggest` | text | no | A text field whose control offers, as the reader types, the distinct values a text field of a catalog holds \u2014 `<entity alias>.<field alias>` (a damage position\'s code among the codes on file); any text is still written |\n\n#### No overlap\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `from` | alias | yes | The stored date (or datetime) a row\'s span starts on |\n| `to` | alias | yes | The stored date (or datetime) a row\'s span ends on \u2014 a day it holds, unless `records` names it the day the row `frees`; a moment another row may start at |\n| `by` | list of alias (at least one) | no | Fields two rows share to compete for a span (the same employee, the same room); absent, every row |\n| `while` | map of alias \u2192 list of alias (at least one) | no | Only rows at these options of these selects hold their span (running, not closed); absent, every row |\n\n<!-- generated:end write-rules -->\n\n<!-- generated:start rules-write -->\n\n#### Rules the check enforces\n\n| Rule | |\n|---|---|\n| `write.natural-key` | A `natural_key` is text or a number a person types back, or a one-row link; a text key is unique \u2014 `unique: true`, or the key\'s fields as a set in the entity\'s `unique`. |\n| `write.default-from` | `default_from` copies `<link>.<field>` off the row a one-row link names, wherever an add fills that link (asked, or the record it is added under), into a field of the same type: a select into one declaring each of its options by alias, several options only into a multi-select. |\n| `write.bounds` | `min` and `max` bound a number or a date \u2014 by a constant a number, by a field of this row or of the one row a one-row link names either, of the same type and unit, never by itself \u2014 and `min` is never above `max`. |\n| `write.bound-per-row-unit` | On a figure whose unit or currency is the row\'s own (`unit_field`, `currency_field`) a constant bound is only 0 \u2014 bound it by a field of the row. |\n| `write.suggest` | `suggest` stands on a stored text field and names `<entity>.<field>`, a stored text field of a declared entity; the app reads that entity, so it has a `records` entry. |\n| `write.same` | `same` stands on a link and names one-row links this entity and the link\'s target both hold to one entity. |\n| `write.options-where` | `options_where` narrows a link by an `and` group of plain conditions over the target\'s own fields \u2014 a number compared in one unit on every row, a select by options it declares. |\n| `write.no-overlap` | `no_overlap` spans two date fields; `by` names values rows share \u2014 a link, a person, one option, a text or a number; `while` names single selects by their options. |\n\n<!-- generated:end rules-write -->\n\n- A `natural_key` is `text` or `number`, because a person types it back, or a\n one-row link, because a row is also recognised by the one row it belongs with (a\n fee by its visit and its kind). A key holding a `text` field is unique: `unique:\n true` on that field, or the key\'s fields as a set in the entity\'s `unique` (a\n site by its customer and its name) \u2014 two rows sharing it would make\n find-or-create pick whichever the read answered first. An add asks the key by\n default.\n- A `min` or `max` naming a field is read off the row as the save would leave it,\n and one naming `<link>.<field>` off the row the link names \u2014 the link\'s side\n re-checks its own rows when that bound moves.\n- A `default_from` copies where the add is sure to hold its link\'s row \u2014 a\n required link, or the record the row is added under (an appointment added under a\n plan takes the plan\'s patient) \u2014 and the two field types match. The copied field is\n not asked there; through an optional link the add leaves empty, it is asked.\n- A `same` link names only rows that name what this row names \u2014 a fee\'s invoice\n is one of its own visit\'s \u2014 through a one-row link both entities hold to one\n entity; its picker offers nothing until the row names one. A row added under a\n record names what it copies off it (`default_from`) from the start, so a block\n expecting one line per row of the link lists that record\'s rows alone. A save\n moving what the two compare, on either row, is refused while they would name apart.\n- An `options_where` link may be left empty; a row it names is refused again\n where the write lands unless it holds the narrowing. A link naming one kind of\n a book\'s rows (a "Shipping line" among the parties) narrows to that kind, or\n its picker offers every row.\n\nA field\'s own `required` is not here: the contract carries it, and every write\npath refuses the row by field name from it. `unique` is the text field\'s own\nclause (\xA7 `text`) \u2014 an add says so at the control before the column does. A\nfield the workspace writes (a formula, a rollup, a lookup, an autonumber, a date\nwith `derive_from`) is never asked, written or edited.\n\n## Apps\n\nAn app is ONE register over one entity and the record each row opens \u2014 or a dashboard\nof readings. The author states COMPOSITION \u2014 which fields go where; the runtime owns\nhow each thing looks, one treatment per concept. Nothing is drawn that the app does not\nname, and each fact appears once on a record. What the vocabulary has no word for is an\nact\'s own `workflow`.\n\n```jsonc\n"apps": [{\n "alias": "gate", "name": "Gate in and out", "entity": "visit",\n "register": { "columns": ["service"], "filters": ["customer", "service"] },\n "record": {\n "sections": [\n { "title": "In", "fields": ["customer", "service"], "blocks": [{ "rows": "fee", "columns": ["amount"] }] },\n { "title": "Out", "at": ["in_yard"], "fields": ["release", "seal"], "blocks": [{ "files": ["photos"] }], "acts": ["gate_out"] }\n ]\n },\n "acts": [{ "alias": "gate_out", "label": "Gate out", "when": { "stage": ["in_yard"] },\n "requires": ["release", "seal"], "set": { "stage": "gone", "left_on": "now" }, "confirm": true }],\n "checks": [{ "field": "unpaid", "blocks": ["gate_out"] }]\n}]\n```\n\n<!-- generated:start apps -->\n\n#### App\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `alias` | alias | yes | The app\'s name within the model \u2014 what `--from model.json#<alias>` picks |\n| `name` | text | yes | The job, in the words of the people who do it |\n| `description` | text | no | What the job is for, in a sentence |\n| `icon` | text | no | A lucide icon name the launcher tile draws |\n| `theme` | [Theme](#theme) | no | The launcher tile\'s colour |\n| `entity` | alias | yes | The entity whose rows this job works \u2014 the register\'s rows |\n| `books` | list of [Book](#book) (at least one) | no | Other entities the register reads as one list with `entity`\'s rows, each opened, edited and acted on in its own table: the same app over each, its fields lined up with `entity`\'s. Only a table or cards |\n| `scope` | alias | no | A required one-row link of the entity: the app works inside one row of the linked entity at a time (a project, a branch), picked from a list of them, and every row it reads, counts and adds is that row\'s |\n| `reads` | `"shared"` | no | Every member reads every row, whatever the entity\'s read scope |\n| `writes` | `"children"` | no | The reader adds and edits the record\'s child rows but not the record\'s own fields; the app\'s acts still move it |\n| `row` | [App row](#app-row) | no | How this job reads the entity\'s rows where it reads them otherwise than `records` (a cashier\'s figure is what is owed): the subtitle, figure and image stated replace the entity\'s wherever this app draws its rows \u2014 the register, a card, the record\'s header, a calendar, lane or roster entry. The title stays the entity\'s |\n| `register` | [Register](#register) | no | The rows: which columns and filters, the order, how a row is added |\n| `record` | [Record page](#record-page) | no | What a row opens: the door, its sections, and beside them its comment thread and its status history where stated |\n| `acts` | list of [Act](#act) | no | What the reader does to a record \u2014 each a press with its conditions and its write |\n| `checks` | list of [Check](#check) | no | Formulas that warn while they stand, or refuse the acts and saves they name |\n\n#### Theme\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `color` | text | yes | The tile\'s colour |\n\n#### App row\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `subtitle` | list of alias (1\u20132) | no | Up to two fields read under the title in this app, in place of the entity\'s |\n| `figure` | alias | no | The one number a row stands for in this app, in place of the entity\'s; a `limits` bound on it reads as its meter |\n| `image` | alias | no | A files field that pictures a row in this app, in place of the entity\'s |\n\n#### Book\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `entity` | alias | yes | Another entity whose rows the register reads beside its own \u2014 the same job over another table (a legacy ledger, a second branch\'s book) |\n| `fields` | map of alias \u2192 alias | no | A field of the app\'s entity \u2192 the field of this entity holding the same fact, where their aliases differ; a field the app reads lines up by its own alias otherwise, and one this entity lacks is blank on its rows and drops from what they open \u2014 an act, check or add needing it is not offered on them. Options line up by alias |\n\n#### Dashboard app\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `alias` | alias | yes | The app\'s name within the model \u2014 what `--from model.json#<alias>` picks |\n| `name` | text | yes | The job, in the words of the people who do it |\n| `description` | text | no | What the job is for, in a sentence |\n| `icon` | text | no | A lucide icon name the launcher tile draws |\n| `theme` | [Theme](#theme) | no | The launcher tile\'s colour |\n| `reads` | `"shared"` | no | Every member reads every row, whatever the entity\'s read scope |\n| `dashboard` | list of ([Metric](#metric) \\| [Breakdown](#breakdown) \\| [Trend](#trend) \\| [Pivot](#pivot) \\| [List](#list)) (at least one) | yes | The readings, in order \u2014 each over every row of its entity, windowed by the one period the reader switches |\n\n<!-- generated:end apps -->\n\n<!-- generated:start rules-app -->\n\n#### Rules the check enforces\n\n| Rule | |\n|---|---|\n| `app.alias-unique` | An app\'s alias is unique among the model\'s apps, and an act\'s among its app\'s acts. |\n| `app.records-entry` | Every entity an app lists, opens or picks \u2014 its own, a block\'s, a linked row\'s, a document\'s \u2014 has a `records` entry. |\n| `app.reads-shared` | `reads: "shared"` stands only where a table the app reads states a `read_scope`. |\n| `app.writes-children` | An app that writes only the record\'s children (`writes: "children"`) adds no row of its own entity. |\n| `app.scope` | `scope` is a required one-row link of the app\'s entity, the same on every row the app lists \u2014 never a column, filter, search field, group, `where`, `opens`, section field or add question. |\n| `app.when` | A `when` holds single selects to options and yes/nos to `true` or `false`, stored or a formula of one \u2014 a milestone\'s `when` only a stored yes/no, read before any formula over it is computed. |\n| `app.asked-written` | What an add, an act or `starts` fills is a field a person writes \u2014 never a formula, rollup, lookup, autonumber or a date the workspace stamps. |\n| `app.own-rows` | A many-link to rows that each belong to one row of this entity is never a section\'s field, an add\'s question or an act\'s ask: those rows are the record\'s own, read as a `rows` block. |\n| `app.create-asks` | An add asks fields of a table people write \u2014 never the link or the folder it files the row under, which the add sets itself. |\n| `app.create-required` | An add (`create`) asks every field its entity requires that nothing else fills \u2014 a `default`, `starts`, the record or folder it files the row under, or a `default_from` over a link it fills \u2014 or the server refuses every add. |\n| `app.books` | A register app\'s `books` each name another entity once \u2014 never the app\'s own \u2014 whose fields line up with the app\'s by alias, else by `fields` (a field the app reads, or one a report prints \u2192 one of the book\'s): read as one column type (a day apart from a moment), one value or several alike, a link to the same entity. Each book holds the title, the status, the folder (`scope`), what `where` keeps and every check locking the rows; the register is a table or cards. Two books holding a field of their own under one alias hold it as one kind of value. A report\'s `books` each name the app\'s entity or one of its books, once, and one of them holds each field its `where` and `per` narrow by. |\n| `app.book-lacks` | *Noted, never refused:* A book lacking a field the app\'s record, an act or an add reads: what reads it drops from the book\'s rows \u2014 an act, a block, a section, the add. A field only a report prints, held by a book as another kind of value, prints blank on its rows. |\n| `app.row` | An app\'s `row` states only what differs from `records`: a line that is not the title, a number for its figure, a files picture no act makes documents into. |\n| `app.files-shown` | Every files field is drawn by some app over its entity \u2014 a section\'s `fields`, a `files` block, a column, the `image`, or where an act making its papers into it (`into`) stands, a section naming the act: the made paper reads under the act, one per template. |\n| `app.document-image` | *Noted, never refused:* A linked entity holding one files field and no `image`: `image` lets a link preview its paper. |\n| `app.tasks` | *Noted, never refused:* A task entity an app lists whose rows no write of the app moves to a closed option (its ring stands disabled), or that an act ticks done and none moves back. |\n\n<!-- generated:end rules-app -->\n\n`writes: "children"` is a desk that adds and edits a record\'s child rows and never\nthe record itself. `reads: "shared"` lifts every read scope the app\'s tables state \u2014\nrefused where none of them states one.\n\n`scope` names a required one-row link of the entity: the app works inside ONE row of\nthe linked entity at a time \u2014 a project, a branch, a season. With no folder remembered\nit opens on the list of them, each with how many of the app\'s rows it holds; inside,\nevery read, count and reading narrows to that one, an add files its row under it (the\nlink is never asked), and the record states its folder in the header. A scope narrows\nthe view; who may read what stays the entity\'s `read_scope`. The folder link is never a\ncolumn, a filter, a search field, a fact or an add\'s question.\n\nA dashboard is `{ "alias", "name", "dashboard": [readings] }` with no entity, register\nor record: each reading reads every row of its entity, and one period the reader\nswitches (this month \xB7 30 days \xB7 this quarter \xB7 this year) windows each reading placed\nin time; one placed in no time reads the rows as they stand now, and its head says so.\nA `list` reading lists rows, each opening in its `app`. The readings answer the one\nquestion the dashboard\'s owner asks, in the order they ask it \u2014 rows to act on lead where\nthe answer is rows; no order of kinds is the rule.\n\n```jsonc\n// The depot owner\'s morning question: which boxes are past their free days, whose are they, is it growing?\n{ "alias": "overview", "name": "Depot overview", "dashboard": [\n { "list": "visit", "where": { "overdue": true }, "columns": ["line"], "app": "gate", "label": "Past free days" },\n { "breakdown": "visit", "by": "line", "where": { "overdue": true }, "label": "Past free days by line" },\n { "trend": "visit", "over": "arrived_on", "where": { "overdue": true }, "label": "Past free days, by arrival" }\n] }\n```\n\n### Register\n\n```jsonc\n"register": { "columns": ["service", "release"], "filters": ["customer", "service"], "create": ["container_no", "customer"] }\n```\n\n<!-- generated:start apps-register -->\n\n#### Register\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `columns` | list of alias | no | Fields read as columns after the row\'s title \u2014 on a calendar\'s entry or a lane\'s block, its facts after its title; never what the row draws itself (its subtitle, image, status, figure, or the bound its figure\'s meter reads against), which the runtime places |\n| `filters` | list of (alias \\| [Tiered filter](#tiered-filter)) (at most 3) | no | The fields the reader narrows the rows by every day, in that order \u2014 each earns its place by that daily use, so none to 3 is normal and 3 is a cap, never a quota (a select, a member, a link, a date, a yes/no, a number as a range, or its `tiers` where the business narrows by those bands daily) \u2014 each on a desk\'s line, in one Filters sheet on a phone. Never the status (its chips); each a field the rows show \u2014 a column, or a part of the row. A threshold the business names ("large orders") is a formula yes/no here; a column\'s header sorts by a day or an amount |\n| `search` | list of alias (at least one) | no | The fields the search box matches (a link by its row\'s title); a pasted list searches each line and names the lines no row matched. Absent, every word of the row |\n| `group` | alias | no | The field the rows open grouped by, the reader\'s grouping starting there \u2014 a period\'s rows read under each period, one per person; absent, ungrouped |\n| `sort` | [Sort key](#sort-key) \\| list of [Sort key](#sort-key) (at least one) | no | The order rows open in: one key, or a list of keys most significant first, each breaking the ties of the ones before it (a select orders by its options\' order); absent, the first deadline soonest first, else the newest first |\n| `tabs` | list of [Register tab](#register-tab) (2\u20135) | no | The lists the register reads its rows as, each a tab with its count over one period the reader picks above them \u2014 the stock at its end beside the arrivals and departures during it, a report\'s sheets; each keeps the register\'s filters and search. Absent, one list. Not with a calendar, roster or lanes, which read their own window |\n| `period` | `"today"` \\| `"week"` \\| `"month"` \\| `"quarter"` \\| `"year"` \\| [Period days](#period-days) | no | With `tabs`: the period they open on \u2014 `today`, `week`, `month`, `quarter` or `year` so far, or `{ days }`, the last that many days through now (a night shift running past midnight reads two); absent, this month so far |\n| `layout` | `"table"` \\| `"cards"` \\| `"calendar"` \\| `"roster"` \\| `"lanes"` \\| `"gantt"` | no | `cards` for rows read by their picture; `calendar` for rows read by their day \u2014 a month, a week, a day or a list, an entry at its hour where its date holds one and over its span where its line holds a second date; `roster` for what each row did on each day of a week or a month (who worked which shift, which vehicle ran), or with `expect` which of a set each row holds; `lanes` for rows booked on a resource over time (an appointment in its chair, a hire on its machine); `gantt` for rows each planned over a run of days, one bar a row on one axis of time (a shipment from its sailing to its arrival, a hire from its start to its return, a task of a project) \u2014 its dates in `gantt`, its lanes the register\'s `group`; absent, a table |\n| `gantt` | [Register gantt](#register-gantt) | no | With `layout: "gantt"`: the dates and facts each row\'s bar is drawn from |\n| `roster` | alias | no | With `layout: "roster"`: the entity whose rows fill the days \u2014 one single link to this entity, and a date on its row line (`records` title or subtitle). A cell reads its row\'s status, else the first single select on its line, several rows a mark each, the cell read by the one latest in that select\'s options; a row whose line holds a second date after the first fills every day to it; a day with none stays empty, never an absence |\n| `expect` | alias | no | With `roster`: a single select of the roster\'s rows whose options are the columns in place of days \u2014 each cell the row holding that option, read by its status, an empty one added there (a checklist of papers per case); the roster then has no period |\n| `of` | alias | no | With `expect`: this entity\'s own multi-select holding the options each row expects \u2014 its other columns stand blank on that row, never added. Its options are among `expect`\'s |\n| `lanes` | alias | no | With `layout: "lanes"`: a one-link of this entity whose target\'s rows are the lanes (a chair, a machine, a room), each drawn even when empty. A row is a block from the first date on its line to the second \u2014 within one day by the hour where the first holds one, else by the day over one day, a week or a month; a free stretch between blocks adds a row there, its lane, its start and the end the stretch reaches filled |\n| `loads` | map of alias \u2192 alias | no | With `lanes`: what a lane carries against what it holds, at most 2 \u2014 a number of this entity each block adds (a weight) to a number of the lane\'s row it is held within (a payload), in one unit or two of one dimension. Each lane reads the most its blocks add up to at one time in the window \u2014 a day\'s rows together \u2014 and names by how much and when a lane runs over |\n| `where` | map of alias \u2192 (list of alias (at least one) \\| boolean) | no | Only the rows whose select holds one of these options and whose yes/no (stored, or a formula) is this value \u2014 read so on the server and never offered as a chip: the rows this app works of an entity other apps read whole (the purchases, of orders both ways). A row added here holds a select\'s one option and a stored yes/no\'s value |\n| `opens` | map of alias \u2192 (list of alias (at least one) \\| boolean \\| `"me"`) | no | The view the register opens on: each entry a narrowing on a field the rows show that stands as a chip the reader may take away (the rows still owed), `"me"` a member field holding the reader (the rows mine). One on the status replaces the chips\' opening on the open work |\n| `remove` | `false` | no | The app edits its records and offers no Delete on them (a row\'s \u22EF, its record\'s \u22EF) \u2014 a desk correcting a date of records another app makes and closes; absent, a record the app writes is deleted where it stands |\n| `create` | list of alias (at least one) \\| `false` | no | The fields asked when a row is added; absent, the title, the subtitle and figure no default fills, the natural key and every required field \u2014 each a person writes; false, rows are not added here |\n| `export` | `true` \\| list of [Register report](#register-report) (at least one) | no | The rows in view saved as the register\'s report \u2014 its title, a line per thing narrowing the rows, the readings and every row: `true`, one workbook of the register\'s columns; a list, the reports a reader picks from one export menu |\n| `readings` | list of ([Metric](#metric) \\| [Breakdown](#breakdown) \\| [Trend](#trend) \\| [Pivot](#pivot)) (at least one) | no | Readings of the register\'s own entity above its rows, over the rows in view \u2014 the search, the status and the filters narrow them, but a picture\'s own field, read as the register opens on it. A press on a breakdown\'s part, a pivot\'s cell or a figure with `where` narrows the rows by its field, as a filter does |\n\n#### Tiered filter\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `field` | alias | yes | A number \u2014 stored, or a formula or rollup reading one \u2014 in one unit for every row |\n| `tiers` | list of number (at least one) | yes | The breakpoints between its bands, ascending, in the field\'s stored unit: under the first, from each to the next, from the last up \u2014 each band worded by the field\'s own format, several picked at once |\n\n#### Sort key\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `field` | alias | yes | The field rows are ordered by |\n| `desc` | `true` | no | Latest or largest first; absent, soonest or smallest first |\n\n#### Register gantt\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `start` | alias | yes | A date of this entity each row\'s bar starts at (a sailing, a hire\'s start, a task\'s start) |\n| `end` | alias | no | The date each row\'s bar ends at, read as `start` is \u2014 two days or two moments; absent, the entity\'s first `due` |\n| `milestones` | list of alias (at least one) | no | Dates of the row marked on its bar\'s line as diamonds, each named by its label (a cut-off, a delivery, the end of free time) \u2014 never `start` or `end` |\n| `planned` | [Gantt plan](#gantt-plan) | no | The plan each row is read against, drawn as a thin bar under its own \u2014 each date read as `start` is; a missing one is the bar\'s own |\n| `progress` | alias | no | A percent of this entity (0\u2013100) filling each row\'s bar as far as the work is done; absent, the bar wears its status\'s tone |\n| `after` | alias | no | A link of this entity to its own rows each row waits on \u2014 it starts once they end (finish-to-start), an arrow from each; a row starting before one ends is drawn in the danger tone |\n\n#### Gantt plan\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `start` | alias | no | The date the row was planned to start at |\n| `end` | alias | no | The date the row was planned to end at (a booked arrival) |\n\n#### Register tab\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `tab` | alias | yes | The tab\'s name: its address, and the key its rows stand under in a report |\n| `label` | text | yes | What the tab holds, in the reader\'s words |\n| `in` | alias | no | A date (stored, or a formula): the tab holds the rows dated within the register\'s period, from its start through its last day, or up to its last minute \u2014 the arrivals of a period |\n| `open` | [Tab open](#tab-open) | no | The tab holds the rows open at the period\'s end: begun before it, and not ended by then \u2014 what is in stock, out on hire or still owed at that moment |\n| `where` | map of alias \u2192 (list of alias (at least one) \\| boolean \\| `"me"`) | no | Only the rows whose select holds one of these options, whose yes/no is this value, or whose member field holds the reader (`"me"`) \u2014 every entry ANDed |\n| `sort` | [Sort key](#sort-key) \\| list of [Sort key](#sort-key) (at least one) | no | The order this tab\'s rows read in, as the register\'s `sort`; absent, the register\'s |\n\n#### Tab open\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `from` | alias | yes | The date a row begins on (stored, or a formula) |\n| `to` | alias | yes | The date a row ends on (stored, or a formula), empty while it is open |\n\n#### Period days\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `days` | integer | yes | How many days through today, at most 366 |\n\n#### Register report\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `label` | text | yes | The report\'s name, in the reader\'s words \u2014 its entry in the export menu and its file\'s title |\n| `description` | text | no | What the report holds, under its name in the menu |\n| `template` | alias | no | A template filled with the report \u2014 `title`, `lines`, `readings`, `at` (when it was made), `dates` (each date filter\'s `from` and `to`, by field), `period` (the tabs\' `from` and `to`), `per` (the value picked), every row in view (at most 20,000) under the entity\'s alias, and each tab\'s under its name: an html one made a PDF, an excel one a workbook; absent, the workbook of the register\'s columns, a sheet per tab |\n| `filename` | text | no | The file\'s name, in the template grammar over one value each \u2014 `title`, `at`, `dates.<field>.from` or `.to` of a date filter, `period.from` or `.to` with tabs, `per` with a `per`, `lines \\| lookup:<n>` \u2014 `Stock {{per}} {{period.to \\| format:"dd.MM.yyyy"}}`; absent, the template\'s name, or the report\'s title |\n| `where` | map of alias \u2192 (list of alias (at least one) \\| boolean \\| `"me"`) | no | The rows the report is of, set on the register as the chips they are before its file is made \u2014 the screen shows what the file holds |\n| `per` | alias | no | A select, a member or a one-link the reader picks one value of from the menu \u2014 the rows narrowed to it, as its chip, and one file of that value |\n| `books` | list of alias (at least one) | no | The rows of these books only \u2014 each the app\'s `entity` or one of its `books`; absent, every book\'s |\n\n<!-- generated:end apps-register -->\n\n<!-- generated:start rules-register -->\n\n#### Rules the check enforces\n\n| Rule | |\n|---|---|\n| `register.export-report` | A register\'s reports each have their own label; `per` picks one option, person or one-link row \u2014 never of the status, the folder or a field its `where` fixes. |\n| `register.export-template` | A template an export fills reads at its root only `title`, `lines`, `readings`, `at`, `dates` and its rows under the register entity\'s alias \u2014 an entity named none of those \u2014 and is the document of no act. |\n| `register.column-drawn` | A column never names what the row draws itself \u2014 its title, subtitle, image, status, figure, or the bound its figure\'s meter reads against \u2014 and names a field once. |\n| `register.filter` | A filter names a select, a member, a link, a date, a yes/no or a number of the register\'s own rows \u2014 a lookup through one-row links as the field it reads \u2014 never the status (its chips), nor one `where` fixes. |\n| `register.filter-split` | *Noted, never refused:* A filter on a field a breakdown or pivot in the band splits the rows by \u2014 a press on its part narrows by it too; a member\'s filter, which narrows to the reader\'s own rows, is never noted. |\n| `register.filter-unshown` | A filter, a narrowing the register `opens` on, and a press in its band (a breakdown\'s part, a pivot\'s cell, a figure\'s `where`) name a field its rows show \u2014 a column; the row\'s title, the line under it, its picture, status or figure, a meter\'s bound, or the unit or currency a drawn figure is read in; a deadline; a warning its line says; the group or lane it stands under. A narrowing to the reader (`"me"`) needs none. |\n| `register.tiers` | `tiers` band a number read in one unit on every row; a figure read in each row\'s own unit or currency is named bare. |\n| `register.search` | `search` names text, a code, or a link \u2014 matched by its row\'s title. |\n| `register.sort` | Each `sort` key names a field of the register\'s rows that holds an order \u2014 never files \u2014 and no field twice. |\n| `register.tabs` | A register\'s `tabs` each name a tab of their own, never a key a report already holds; a tab\'s `in` and `open` dates are dates of its rows, `open`\'s two different; its `where` and `sort` hold as the register\'s do, and never narrow by the status, which its chips split. Only a table or cards reads tabs, and a band reading\'s `tab` names one of them. |\n| `register.group` | `group` names one value per row \u2014 a single select, a one-row link, one member, a date, a yes/no or a text, a lookup through one-row links as the field it reads \u2014 never the status (its chips); only a table groups, and a gantt into its lanes; grouping by the title needs a subtitle to lead each row. |\n| `register.readings-own` | A register\'s `readings` read its own entity; another entity\'s stand on a dashboard. |\n| `register.readings-restate` | A register\'s metric never sums the row\'s figure or counts the rows without a `where`: the summary line totals the figure and the status chips count the rows. |\n| `register.where-opens` | `where` and `opens` hold the register\'s own selects and yes/nos, and `opens` a member field holding the reader (`"me"`); a field `where` fixes is never a filter nor opened otherwise, and `opens` stays among the options `where` keeps. |\n| `register.roster` | `layout: "roster"` and `roster` go together: the roster\'s entity has one single link to this one and a date on its line, a roster draws no `columns`, `expect` needs `roster` and `of` needs `expect`. |\n| `register.roster-expect` | A roster\'s `expect` is a single select of its rows, and `of` this entity\'s multi-select whose every option is one of `expect`\'s. |\n| `register.gantt` | `layout: "gantt"` and `gantt` go together: `start` and `end` (absent, the entity\'s first `due`) are two different dates of the row, both days or both moments; each milestone another date of it, each planned date read as `start` is, `progress` a percent and `after` a link to rows of the same entity. |\n| `register.lanes` | `layout: "lanes"` and `lanes` go together: `lanes` is a one-row link to an entity with a `records` entry, the rows name a date on their line, a block\'s two ends are read alike (two days or two moments), and `loads` needs `lanes`. |\n| `register.loads` | `loads` names one to 2 numbers a block adds \u2014 never a percent \u2014 each against a number of the lane\'s row, in one unit or two of one dimension. |\n| `register.band-pivot` | *Noted, never refused:* A pivot in a register\'s band: its grid reads on a dashboard, and the band leads with a `metric`. |\n| `register.band-headline` | *Noted, never refused:* A register over rows that move (a status or a deadline) stating no `readings`: the band leads with the job\'s headline number. |\n| `register.remove` | A register\'s `remove: false` hides the Delete its records would offer, so it stands only where the app writes its records. |\n\n<!-- generated:end rules-register -->\n\nThe row is the entity\'s `records` entry: its picture, title and the line under it,\nthen the status, the columns, and the figure at the right; the title, the status and a\ncolumn of days or amounts sort the rows by their header. The status is always the chips, with\ncounts, opening on the rows not closed (every row on a calendar or a lanes board, whose\nwindow of time narrows them) \u2014 never one of `filters`. `filters` are the ones a reader\nnarrows by every day \u2014 none to three, never a quota \u2014 in order of use, each a field the\nrows show (a column, a part of the row), each on the line\nwith the search and the chips, the grouping one\nchip at its end; a member filter offers "mine". A date filters by a range of days \u2014 of\nminutes on a `datetime` field, its end not held, so back-to-back shifts read each row\nonce \u2014 and can keep the rows holding no date too (in the yard at a moment: in before it,\nout after it or not yet). A number filters by a range typed in its\nunit or currency \u2014 a figure read in each row\'s own, in the one picked beside it \u2014 with a\nslider over a percent or a figure against a constant `limits`; `{ "field", "tiers" }`\noffers the bands its breakpoints cut in place of a range (`[1000, 5000]` on a weight in\nkg: under 1.000 kg, 1.000\u20135.000 kg, 5.000 kg and over), refused on a figure read in each\nrow\'s own unit. A threshold with a name is a formula yes/no, never a range. A\ncolumn the row already draws is refused. `sort` is one key or a list of keys, most\nsignificant first, each breaking the ties of the ones before it \u2014 a shipping line in its\nselect\'s option order, then the arrival day soonest first:\n`[{ "field": "shipping_line" }, { "field": "arrived_on" }]`; a header\'s press leads them.\nAbsent `sort`, rows open soonest deadline first where the row has a `due`, else newest first. `search` names the fields\nthe search box matches \u2014 a link by its row\'s title \u2014 and a pasted list searches each\nline, naming the lines no row matched; absent, it matches every word of the row.\n`export` saves the register as its report \u2014 the title, a line per chip, the search and\nthe moment it was exported, the readings, and every row in view, at most 20,000 of them.\n`export: true` saves it as a workbook, the readings on a sheet before the rows. A list\nstates the reports the business sends \u2014 one a button, several one menu \u2014 each a `label`,\na `description`, and a `template` filled with `title`, `lines`, `readings`, `at` (the\nmoment it was made), `dates` (each date filter\'s `from` and `to` by field, a day or a\nmoment as the filter bounds the rows) and the rows under the entity\'s alias (an html one\nmade a PDF, an `excel` one a workbook; absent, the workbook). A report\'s `filename` names\nits file in the template grammar over the same keys but the rows \u2014\n`"Stock {{dates.arrived.to | format:\\"dd.MM.yyyy\\"}}"`; absent, the template\'s name, or\nthe label. A report of some rows (`where`), or of one value the reader picks (`per`: one\nline\'s file), first narrows the register as its chips, so the screen shows what the file holds.\n`readings` read the register\'s own rows above them: the rows in view, so the search,\nthe status and the filters narrow each one, but a picture\'s own field, which it reads as\nthe register opens on it. They are optional \u2014 state them where a\npicture answers something the rows cannot \u2014 and stand under the title in one compact\nband: lead with the job\'s headline number (a `metric`), then at most the mix (a\n`breakdown`) and the movement (a `trend`) \u2014 a `pivot` stands under the band as a whole table, every line and its totals. A picture\nstands only where it splits the rows in view into two parts or more, one of them\nholding two rows or more; a split whose rows\nall fall in one part, and a lone figure with no picture beside it, are said on the\nsummary line instead, so a band stands only holding a picture or two figures. The summary line already totals the\nfigure over the rows in view and the status chips count them, so a `metric` summing the\nfigure, or counting the rows of an entity with a status, is refused unless its `where`\nnarrows it.\n\n`where` keeps the rows the app works, read so on the server and never a chip: a\npurchase desk over orders of both directions states `"where": { "direction":\n["purchase"] }`, and a row added there is written holding the one option (a select kept\nat several is asked among them). `opens` is the view the register opens on, each entry a\nchip the reader may take away: a cashier opens on `"opens": { "owes": true }`. Both take\na select\'s options or a yes/no\'s value, stored or a formula; `opens` on the status is\nthe chips\' opening choice. `opens` also takes `"me"` on a member field \u2014 `"opens": {\n"assignee": "me" }` opens on the rows assigned to whoever reads, the server reading the\nreader, never the page; `where` refuses it, since it fixes the rows for every reader\n(who may read a row at all is the entity\'s `read_scope`).\n\n`layout: "calendar"` places each row on its day \u2014 a month, a week, a day or a list (a\nphone reads the list or a day): at its hour where the date holds one, over its span\nwhere the row\'s line holds a second date after it, its line\'s other words and the\nregister\'s `columns` after its title. The calendar reads the window in view, and its\ncount and the status chips count that window, opening on every row.\n\n`layout: "lanes"` with `lanes` naming a one-link draws each row of the linked entity as\na lane (a chair, a machine, a room), empty ones too, and each register row as a block\nfrom the first date on its line to the second: over the hours of one day where the first\nholds its hour, else over the days of one day, a week or a month. A block reads the row\'s\nname and the first of its `columns`; a free stretch between blocks adds a row there, its\nlane and its start set, and its end where the next block bounds it. A phone lists each\nlane\'s blocks and free stretches in clock order. Rows naming no lane stand first, in a lane\nof their own; where the app writes the link, each block moves to another lane from its menu.\nInside a folder (`scope`), the lanes are the ones whose own one-link names that folder \u2014 a\nbranch\'s rooms, never every branch\'s. A span\'s end holds its day unless `records` names it\nthe day the row `frees`.\n\n`loads` names what a lane carries: `{ "weight": "payload", "volume": "box" }` sums each\nblock\'s number and reads it against the lane row\'s own, at most two. A lane holds its\nblocks while they stand, so it reads the most they add up to at one time in the window \u2014\na day\'s rows together \u2014 named, past its bound, by how much and on which day or at which\nhour. The two are in one unit, or two of one dimension (kg against t); a block then reads\nwhat it adds, and the move menu reads each lane as it would stand with the block on it.\nA bound a single row must keep (no parcel heavier than its van) is a `write_rules` `max`\nthrough the link, which refuses the move.\n\n`layout: "gantt"` with `gantt` draws each register row as one bar on one axis of time:\n`{ "start": "etd", "end": "eta", "milestones": ["cut_off"], "planned": { "end": "booked_eta" },\n"progress": "done", "after": "waits_on" }`. `end` defaults to the entity\'s first `due`, and an\nentity with neither is refused; `start` and `end` are two dates of the row, both days or both\nmoments, and each planned date is read as `start` is. `progress` is a percent (0\u2013100);\n`after` is a link to rows of the same entity, one or several, each ending before the row\nstarts. The bars stand in the lanes of the register\'s `group`. Where the app writes the\nrow and a person writes a bar\'s date, the bar is dragged through the row\'s save, which then\nwrites those two dates though no section places them; a computed date, one an act sets, or\nan app with `writes: "children"` drags nothing.\n\n`layout: "roster"` draws the register\'s rows down the side and the days of a week or a\nmonth across; `roster` names the entity whose rows fill the days \u2014 each stands on one\nregister row by its single link to it, and on the first date of its line (a shift on a\nperson\'s day, a run on a vehicle\'s), on every day through the second date where its line\nholds one. A cell reads its row\'s status, else the first single select on its line (a\nshift\'s kind), several rows their count; a week\'s cells say their words and one more\nfact of the line, a month\'s keep their marks. A day holding none stays empty: nothing\nwas due, which is never an absence. A filled cell opens its row; an empty one adds a row\nthere, the register row and the day set, asking what any add of it asks \u2014 unless no\nperson writes those rows (`writes: false`). With `expect` (a single select of those\nrows) the columns are its options in place of days \u2014 a checklist of papers \u2014 and `of`\n(this entity\'s multi-select) names the ones each row expects: a gap among them reads as\none and adds its row, the others stand blank, and each row reads what it holds of them,\n`2/3`. A roster states no `columns` and needs no `readings`: its legend counts each\nstate in view.\n\n```jsonc\n"register": { "layout": "roster", "roster": "run", "filters": ["operator"] }\n```\n\nAn app whose job reads its rows otherwise than the entity\'s `records` restates them in\nits own `row` \u2014 the cashier\'s figure is what is still owed, its line the service and the\nday. The subtitle, figure and image it states replace the entity\'s wherever that app\ndraws a row of it; every other app reads `records`, and the title is the entity\'s.\n\n### Readings\n\nA reading is one number, or one picture of numbers, over rows. Where it stands decides\nwhich rows: a dashboard reads every row, a register\'s `readings` the rows in view, a\nrecord\'s reading block the child rows under the record. The picture is the runtime\'s,\nchosen from what the reading means \u2014 no key picks a chart.\n\n```jsonc\n{ "metric": "visit", "value": "fees", "where": { "paid": false }, "over": "arrived_on", "label": "Unpaid" }\n{ "breakdown": "visit", "by": "line" }\n{ "trend": "visit", "over": "arrived_on", "value": "fees" }\n{ "pivot": "visit", "rows": "size", "columns": "line" }\n{ "list": "visit", "where": { "stage": ["in_yard"] }, "columns": ["line"], "app": "gate", "label": "In the yard" }\n{ "list": "task", "where": { "assignee": "me" }, "label": "My tasks" }\n```\n\nA reading\'s `where` keeps rows by a select\'s options, a yes/no\'s value, or `"me"` on a\nmember field \u2014 the rows that name whoever reads, read so on the server.\n\n<!-- generated:start apps-readings -->\n\n#### Metric\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `metric` | alias | yes | The entity whose rows are counted, or summed, into one number |\n| `value` | alias | no | A number field summed over the rows; absent, the rows are counted |\n| `where` | map of alias \u2192 (list of alias (at least one) \\| boolean \\| `"me"`) | no | Only the rows whose select holds one of these options, whose yes/no (stored, or a formula) is this value \u2014 each its own, or looked up through one-row links \u2014, or whose member field holds the reader (`"me"`) \u2014 every entry ANDed |\n| `tab` | alias | no | On a register\'s band, the tab whose rows it reads, whichever tab is open; absent, the open tab\'s |\n| `over` | alias | no | The date that places a row in time \u2014 its own (stored, or a formula), or its parent\'s through a lookup over a one-link: a dashboard\'s period windows the rows by it (a reading without one reads the rows as they stand now), and a metric reads its recent periods beside the number |\n| `target` | number | no | The number it is read against, where no `limits` bounds its value (a bounded value is read against the sum of its bound) |\n| `better` | `"up"` \\| `"down"` | no | Which way its change over the recent periods `over` reads is good; absent, a sum\'s rise and a count kept by `where` falling |\n| `label` | text | yes | What the reading is, in the reader\'s words |\n\n#### Breakdown\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `breakdown` | alias | yes | The entity whose rows are split into parts |\n| `by` | alias | yes | The single select, one link, member or yes/no (stored, a formula, or a lookup of one through one-row links) each row is counted under |\n| `value` | alias | no | A number field summed over the rows; absent, the rows are counted |\n| `where` | map of alias \u2192 (list of alias (at least one) \\| boolean \\| `"me"`) | no | Only the rows whose select holds one of these options, whose yes/no (stored, or a formula) is this value \u2014 each its own, or looked up through one-row links \u2014, or whose member field holds the reader (`"me"`) \u2014 every entry ANDed |\n| `tab` | alias | no | On a register\'s band, the tab whose rows it reads, whichever tab is open; absent, the open tab\'s |\n| `over` | alias | no | The date that places a row in time \u2014 its own (stored, or a formula), or its parent\'s through a lookup over a one-link: a dashboard\'s period windows the rows by it (a reading without one reads the rows as they stand now), and a metric reads its recent periods beside the number |\n| `label` | text | no | What the reading is, in the reader\'s words; absent, the `by` field\'s label |\n\n#### Trend\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `trend` | alias | yes | The entity whose rows are counted, or summed, per period |\n| `over` | alias | yes | The date each row falls on \u2014 its own (stored, or a formula), or its parent\'s through a lookup over a one-link |\n| `ahead` | `true` | no | Counts forward: the current period first, then the ones after it, over the rows dated today or later (arrivals by their ETA); absent, the periods up to today |\n| `value` | alias | no | A number field summed over the rows; absent, the rows are counted |\n| `where` | map of alias \u2192 (list of alias (at least one) \\| boolean \\| `"me"`) | no | Only the rows whose select holds one of these options, whose yes/no (stored, or a formula) is this value \u2014 each its own, or looked up through one-row links \u2014, or whose member field holds the reader (`"me"`) \u2014 every entry ANDed |\n| `tab` | alias | no | On a register\'s band, the tab whose rows it reads, whichever tab is open; absent, the open tab\'s |\n| `label` | text | no | What the reading is, in the reader\'s words; absent, the value\'s label, else the entity\'s |\n\n#### Pivot\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `pivot` | alias | yes | The entity whose rows are counted, or summed, in a grid |\n| `rows` | alias | yes | The single select, one link, member or yes/no (stored, a formula, or a lookup of one through one-row links) down the side |\n| `columns` | alias | yes | The single select, one link, member or yes/no (stored, a formula, or a lookup of one through one-row links) across the top |\n| `value` | alias | no | A number field summed over the rows; absent, the rows are counted |\n| `where` | map of alias \u2192 (list of alias (at least one) \\| boolean \\| `"me"`) | no | Only the rows whose select holds one of these options, whose yes/no (stored, or a formula) is this value \u2014 each its own, or looked up through one-row links \u2014, or whose member field holds the reader (`"me"`) \u2014 every entry ANDed |\n| `tab` | alias | no | On a register\'s band, the tab whose rows it reads, whichever tab is open; absent, the open tab\'s |\n| `over` | alias | no | The date that places a row in time \u2014 its own (stored, or a formula), or its parent\'s through a lookup over a one-link: a dashboard\'s period windows the rows by it (a reading without one reads the rows as they stand now), and a metric reads its recent periods beside the number |\n| `label` | text | no | What the reading is, in the reader\'s words; absent, the two fields\' labels |\n\n#### List\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `list` | alias | yes | The entity whose rows are listed, each in its `records` anatomy |\n| `where` | map of alias \u2192 (list of alias (at least one) \\| boolean \\| `"me"`) | no | Only the rows whose select holds one of these options, whose yes/no (stored, or a formula) is this value \u2014 each its own, or looked up through one-row links \u2014, or whose member field holds the reader (`"me"`) \u2014 every entry ANDed |\n| `columns` | list of alias | no | Fields read after each row\'s title |\n| `app` | alias | no | An app of this model over the same entity that a row opens in |\n| `label` | text | yes | What the reading is, in the reader\'s words |\n\n<!-- generated:end apps-readings -->\n\n<!-- generated:start rules-reading -->\n\n#### Rules the check enforces\n\n| Rule | |\n|---|---|\n| `reading.value` | A reading sums a number, never a percent \u2014 percents do not add up. |\n| `reading.split` | `by`, `rows` and `columns` split by one value each row holds \u2014 a single select, a one-row link, one member or a yes/no, stored or a formula, a lookup through one-row links as the field it reads \u2014 and a pivot crosses two different fields. |\n| `reading.over` | `over` is a date: the row\'s own, stored or a formula, or its parent\'s through a lookup over a one-row link. |\n| `reading.where` | A reading\'s `where` keeps rows by a select\'s options, a yes/no\'s `true` or `false`, or a member field holding the reader (`"me"`). |\n| `reading.target` | `target` reads a value nothing bounds \u2014 a value `limits` bounds reads against the sum of its bound. |\n| `reading.better` | `better` needs `over`: a change is read over the periods its date places the rows in. |\n| `reading.list-app` | A list\'s `app` is a register app of this model over the list\'s own entity. |\n\n<!-- generated:end rules-reading -->\n\nA reading counts its rows, or sums the number `value` names \u2014 never a percent. `by`,\n`rows` and `columns` split the rows by one value each: a single select, a one-row link,\none member, or a yes/no \u2014 stored or a formula, its parts read as its label and the\nothers. `over` is a date, the row\'s own (stored or a formula) or its parent\'s through a\nlookup over a one-link: a trend\'s periods, a metric\'s recent periods beside its number,\nand what a dashboard\'s period windows. `ahead: true` reads a trend forward from the\ncurrent period over the rows still to come. `where` keeps rows whose select holds one\nof the options, or whose yes/no is the value, every entry ANDed \u2014 a band leads with the\njob\'s exception as a formula\'s yes/no (what is late, what is short), never a stage the\nstatus chips already count. A metric summing a value `limits` bounds reads against the\nsum of its bound (`10 / 14`); `target` reads one against a number nothing bounds. A\nmetric\'s change is green where it moves the good way: a sum\'s rise, a count kept by\n`where` falling; `better` states the other way where that reads wrong \u2014 money going out\n(`"better": "down"`). A register reads only its own entity; a `list` stands only on a\ndashboard.\n\n### Record\n\n```jsonc\n"record": {\n "door": "page",\n "sections": [\n { "title": "Details", "fields": ["size", "line", "built_on"] },\n { "title": "Gate in", "at": ["arriving", "in_yard"], "fields": ["arrived_on", "truck"],\n "blocks": [{ "rows": "fee", "where": { "leg": ["in"] }, "expect": "kind", "columns": ["amount", "paid_by"] }],\n "acts": ["gate_in_paper"] },\n { "title": "Sell to a buyer", "description": "Moves the container to the buyer\'s stock once it has left.", "acts": ["sell"] }\n ]\n}\n```\n\n<!-- generated:start apps-record -->\n\n#### Record page\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `door` | `"page"` \\| `"drawer"` \\| `"beside"` | no | A page for work read at length; a drawer for rows worked one after another from the register; `beside` for rows worked one after another while the list stays in view \u2014 the register\'s list and the open record side by side, over a table register alone. Each stacks its sections, and from three sections on navigates them \u2014 a page by a rail, a drawer by tabs; absent, a page where a section has `at` or is a `page`, the record has more than three sections or one stands beside the rest (`side`), else a drawer |\n| `sections` | list of [Section](#section) (at least one) | no | The record in the order its work reaches it \u2014 each section its fields, then its blocks, then its acts. Absent, one section of every field a person writes that no other place shows |\n| `comments` | `true` | no | The record\'s comment thread beside the sections \u2014 who wrote what and when, with a composer \u2014 stated only where the job\'s people discuss a record or the owner asks for one. Absent, it is drawn nowhere |\n| `history` | `true` | no | The record\'s status history beside the sections \u2014 each move, when and who \u2014 stated only where the owner asks for an audit trail of the moves: the rail already marks the steps passed, and the entity\'s `status.history` is written either way. Absent, it is drawn nowhere |\n\n#### Section\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `title` | text | yes | What the section is, in the reader\'s words \u2014 its heading, and its name on the record\'s rail or tab |\n| `description` | text | no | One sentence under the title: what the section is for, or what its act does |\n| `at` | list of alias (at least one) | no | Options of the record\'s status (or its milestones) during which this section is the work now \u2014 a step of the path the record moves through: its rail item reads now while the status holds one and this app has work there, done once the record\'s history says it went through one \u2014 or it is a step of the path before where that history opens (with none, where the record stands) that no act moves the work past \u2014 and it stands before the section that is the work now, and waiting otherwise; a section every option of which ends the work, past the path\'s end or entered by its own act, is an exit: no item and nothing drawn until the record stands in it, then set apart after the steps and never one; A page opened from the register opens at it. It never locks what the section holds: its fields are written as the write rules and checks allow at any stage. An act it names is offered during these alone: its `when` holds the status to them, or on milestones it stamps the step after one of them \u2014 a detour and an exit are sections of their own, a detour stated before the step it returns to |\n| `fields` | list of alias (at least one) | no | The record\'s own fields in this section, in the order they are read \u2014 never one the header draws: the row\'s title, subtitle, image, status or figure |\n| `blocks` | list of [block](#blocks) (at least one) | no | What the section holds besides its own fields, under them, in order |\n| `acts` | list of alias (at least one) | no | Acts of this app drawn under the section\'s fields, their asks as more of them and what each lacks said above its button; the header draws no act, so every act of the record itself is named by one section \u2014 an act of a child (`of`) stands on the child\'s row and is never named here. An act moving the status, offered at ONE step (the stage its `when` holds), stands at that step\'s foot when it moves the work forward \u2014 to a later option, an exit too \u2014 or back to a step the work passes anyway; one offered at several steps, or moving back into a detour, stands in the section it enters, which draws it while the work waits \u2014 an exit\'s in the header\'s \u22EF, since an exit stands only once the record is in it. A correction \u2014 `danger` and not one of its step\'s outcomes (no other status move offered at the same moment) \u2014 waits in the section heading\'s \u22EF, and one no section names in the header\'s \u22EF; every other act is named by a section |\n| `when` | map of alias \u2192 (list of alias (at least one) \\| boolean) | no | Shown only while each named select of the record holds one of these options, and each named yes/no (stored, or a formula) is this value |\n| `side` | `true` | no | The section the rest are worked from, drawn in a pane beside them rather than in their scroll \u2014 the photos looked at, the pool picked from, the paper typed off; at most one on a record, which opens on a page door. It holds what any section holds |\n| `page` | `true` | no | A page of its own, off the record\'s scroll: its own heading and a way back, reached from the record\'s rail after the scroll\'s sections, or from a link at its place where the record has no rail \u2014 for what makes the record worse stacked in it: a long list of related rows, a workspace of its own. Only on a page door, never a step (`at`) |\n\n<!-- generated:end apps-record -->\n\n<!-- generated:start rules-record -->\n\n#### Rules the check enforces\n\n| Rule | |\n|---|---|\n| `record.placed-once` | A field, a block and an act each stand once on a record: the header draws the title, subtitle, image, status (or milestones), figure and folder, and a field\'s one other place is a section\'s `fields`, a files block or a text block \u2014 save the image, which one files block may draw again, whole. |\n| `record.foot-total` | A rows block\'s foot totals the child\'s figure and each summed column over the rows it lists; the record\'s rollup summing that field over those rows (the same `where`) is that foot, never also one of a section\'s `fields`. |\n| `record.fact-restates` | A section\'s field never looks up what its link already reads where it stands: the linked row\'s title and line beside the link, the figure or bound its meter reads on the header\'s line. |\n| `record.section-holds` | A section holds fields, blocks or acts. |\n| `record.at` | `at` names options of the record\'s stored status, or its milestones; a record with no status has no steps. |\n| `record.page` | A section is a `page` only on a page door \u2014 never a drawer\'s or one beside the list \u2014 and never a step (`at`), its title naming an address no other page shares (a letter or a digit at least), and never every section. |\n| `record.side` | At most one section stands beside the rest (`side`), on a page door, and never also a page of its own. |\n| `record.beside` | A `door: "beside"` record opens beside a table register \u2014 never a board, cards or a calendar, which draw no list to stand beside. |\n| `record.history` | `record.history` draws a status history the entity records (`status.history`); no block reads that history. |\n| `record.section-acts` | A section\'s `acts` name acts of the record itself: an act of a child (`of`) stands on the child\'s row, and one over the register\'s rows (`on`) on the register. |\n| `record.act-in-section` | Every act of the record stands in the `acts` of the section whose work it is; only a correction no section owns \u2014 `danger`, and no other status move offered at the same moment \u2014 waits in the header\'s \u22EF. |\n| `record.move-home` | A status move offered at one step stands at that step\'s foot when it moves the work forward, to an exit, or back to a step it passes anyway; one offered at several steps, or moving back into a detour, stands in the section `at` the status it enters. |\n| `record.act-staged` | An act at a staged section\'s foot is offered only during that section\'s steps: its `when` holds the status to options the section names \u2014 on milestones, it stamps the step after one of them. |\n| `record.act-hidden` | An act is never offered only while its section\'s `when` hides the section. |\n\n<!-- generated:end rules-record -->\n\nThe record is its `sections`, stacked in the order the work reaches them. A section is\nits fields, the acts under them, then its blocks \u2014 the acts at its foot instead where one\nreads what a block holds or none of its fields \u2014 and holds at least one of them. From\nthree sections on, the record is navigated by their titles: a page by a rail that jumps\nto each (a strip of the same names on a phone), a drawer by tabs, the companion the last;\nwith fewer, the sections just stack. `description` is one sentence under the title.\n`at` names the status options (or the milestones) during which the section is the work\nnow \u2014 only on a record that truly moves through those stages: the rail marks it done,\nnow or waiting (an item of no `at` is its title alone), and the section stands even holding\nnothing yet \u2014 it never hides a field, locks it or gates an act, and a record opens at its\nhead. `page: true` makes a section a page of its own under the\nrecord\'s, off its scroll, with a way back \u2014 rarely, for a long related list or a workspace\nof its own that would make the record worse stacked; never on a drawer or a step. The\nheader draws no act: every act on the record is named by the section whose work it is, and one no section names is refused, naming its likely\nsection \u2014 only a correction no section owns waits in the header\'s \u22EF beside Delete. An act\nmoving the status and offered at ONE step (its `when`) stands at that step\'s foot when it\nmoves the work forward \u2014 to the next step, or to an exit later in the status\'s options\n(Reject beside Approve) \u2014 or back to a step the work passes anyway. One offered at SEVERAL\nsteps, or moving back into a detour, is named by the section it enters: a detour draws it and its\ngaps alone while it waits. A `danger` act is a correction \u2014 waiting in the \u22EF of what it acts\non: its section\'s heading, its row, the header\'s where no section names it \u2014 unless it moves\nthe status while another status move is offered at the same moment; then it is one of that\nstep\'s outcomes, a button. Cancel offered at Held and at Confirmed, beside Confirm, is an\noutcome into an exit: state `{ "title": "Cancelled", "at": ["cancelled"], "fields": ["reason"],\n"acts": ["cancel"] }`. An exit stands only once the record is in it, its reason there and its\nrail item apart from the path; until then Cancel waits in the header\'s \u22EF. `comments: true`\nputs the record\'s comment thread beside the sections, and `history: true` its status history \u2014\nthe history never as a section. Both are opt-in: state them only where the job\'s people\ndiscuss a record, or the owner asks for an audit trail of its moves.\n\nA field, a block and an act is each placed ONCE on a record \u2014 the header, one section;\na second place is refused. The header draws the entity\'s `records` title, subtitle,\nimage, status (or milestones) and figure, so none of them is named again in a section\'s\n`fields` or the register\'s `columns` \u2014 save the image, which one `files` block may draw\nagain, whole at reading size (an incident\'s photo, a scan), the header keeping its mark. Refused too: a section holding nothing, an `at`\non a record with no status, and a section\'s act that is a child\'s or runs over the\nregister\'s rows.\nAbsent `sections`, one section holds every field a person writes that the header does\nnot show. An editable field rests as its control and saves as it changes; everything\nelse is plain text. A section or a block with `when` is shown only while each named\nselect of the record holds one of its options. The one layout serves every entity: a\ncustomer or a product is sections without `at`.\n\n### Blocks\n\n```jsonc\n"blocks": [\n { "rows": "fee", "columns": ["kind", "amount"] },\n { "rows": "fee", "where": { "leg": ["out"] }, "columns": ["kind", "amount"], "under": "invoice" },\n { "agenda": "booking", "columns": ["guide"], "start": "arrival" },\n { "timeline": "note" },\n { "files": ["photos", "papers"] },\n { "text": "remarks", "when": { "stage": ["held"] } },\n { "metric": "fee", "value": "amount", "label": "Fees" },\n { "breakdown": "fee", "by": "kind" },\n { "trend": "fee", "over": "charged_on", "value": "amount" },\n { "pivot": "fee", "rows": "kind", "columns": "method" }\n]\n```\n\n<!-- generated:start apps-blocks -->\n\n#### Rows block\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `rows` | alias | yes | A child entity whose rows belong to this record |\n| `via` | alias | no | The child\'s link to this record, where it has more than one |\n| `title` | text | no | The block\'s heading; absent, the child entity\'s or the field\'s own label |\n| `columns` | list of alias | no | The child\'s fields read as columns after its title \u2014 never what its row draws (subtitle, image, status, figure, or the bound its figure\'s meter reads against), though an `expect` block\'s figure is a column, filled in place. |\n| `create` | list of alias (at least one) \\| `false` | no | The child\'s fields asked when a row is added here; absent, its title, its picture, the subtitle and figure no default fills, the block\'s columns, its natural key and every required field \u2014 each a person writes; false, rows are not added here |\n| `where` | map of alias \u2192 list of alias (at least one) | no | Only the child rows whose single select holds one of these options \u2014 every entry ANDed, read so on the server; a row added here holds them (one leg\'s fees, of a record holding both legs\'). Each select is one the child requires, so no row stands in no block |\n| `expect` | alias | no | The child\'s title when the record holds one row per value of it \u2014 a single select (one per option, in order) or a one-row link to a catalog (one per row the link may point at): a value not yet filled reads as its own empty line, and no value holds two rows. The block\'s `columns` name at least one field a person fills in place |\n| `of` | alias | no | With `expect` on a select: the record\'s own multi-select holding the options this record expects a line for \u2014 the options it holds, in order, rather than every option. Its options are among the title\'s |\n| `under` | alias | no | The child\'s one-link to a document that covers its rows (an invoice over its fee lines), whose many-link points back and which links to this record: each document stands as a heading over the lines it covers; a filled line no document covers says so on its own row with a make for one document over it, and two or more waiting take a combined make in the block\'s heading. Never also a rows block of the document |\n| `filters` | list of (alias \\| [Tiered filter](#tiered-filter)) (at most 3) | no | The child\'s fields the reader narrows the block\'s rows by, as a register\'s `filters` \u2014 none to 3, each a field its rows show (a column, or a part of the row), its status among them where the business narrows by it \u2014 never on a task list, whose status chips narrow it |\n| `search` | list of alias (at least one) | no | The child\'s fields the block\'s search box matches (a link by its row\'s title), a pasted list line by line; absent, the block has no search box |\n| `group` | alias | no | The child\'s field the block\'s rows open grouped by, as a register\'s `group` \u2014 the reader\'s grouping starting there; absent, ungrouped |\n| `sort` | [Sort key](#sort-key) \\| list of [Sort key](#sort-key) (at least one) | no | The order the block\'s rows open in, as a register\'s `sort` \u2014 within each group where the block groups them; absent, the child\'s first deadline soonest first, else the newest first |\n| `edits` | list of alias (1\u20132) | no | The child\'s fields each line holds as its control, written where the line stands (a quote line\'s sell price) \u2014 a number, a date, words or one option the row\'s save writes, each a column or the figure, at most 2; every other value reads, and the row opens to write it |\n| `remove` | `false` | no | This list offers no Delete on its rows (its open row and \u22EF); they are deleted where another list of them or their own record offers it \u2014 e.g. lines that are a selection of rows kept elsewhere |\n| `when` | map of alias \u2192 (list of alias (at least one) \\| boolean) | no | Shown only while each named select of the record holds one of these options, and each named yes/no (stored, or a formula) is this value |\n\n#### Timeline block\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `timeline` | alias | yes | A child entity read as a log of dated entries, newest first, with a composer where the app adds entries, each corrected and removed where it stands as a rows block\'s row (entries planned ahead are an `agenda`) \u2014 never the record\'s status history, which `record.history` draws beside the record |\n| `via` | alias | no | The child\'s link to this record, where it has more than one |\n| `title` | text | no | The block\'s heading; absent, the child entity\'s or the field\'s own label |\n| `create` | list of alias (at least one) \\| `false` | no | The child\'s fields asked when a row is added here; absent, its title, the subtitle and figure no default fills, its natural key and every required field \u2014 each a person writes; false, rows are not added here |\n| `when` | map of alias \u2192 (list of alias (at least one) \\| boolean) | no | Shown only while each named select of the record holds one of these options, and each named yes/no (stored, or a formula) is this value |\n\n#### Agenda block\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `agenda` | alias | yes | A child entity read as planned entries on a time axis: by day, soonest first, each entry with its picture and the day that is today marked. The day is the first date on the child\'s row line (its `records` title or subtitle); within a day entries follow a datetime there, else the first single select after the date \u2014 its options in the day\'s order |\n| `via` | alias | no | The child\'s link to this record, where it has more than one |\n| `title` | text | no | The block\'s heading; absent, the child entity\'s or the field\'s own label |\n| `columns` | list of alias | no | The child\'s facts read on an entry\'s line after its title \u2014 never what its line already draws (subtitle, image, status, figure) |\n| `create` | list of alias (at least one) \\| `false` | no | The child\'s fields asked when a row is added here; absent, its title, its picture, the subtitle and figure no default fills, the block\'s columns, its natural key and every required field \u2014 each a person writes; false, rows are not added here |\n| `start` | alias | no | The record\'s date that is the first day: each day\'s heading counts from it (Day 1, Day 2); absent, the weekday and the date alone |\n| `when` | map of alias \u2192 (list of alias (at least one) \\| boolean) | no | Shown only while each named select of the record holds one of these options, and each named yes/no (stored, or a formula) is this value |\n\n#### Files block\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `files` | list of alias (at least one) | yes | Files fields of this record, read as pictures and documents |\n| `title` | text | no | The block\'s heading; absent, the child entity\'s or the field\'s own label |\n| `when` | map of alias \u2192 (list of alias (at least one) \\| boolean) | no | Shown only while each named select of the record holds one of these options, and each named yes/no (stored, or a formula) is this value |\n\n#### Text block\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `text` | alias | yes | A long text field read as prose |\n| `title` | text | no | The block\'s heading; absent, the child entity\'s or the field\'s own label |\n| `when` | map of alias \u2192 (list of alias (at least one) \\| boolean) | no | Shown only while each named select of the record holds one of these options, and each named yes/no (stored, or a formula) is this value |\n\n#### Metric block\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `metric` | alias | yes | The entity whose rows are counted, or summed, into one number |\n| `value` | alias | no | A number field summed over the rows; absent, the rows are counted |\n| `where` | map of alias \u2192 (list of alias (at least one) \\| boolean \\| `"me"`) | no | Only the rows whose select holds one of these options, whose yes/no (stored, or a formula) is this value \u2014 each its own, or looked up through one-row links \u2014, or whose member field holds the reader (`"me"`) \u2014 every entry ANDed |\n| `tab` | alias | no | On a register\'s band, the tab whose rows it reads, whichever tab is open; absent, the open tab\'s |\n| `over` | alias | no | The date that places a row in time \u2014 its own (stored, or a formula), or its parent\'s through a lookup over a one-link: a dashboard\'s period windows the rows by it (a reading without one reads the rows as they stand now), and a metric reads its recent periods beside the number |\n| `target` | number | no | The number it is read against, where no `limits` bounds its value (a bounded value is read against the sum of its bound) |\n| `better` | `"up"` \\| `"down"` | no | Which way its change over the recent periods `over` reads is good; absent, a sum\'s rise and a count kept by `where` falling |\n| `label` | text | yes | What the reading is, in the reader\'s words |\n| `via` | alias | no | The child\'s link to this record, where it has more than one |\n| `when` | map of alias \u2192 (list of alias (at least one) \\| boolean) | no | Shown only while each named select of the record holds one of these options, and each named yes/no (stored, or a formula) is this value |\n\n#### Breakdown block\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `breakdown` | alias | yes | The entity whose rows are split into parts |\n| `by` | alias | yes | The single select, one link, member or yes/no (stored, a formula, or a lookup of one through one-row links) each row is counted under |\n| `value` | alias | no | A number field summed over the rows; absent, the rows are counted |\n| `where` | map of alias \u2192 (list of alias (at least one) \\| boolean \\| `"me"`) | no | Only the rows whose select holds one of these options, whose yes/no (stored, or a formula) is this value \u2014 each its own, or looked up through one-row links \u2014, or whose member field holds the reader (`"me"`) \u2014 every entry ANDed |\n| `tab` | alias | no | On a register\'s band, the tab whose rows it reads, whichever tab is open; absent, the open tab\'s |\n| `over` | alias | no | The date that places a row in time \u2014 its own (stored, or a formula), or its parent\'s through a lookup over a one-link: a dashboard\'s period windows the rows by it (a reading without one reads the rows as they stand now), and a metric reads its recent periods beside the number |\n| `label` | text | no | What the reading is, in the reader\'s words; absent, the `by` field\'s label |\n| `via` | alias | no | The child\'s link to this record, where it has more than one |\n| `when` | map of alias \u2192 (list of alias (at least one) \\| boolean) | no | Shown only while each named select of the record holds one of these options, and each named yes/no (stored, or a formula) is this value |\n\n#### Trend block\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `trend` | alias | yes | The entity whose rows are counted, or summed, per period |\n| `over` | alias | yes | The date each row falls on \u2014 its own (stored, or a formula), or its parent\'s through a lookup over a one-link |\n| `ahead` | `true` | no | Counts forward: the current period first, then the ones after it, over the rows dated today or later (arrivals by their ETA); absent, the periods up to today |\n| `value` | alias | no | A number field summed over the rows; absent, the rows are counted |\n| `where` | map of alias \u2192 (list of alias (at least one) \\| boolean \\| `"me"`) | no | Only the rows whose select holds one of these options, whose yes/no (stored, or a formula) is this value \u2014 each its own, or looked up through one-row links \u2014, or whose member field holds the reader (`"me"`) \u2014 every entry ANDed |\n| `tab` | alias | no | On a register\'s band, the tab whose rows it reads, whichever tab is open; absent, the open tab\'s |\n| `label` | text | no | What the reading is, in the reader\'s words; absent, the value\'s label, else the entity\'s |\n| `via` | alias | no | The child\'s link to this record, where it has more than one |\n| `when` | map of alias \u2192 (list of alias (at least one) \\| boolean) | no | Shown only while each named select of the record holds one of these options, and each named yes/no (stored, or a formula) is this value |\n\n#### Pivot block\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `pivot` | alias | yes | The entity whose rows are counted, or summed, in a grid |\n| `rows` | alias | yes | The single select, one link, member or yes/no (stored, a formula, or a lookup of one through one-row links) down the side |\n| `columns` | alias | yes | The single select, one link, member or yes/no (stored, a formula, or a lookup of one through one-row links) across the top |\n| `value` | alias | no | A number field summed over the rows; absent, the rows are counted |\n| `where` | map of alias \u2192 (list of alias (at least one) \\| boolean \\| `"me"`) | no | Only the rows whose select holds one of these options, whose yes/no (stored, or a formula) is this value \u2014 each its own, or looked up through one-row links \u2014, or whose member field holds the reader (`"me"`) \u2014 every entry ANDed |\n| `tab` | alias | no | On a register\'s band, the tab whose rows it reads, whichever tab is open; absent, the open tab\'s |\n| `over` | alias | no | The date that places a row in time \u2014 its own (stored, or a formula), or its parent\'s through a lookup over a one-link: a dashboard\'s period windows the rows by it (a reading without one reads the rows as they stand now), and a metric reads its recent periods beside the number |\n| `label` | text | no | What the reading is, in the reader\'s words; absent, the two fields\' labels |\n| `via` | alias | no | The child\'s link to this record, where it has more than one |\n| `when` | map of alias \u2192 (list of alias (at least one) \\| boolean) | no | Shown only while each named select of the record holds one of these options, and each named yes/no (stored, or a formula) is this value |\n\n<!-- generated:end apps-blocks -->\n\n<!-- generated:start rules-block -->\n\n#### Rules the check enforces\n\n| Rule | |\n|---|---|\n| `block.child-link` | A block reads a child linked to the record; `via` names the link where the child links more than once. |\n| `block.column-drawn` | A child block\'s column never names what the child\'s row draws \u2014 its title, subtitle, image, status, figure, or the bound its figure\'s meter reads against \u2014 nor its link back to the record, and names a field once; an `expect` block\'s figure is its filled-in-place column. |\n| `block.title-back` | Where a child\'s title is its link back to the record a block lists it under, its first other subtitle names it there \u2014 a text, a code, a select, a link or a member, never a number or a date. |\n| `block.where` | A rows block\'s `where` names single selects the child requires, by options they hold; one held at a single option is never also a column. |\n| `block.under` | `under` names the child\'s one-row link to a document entity that pairs a many-link back and is read under this record by its one link; the lines\' columns never read the document (its heading does) nor hold long text. |\n| `block.under-once` | Documents drawn over their lines (`under`) are never listed again as a block of their own. |\n| `block.edits` | A rows block\'s `edits` name at most two of the child\'s columns or its figure that the row\'s own save writes \u2014 a number, a date, a few words or one option \u2014 never on an `expect` block, whose lines are filled in place already. |\n| `block.remove` | A rows block\'s `remove: false` hides the Delete its rows would offer, so it stands only where the app writes those rows. |\n| `block.narrow` | A rows block\'s `filters`, `search`, `group` and `sort` follow the register\'s rules over the child\'s rows, its status among them but on a task list (its chips); never on the link back to the record or a select its `where` holds at one option, never on an `expect` block, and an `under` block is grouped by its documents. |\n| `block.expect` | `expect` names the child\'s title \u2014 a single select or a one-row link a person writes, never the link back \u2014 on a child added in the block; the block\'s `columns` name at least one field a person fills in place, and its `create` takes the file where the child is pictured by one. |\n| `block.expect-fills` | An expected line is made from its key and its first filled column, so every other field the child requires has a default, a start or a `default_from`, or is read first. |\n| `block.expect-of` | A block\'s `of` needs `expect` on a select, and names the record\'s multi-select whose every option is one of the title\'s. |\n| `block.agenda` | An agenda\'s child names a date on its row line (`records` title or subtitle); `start` is a date of the record. |\n| `block.timeline` | A timeline\'s child holds a date to place its entries by. |\n| `block.field-kind` | A files block names files fields of the record, and a text block a text field. |\n| `block.read-scope` | A child read under a record whose rows a `read_scope` narrows states its own \u2014 a row rule is not inherited, save the status history\'s, which takes its record\'s. |\n| `block.agenda-note` | *Noted, never refused:* Rows each on a day and within it (at an hour, in a part of the day) listed as a table: an `agenda` draws them by day. |\n| `block.under-note` | *Noted, never refused:* Rows holding a paper that cover another block\'s lines, listed apart with no add of their own stated: `under` draws them over the lines they cover. |\n\n<!-- generated:end rules-block -->\n\nA `rows`, `agenda` or `timeline` block reads a child entity through its link to the\nrecord \u2014 `via` names it where the child links more than once. A rows block adds rows\n(asking the child\'s title, its picture, the block\'s columns and its required fields)\nand opens each in a drawer. An `agenda` draws planned entries by day, soonest first:\nthe first date on the child\'s row line is the day, a datetime there or the first single\nselect after the date orders a day, and `start` \u2014 the record\'s date \u2014 is day 1. `under`\nnames the child\'s one-link to a document covering its lines (an invoice over its fees,\nits many-link paired back, linking to the record): each document heads the lines it\ncovers, the uncovered lines first \u2014 the record never lists the documents again as a\nblock of their own. A filled line no document covers says so on its own row, where one\npress makes a document over that line alone, its figure starting at the line\'s amount;\nwhere two or more wait, the block\'s heading makes one over those ticked in its dialog.\nA timeline reads its child\'s first date as the moment and its member field as who, with\na composer where the app writes it. An agenda\'s or a timeline\'s entry with no day yet is\na plan, listed first and dated in place. `create` names the child\'s fields an add here asks \u2014\nthe link to this record is filled, never asked \u2014 and `false` adds none. `expect`\nnames the child\'s title where the record holds one row per value of it \u2014 a select\'s\noptions, or the rows a link to a catalog may point at (`options_where`): every value\nis a line, one not yet filled a quiet line whose first column makes its row \u2014 or, where\nthe child is pictured by a file a person brings (its `records` `image`), a placeholder\nnaming the paper whose upload makes the row with its file \u2014 the add\noffers only the values not yet held, and every write path refuses a second row of one\nvalue under one record. A field that starts from the catalog row (`default_from`)\nshows that value as its hint, taken with one press. `where` keeps only the child rows\nwhose single select holds one of its options \u2014 read so on the server, a row added\nthere holding them, its lines expected once per value among them (one leg\'s fees\nbeside the other\'s), and its foot totalled by the record\'s sum narrowed alike. A `metric`, `breakdown`, `trend`\nor `pivot` block is a reading (\xA7 Readings) of a child entity\'s rows under this record,\nread only \u2014 `via` names the link where the child has more than one.\n\n### Tasks\n\nAn entity whose `records` states `"task": true` \u2014 its status a stored select with `closed` \u2014 is\nwork someone finishes. Every table of its rows draws it alike \u2014 a rows block of the record it\nbelongs to, its own register, the rows filed under one of its rows (its steps) \u2014 each row led by a\nring. Ticking the ring moves the row to a closed option by the one write the app already has for\nthat move \u2014 the act of that entity setting a closed option (its first preferred), offered on an\nopen row and asking nothing, else its own save where people write the status \u2014 stamping what\nthat act stamps and appending the status history. Unticking runs the act moving a closed row\nback (its `when` holding a closed option), else the save back to the status\'s default. A move no\nwrite reaches leaves the ring disabled, never hidden, and the check notes it. The status is moved\nin place on the row by the same moves; every other field is edited where the row opens. A\nfiles column reads on a task\'s line as how many files it holds. An act `of` the child with\n`on: "rows"` completes several at once: the list\'s heading offers to select rows, and the act\nruns on those picked. A block with `expect` or `under` draws its own lines, never rings.\n\n```json\n{\n "entities": [\n {\n "alias": "project",\n "label": "Projects",\n "singular": "Project",\n "fields": [\n { "alias": "name", "label": "Name", "type": "text", "required": true },\n { "alias": "lead", "label": "Lead", "type": "select_member" }\n ]\n },\n {\n "alias": "task",\n "label": "Tasks",\n "singular": "Task",\n "fields": [\n { "alias": "title", "label": "Title", "type": "text", "required": true },\n {\n "alias": "project",\n "label": "Project",\n "type": "select_record_link",\n "target_entity": "project",\n "cardinality": "one",\n "required": true\n },\n { "alias": "assignee", "label": "Assignee", "type": "select_member" },\n {\n "alias": "status",\n "label": "Status",\n "type": "select",\n "required": true,\n "options": [\n { "alias": "todo", "label": "To do", "color": "slate" },\n { "alias": "done", "label": "Done", "color": "green" }\n ],\n "default": ["todo"]\n },\n { "alias": "due", "label": "Due", "type": "date", "format": "date" },\n { "alias": "done_on", "label": "Done on", "type": "date", "format": "date" }\n ]\n }\n ],\n "records": {\n "project": { "title": "name" },\n "task": {\n "title": "title",\n "subtitle": ["assignee"],\n "status": { "field": "status", "closed": ["done"] },\n "due": ["due"],\n "task": true,\n "starts": { "assignee": "me" }\n }\n },\n "apps": [\n {\n "alias": "projects",\n "name": "Projects",\n "entity": "project",\n "record": {\n "sections": [{ "title": "Work", "fields": ["lead"], "blocks": [{ "rows": "task", "columns": ["due"] }] }]\n },\n "acts": [\n { "alias": "complete", "label": "Complete", "of": "task", "when": { "status": ["todo"] }, "set": { "status": "done", "done_on": "now" } },\n { "alias": "reopen", "label": "Reopen", "of": "task", "when": { "status": ["done"] }, "set": { "status": "todo", "done_on": null } },\n { "alias": "complete_all", "label": "Complete", "of": "task", "on": "rows", "when": { "status": ["todo"] }, "set": { "status": "done", "done_on": "now" } }\n ]\n },\n {\n "alias": "my_tasks",\n "name": "My tasks",\n "entity": "task",\n "register": {\n "opens": { "assignee": "me" },\n "readings": [{ "metric": "task", "where": { "status": ["todo"] }, "label": "Open" }]\n },\n "record": { "door": "drawer" },\n "acts": [\n { "alias": "finish", "label": "Finish", "when": { "status": ["todo"] }, "set": { "status": "done", "done_on": "now" } },\n { "alias": "undo", "label": "Undo", "when": { "status": ["done"] }, "set": { "status": "todo", "done_on": null } }\n ]\n }\n ]\n}\n```\n\n### Acts\n\n```jsonc\n"acts": [{\n "alias": "gate_out", "label": "Gate out",\n "when": { "stage": ["in_yard"] }, "requires": ["release", "seal"],\n "asks": ["seal", { "input": "note", "label": "Note", "type": "long_text" }],\n "set": { "stage": "gone", "left_on": "now", "left_by": "me", "remark": "input:note" },\n "confirm": true\n}]\n```\n\n<!-- generated:start apps-acts -->\n\n#### Act\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `alias` | alias | yes | The act\'s name within its app \u2014 its workflow is `act_<alias>` |\n| `label` | text | yes | The verb, as the reader says it |\n| `on` | `"record"` \\| `"rows"` \\| `"view"` | no | `rows` runs on the rows ticked in the register \u2014 with `of`, on the child rows ticked together in a rows block of the record; `view` on every row the register shows (offered while the view is narrowed to its `when`; it states no `requires`); absent, on one record (its page and its row\'s menu) |\n| `of` | alias | no | A rows block\'s child entity the act works on: offered on each child row \u2014 or, `on: "rows"`, on the rows ticked together in the block \u2014 its conditions and its write that row\'s |\n| `when` | map of alias \u2192 (list of alias (at least one) \\| boolean) | no | The act works only while each named select holds one of these options, and each named yes/no (stored, or a formula) is this value |\n| `requires` | list of alias (at least one) | no | Fields that must be filled first, each drawn where the act stands (its section\'s fields, a block\'s, or the act\'s asks \u2014 never the header alone); a blocked act names each one missing |\n| `recommends` | list of alias (at least one) | no | Fields the act runs without and reads when filled: while one is empty, the act says what it will leave blank |\n| `asks` | list of (alias \\| [Act input](#act-input)) (at least one) | no | What the reader states when pressing it \u2014 fields of the record (written by the act) or inputs |\n| `set` | map of alias \u2192 (text \\| number \\| boolean \\| `null` \\| [Set formula](#set-formula)) | no | What the act writes: an option alias, a value, `"now"`, `"me"` or `"input:<name>"` \u2014 each the act\'s outcome, read-only elsewhere; or `{ "formula": \u2026 }`, computed over each acted row as it stands when the act runs and written then (a sell price at cost and markup): a starting value, which a person may change after and the act never recomputes |\n| `workflow` | alias | no | An authored workflow (src/workflows/<alias>.ts) the act runs after its checks, for what `set` cannot say |\n| `template` | alias | no | A document template the act makes a file from \u2014 an html one made a PDF, an excel one a workbook; over several rows, one file of them all, the rows listed under the entity\'s alias |\n| `templates` | list of [Act template](#act-template) (at least 2) | no | Several papers the act makes into `into` (a case\'s forms), instead of one `template`: drawn where the act stands as a list of its papers \u2014 each made one as its file, each not yet made as a placeholder naming it \u2014 the reader ticks which to make, and one press makes those, each replacing the paper its template made before |\n| `into` | alias | no | The files field of the one record the made document is kept in \u2014 remade, it replaces the paper its template made before, never a file a person put there |\n| `intake` | alias | no | A child the record lists in a rows block whose rows are its papers: the press takes papers, an agent reads them, and each is filed as a row of this child \u2014 its kind the child\'s title, its file in the child\'s files field |\n| `record` | [Act record](#act-record) | no | The press records a call, visit or meeting through the host \u2014 Stop ends it \u2014 and files it as a new row of a child the record lists: its audio, its screen where captured, its transcript |\n| `fills` | list of text (at least one) | no | With `intake`, what the papers read may fill: a field of the record, `<link>.<field>` on the row a required one-row link of the record names, or a child whose rows the papers add \u2014 shown before and after, and saved where it differs. With `record`, fields of the new row an agent fills from the transcript, saved as they come |\n| `confirm` | `true` | no | The press asks first; the question lists every active warning |\n| `danger` | `true` | no | The act destroys or cannot be undone |\n\n#### Act input\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `input` | alias | yes | A value the act asks for that is not a field of the record |\n| `label` | text | yes | What the reader is asked, in their words |\n| `type` | `"text"` \\| `"long_text"` \\| `"number"` \\| `"date"` \\| `"select"` \\| `"link"` \\| `"member"` | yes | What the reader states |\n| `options` | list of [Select option](#select-option) (at least one) | no | The choices a `select` input offers, one picked \u2014 written into a select holding the same option aliases |\n| `entity` | alias | no | The entity a `link` input picks one row of |\n| `required` | `true` | no | The act is refused without it |\n| `default` | alias | no | A field of the row whose value the input starts from, still changed at will |\n\n#### Act template\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `template` | alias | yes | A document template the act makes a paper of, kept in `into` \u2014 an html one made a PDF, an excel one a workbook |\n| `when` | map of alias \u2192 (list of alias (at least one) \\| boolean) | no | The paper starts ticked while each named select of the record holds one of these options and each named yes/no (stored, or a formula \u2014 how a multi-select is tested: `includes({needs}, {needs:x})`) is this value \u2014 a suggestion the reader changes, never a refusal; absent, it starts ticked |\n\n#### Set formula\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `formula` | text | yes | A formula over the acted row\'s fields, `{alias}` each, read as the entity\'s own formulas read them |\n\n#### Act record\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `into` | alias | yes | A child the record lists in a timeline or rows block, linking back to it by a single link: each recording is filed as a new row of it |\n| `audio` | alias | yes | The child\'s files field the recording\'s audio is kept in |\n| `transcript` | alias | yes | The child\'s text field the transcript is kept in, a speaker\'s turn per line |\n| `video` | alias | no | The child\'s files field the screen is kept in, where the recording captured it |\n| `at` | alias | no | The child\'s date field stamped with the moment the recording started |\n| `by` | alias | no | The child\'s member field set to the person who recorded |\n\n<!-- generated:end apps-acts -->\n\n<!-- generated:start rules-act -->\n\n#### Rules the check enforces\n\n| Rule | |\n|---|---|\n| `act.set-not-asked` | A field an act `set`s \u2014 an option, a value, `now`, `me`, an input \u2014 is the act\'s outcome: read-only everywhere else and never asked by an add (`create`); a required one takes a `default`. A field an act computes by a `formula` is a starting value, which a person may change after. |\n| `act.set-formula` | `set: { <field>: { formula } }` reads fields of the acted row and computes what the field holds \u2014 a number, a date, text or a yes/no, typed as the entity\'s own formulas are \u2014 never the status or a milestone; a bound a write rule holds the field to is checked on the computed value as the act runs. |\n| `act.of` | An act\'s `of` names a child the record lists in a rows block; it works on the one child row it is pressed on \u2014 or, `on: "rows"`, on the rows ticked together where the record lists that child as tasks \u2014 never on the rows in view. |\n| `act.writes-line-key` | An act never writes the link or the key naming an expected line under its record \u2014 a line\'s key is stated by filling it. |\n| `act.makes-or-runs` | An act makes a document (`template`, `templates`) or runs an authored `workflow`, never both \u2014 the workflow makes what it needs. |\n| `act.view` | An act over every row in view (`on: "view"`) writes (`set`), makes a document or runs a workflow, and states no `requires`. |\n| `act.requires-drawn` | Every field an act `requires` is drawn where the act stands \u2014 its section\'s fields, a block\'s, its asks, or an unstaged section\'s where its own is unstaged \u2014 never the header alone. |\n| `act.requires-written` | A field an act requires that only an act writes is written by an act offered together with it, or asked by the act itself. |\n| `act.recommends` | A field an act `recommends` is not also in its `requires`. |\n| `act.set` | `set` writes each field a value it holds \u2014 an option alias, a number, a yes/no, a text, `"now"` on a date, `"me"` on a member, `"input:<name>"` of an input the act asks of the same kind \u2014 or clears an optional one with `null`. |\n| `act.set-milestone` | An act stamps a milestone, never clears one \u2014 unticking clears it and every later one. |\n| `act.into` | `into` is a files field of the one record the act is pressed on, beside its `template` or `templates`. |\n| `act.templates` | `templates` names each template once, kept in `into`, on one record pressed alone \u2014 never beside `template`, `asks`, `of` or an `on` over several rows. |\n| `act.history-copy` | What a status move asks is copied onto the history\'s field of the same name, which holds the same kind of value. |\n| `act.intake` | `intake` names a child the record lists in a rows block that expects its title \u2014 a single select, the paper\'s kind \u2014 and is narrowed by no `where`, holding exactly one files field and needing nothing an added line does not fill; the act runs on one record, states `fills`, and states nothing but its `label`, `when` and `requires` beside them. |\n| `act.record` | `record` names a child the record draws in a timeline block or a rows block with no `expect`, `under` or `where`, linking back by one single link, whose row nothing requires beyond what the recording writes: `audio` and `video` its files fields, `transcript` a text field, `at` a date, `by` a member field, each a field of its own; the act runs on one record and states nothing but its `label`, `when`, `requires` and `fills` beside it. |\n| `act.fills` | `fills` names each once, only beside `intake` or `record`. Beside `record`, each is a field of the recording\'s row that the recording itself does not write. Beside `intake`: a field of the record, `<link>.<field>` on the row a required one-row link of the record names, or a child the record adds rows of in a rows block with no `expect`, `under` or `where` \u2014 its rows filled in the fields that block adds them with, among them every field an added row cannot be made without. Each field filled is one a person writes holding text, a number, a date, a yes/no or options, never a status, a milestone or a field an act writes. |\n| `act.fills-drawn` | *Noted, never refused:* A field `fills` writes that the record draws nowhere: the review shows the change, the record does not. |\n| `act.fills-new` | *Noted, never refused:* A child `fills` adds rows of, whose natural key the papers cannot state: every row read is added, so the same papers read twice list them twice. |\n| `act.input` | An input has a name of its own \u2014 never another ask\'s, a field\'s, or the record input its workflow takes \u2014 `options` exactly when it is a select, `entity` exactly when it is a link, and a `default` field holding what it states. |\n\n<!-- generated:end rules-act -->\n\nAn act whose `when` does not hold is not offered \u2014 each select named holding one of its\noptions, each yes/no named (stored, or a formula: a period that has ended) its `true` or\n`false`; every one that holds stands at the\nfoot of the section naming it in `acts` \u2014 a correction in that section heading\'s \u22EF, one\nof a child row on its row (a correction in the row\'s \u22EF). An act\'s `requires` are fields\nits section draws \u2014 its `fields`, a block\'s, the act\'s `asks`, or those of another section\nwith no `at` where its own has none; one shown only in the header or a staged section\nelsewhere is refused. One whose `requires` are\nempty is offered disabled, naming each; one whose `recommends` are empty names what\nit will leave blank and still presses; one a standing check blocks is offered\ndisabled in the check\'s words. An input\'s `default` is the row\'s field it starts from. The generated `act_<alias>` workflow re-checks all three on the\nserver, writes `set` and what was asked, and appends the status history where the\nact moves the status \u2014 each thing it asked copied onto the history row where the\nhistory has a field of the same name. `template` makes a document from the row and\nkeeps it in `into`; over several rows it makes one document of them all, the rows\nlisted under the entity\'s alias, and hands it back. `templates` instead names a set of\npapers kept in `into`, listed where the act stands \u2014 each made one as its file, each\nnot made yet as a placeholder \u2014 those whose `when` holds ticked to start; the press\nmakes the ticked ones (sent in `templates`), each replacing the file its template made\nbefore (the file whose `document_template_id` is that template \u2014 never a file a person\nput there), and the set downloads as one archive (`archive_<alias>`). A field an act writes is read-only everywhere else, never asked by an add \u2014 a required one takes a `default`. A `select` input offers its own options and is written into\na select holding the same option aliases; a `link` input picks one row of its `entity`\nand a `member` input one person, each written into a field of its kind. A field an act\nboth `requires` and `asks` never disables the press \u2014 the act asks it, required.\n\n`on: "view"` runs on every row the register shows \u2014 its search, status and filters \u2014\neach row checked before any is written. `of` names a rows block\'s child entity: the\nact is offered on each child row (its menu and its drawer), its `when`, `requires`,\n`set` and checks are the child\'s, it runs on that row, and a check of the record that\nlocks the child rows locks it too. With `on: "rows"` it runs on the child rows ticked\ntogether in the record\'s task list (\xA7 Tasks) \u2014 every row checked before any is written;\nit is refused where the record lists no tasks of that child.\n\n`intake` reads papers: the press takes files, an agent (`act_<alias>` among the app\'s\nagents) reads them, and each is filed as the record\'s line of its kind in the named\nchild \u2014 that child\'s title \u2014 or a new line, its file in the child\'s one files field.\nWhat they state of each field `fills` names is shown beside what the row holds; the save\nwrites only what differs \u2014 the record\'s fields, `<link>.<field>` on the row a required\none-row link names, a child\'s rows (one found by the child\'s `natural_key` changed, else\nadded) \u2014 each refused as its own save is, and nothing written while one refuses.\n\n```jsonc\n{ "alias": "read_papers", "label": "Read papers", "intake": "customer_paper", "fills": ["phone", "contact.email", "branch"] }\n```\n\n### Checks\n\n```jsonc\n"checks": [{ "field": "release_warning" }, { "field": "unpaid", "blocks": ["gate_out"] }, { "field": "locked", "blocks": ["edit"] }]\n```\n\n<!-- generated:start apps-checks -->\n\n#### Check\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `field` | alias | yes | A yes/no or text formula of the record (or of the child `of` names): true or non-empty means the check stands; its label is its short name on a row\'s line, and a text formula\'s words are the sentence the record and a tooltip say |\n| `of` | alias | no | A rows block\'s child entity this check is a formula of: standing on a child row, it refuses what it blocks of that row |\n| `blocks` | `"all"` \\| list of (alias \\| `"edit"` \\| `"delete"`) (at least one) | no | Acts, "edit" (the saves and the Delete), "delete" (the Delete alone) or "all" that the standing check refuses; absent, it only warns |\n| `resolve` | alias | no | An act of this app, on the rows the check stands on, that clears it: the check\'s line links to the act where it stands, and the act keeps its own conditions |\n\n<!-- generated:end apps-checks -->\n\n<!-- generated:start rules-check -->\n\n#### Rules the check enforces\n\n| Rule | |\n|---|---|\n| `check.field` | A check\'s `field` is a yes/no or text formula of the record, or of the child `of` names. |\n| `check.of` | A check\'s `of` names a child the record lists in a rows block. |\n| `check.not-placed` | A check\'s field is never also placed on the record \u2014 the line under the header says it. |\n| `check.blocks` | A check of the record blocks its acts, `"edit"`, `"delete"` or `"all"`; a check of a child blocks that row\'s saves, its Delete, the acts `of` that child, or all \u2014 never the record\'s acts. |\n| `check.resolve` | `resolve` names an act of this app on the rows the check stands on, pressed on one row, not refused by the check, and drawn as a button \u2014 never a correction. |\n| `check.restates-meter` | A warning reading a gated meter\'s figure against its bound or pass mark is refused \u2014 the meter draws it. |\n\n<!-- generated:end rules-check -->\n\nA check is a yes/no or text formula of the record: it stands while true or\nnon-empty, and a text formula\'s words are what the reader sees. Without `blocks`\nit warns; with them it refuses the acts named, `"edit"` the record\'s own saves (and\nits Delete), `"delete"` its Delete alone, or `"all"` \u2014 on the server, in every write it\nnames. A check `of` a rows block\'s child stands on each child row: it refuses that\nrow\'s saves (`edit`), its Delete (`delete`), the acts `of` that child it names, or\n`all` of them \u2014 never the record\'s acts. A warning reading the figure of a meter that\n`gates` marks against its bound or its pass mark is refused: the meter already draws it.\n\nThe check field\'s `label` is its short name \u2014 "Over budget", "Release expired": a row\'s\nline says it in the check\'s tone (the most severe of several, and how many more), while\nits words whole are the row\'s tooltip and stand under the record\'s header. So label it\nas the reader names the problem, in a few words, and let a text formula say the\nsentence. The compiler records the fields a formula reads: a row drawing each of them\nas a control says nothing of the check on its line (the empty control says it), and a\ncheck with no `resolve` links to the first of them the record draws.\n\n## What a run remembers\n\nThe WORKSPACE remembers what each entity, field, select option, role and\ntemplate alias became here, plus which record each first row landed on and the\nfile each document path was uploaded as. The model file itself holds no live id\n\u2014 it is the portable half, and the same file applies to a demo workspace and to\na customer\'s \u2014 so the join lives where the things it names live, and one model\napplied to two workspaces holds two bindings that know nothing of each other.\n\nIt is the workspace\'s and not the file\'s because a model file is never\ncommitted: memory kept beside it is one author\'s disk, absent for a teammate, on\na second machine or after a delete \u2014 and every one of those goes quietly back to\nbinding by label, which is what grows the second table.\n\nEvery later apply binds through it: it takes the remembered id first and falls\nback to a label only for an alias nothing has bound \u2014 a field you have just\nadded, or a workspace nothing has applied this model to. That is what makes a\nrelabel on EITHER side a rename rather than one thing the workspace lacks and\none the model lacks: `apply` binds the thing it has always meant and reports the\nmove \u2014 which side is right is yours to decide, not a reason to refuse the run.\n\nA bound target the workspace no longer holds IS a refusal, by name: re-binding\nto whatever carries that label today is how the model comes to point at\nsomebody else\'s table. `lotics run restore_table` puts a deleted table back.\n\n`lotics model pull` writes a workspace that already works as a model file \u2014 the\nstarting point for another business\'s model, never a source of truth: it carries\none business\'s words and stops describing that workspace the moment either\nchanges.\n\n## A complete model\n\n```json\n{\n "entities": [\n {\n "alias": "customer",\n "label": "Customers",\n "singular": "Customer",\n "fields": [\n { "alias": "name", "label": "Name", "type": "text", "required": true },\n { "alias": "logo", "label": "Logo", "type": "files" },\n {\n "alias": "tier",\n "label": "Tier",\n "type": "select",\n "options": [\n { "alias": "standard", "label": "Standard", "color": "slate" },\n { "alias": "gold", "label": "Gold", "color": "amber" }\n ],\n "default": ["standard"]\n },\n {\n "alias": "orders",\n "label": "Orders",\n "type": "select_record_link",\n "target_entity": "order",\n "cardinality": "many",\n "sync_both_ways": true,\n "paired_field_alias": "customer",\n "display_field_aliases": ["code"]\n },\n {\n "alias": "total_ordered",\n "label": "Total ordered",\n "type": "rollup",\n "source_field_alias": "orders",\n "aggregate_option": { "operation": "sum", "field_key": "amount" }\n }\n ],\n "views": [\n {\n "alias": "gold",\n "label": "Gold customers",\n "filters": {\n "node_type": "condition",\n "type": "select",\n "field_key": "tier",\n "operator": "has_any_of",\n "value": ["gold"]\n },\n "sort": [{ "field_key": "name", "order": "asc" }]\n }\n ]\n },\n {\n "alias": "order",\n "label": "Orders",\n "singular": "Order",\n "fields": [\n { "alias": "code", "label": "Order no.", "type": "text", "unique": true },\n { "alias": "placed_on", "label": "Placed on", "type": "date", "format": "date" },\n {\n "alias": "amount",\n "label": "Amount",\n "type": "number",\n "format": "currency",\n "currency": "VND"\n },\n {\n "alias": "total",\n "label": "Total with VAT",\n "type": "formula",\n "formula": { "expression": "{amount} * 1.1", "format": "currency", "currency": "VND" }\n },\n {\n "alias": "customer",\n "label": "Customer",\n "type": "select_record_link",\n "target_entity": "customer",\n "cardinality": "one",\n "sync_both_ways": true,\n "paired_field_alias": "orders",\n "display_field_aliases": ["name"]\n },\n { "alias": "note", "label": "Note", "type": "text" }\n ]\n }\n ],\n "roles": [{ "alias": "sales", "label": "Sales" }],\n "records": {\n "customer": { "title": "name", "image": "logo", "party": "organization", "figure": "total_ordered" },\n "order": { "title": "customer", "subtitle": ["code", "placed_on"], "figure": "amount", "starts": { "placed_on": "today" } }\n },\n "apps": [\n {\n "alias": "customers",\n "name": "Customers",\n "entity": "customer",\n "register": {\n "columns": ["tier"],\n "readings": [{ "breakdown": "customer", "by": "tier", "value": "total_ordered" }]\n },\n "record": {\n "sections": [\n { "title": "Account", "fields": ["tier"] },\n { "title": "Orders", "blocks": [{ "rows": "order", "columns": ["total"], "create": ["code", "amount"] }] }\n ]\n }\n },\n {\n "alias": "orders",\n "name": "Orders",\n "entity": "order",\n "register": {\n "filters": ["customer"],\n "create": ["code", "customer", "placed_on"],\n "readings": [{ "trend": "order", "over": "placed_on", "value": "amount" }]\n },\n "record": { "door": "drawer" }\n }\n ],\n "rows": {\n "customer": [\n { "ref": "acme", "fields": { "name": "Acme Trading", "tier": "gold" } },\n { "ref": "bluebird", "fields": { "name": "Bluebird Foods", "tier": "standard" } }\n ],\n "order": [\n {\n "ref": "so_1001",\n "fields": {\n "code": "SO-1001",\n "placed_on": "@month-start+2",\n "amount": 4200000,\n "customer": "customer:acme"\n }\n },\n {\n "ref": "so_1002",\n "fields": {\n "code": "SO-1002",\n "placed_on": "@today-3",\n "amount": 1150000,\n "customer": "customer:bluebird"\n }\n }\n ]\n }\n}\n```\n\nEach app of this file, as its reader will see it:\n\n```\nApp customers \u2014 "Customers" over Customers (customer)\n Register \u2014 table (default), newest first (default)\n row [Logo] \xB7 Name \xB7 figure Total ordered\n columns Tier\n filters \u2014\n readings "Tier" \u2014 Total ordered summed per Tier, over the rows in view (default)\n add Name (default)\n Record \u2014 a drawer (default)\n header image Logo \xB7 title Name \xB7 figure Total ordered\n checks \u2014\n header \u22EF Delete\n sections 1. "Account"\n fields Tier\n 2. "Orders"\n block Orders (default) \u2014 rows of Orders through Customer; columns Total with VAT; add asks Order no., Amount\n thread \u2014\n history \u2014\n acts \u2014\n Reads Customers, Orders\n\nApp orders \u2014 "Orders" over Orders (order)\n Register \u2014 table (default), newest first (default)\n row Customer \xB7 under it Order no., Placed on \xB7 figure Amount\n columns \u2014\n filters Customer\n readings "Amount" \u2014 Amount summed per period by Placed on, over the rows in view (default)\n add Order no., Customer, Placed on \u2014 starting Placed on at today\n Record \u2014 a drawer\n header title Customer \xB7 subtitle Order no., Placed on \xB7 figure Amount\n checks \u2014\n header \u22EF Delete\n sections 1. the record\'s details (default)\n fields Note\n thread \u2014\n history \u2014\n acts \u2014\n Reads Orders, Customers\n\n```\n\nEach `(default)` is a value the model left to the system: the door a record opens\nthrough \u2014 a drawer, since neither record has a stage or more than three sections \u2014 the\norder rows open in, what an add asks, and the order\'s one section \u2014 every field a\nperson writes that the header does not already show. The customer\'s orders\nare a block in its "Orders" section, never its own `Orders` link as a field as well:\none fact, one place. An order is titled by its customer and read by its number under\nit; the block names what its add asks, since a subtitle or a figure is never asked by\ndefault. `Total ordered` is a rollup, so it is read and never asked; the\norders app\'s record opens in a drawer, because its rows are worked one after another.\n';
|
|
40284
|
+
var model_reference_default = '# The Lotics workspace model (`model.json`)\n\nOne JSON file describing a workspace: its tables, fields, options, views, roles,\nfirst rows, how a row of each table is recognised, and the apps over them. Each \xA7\nis its own page at `model/<section>` \u2014 `lotics docs`, or the `docs` tool. Complete models of several trades, to read as\nworked examples: `https://lotics.ai/presets/index.json`.\n\n**What a model composes with**\n\n- **Entities and fields** (\xA7 Entity, \xA7 Field) \u2014 the tables, their columns, options and links.\n- **Records** (\xA7 Records) \u2014 how a row of each entity is RECOGNISED: its title, the line\n under it, its picture, its status, the one number it stands for. Stated once per entity\n and read by every surface that draws one of its rows.\n- **Write rules** (\xA7 Write rules) \u2014 what a write finds, copies, bounds and picks among.\n- **Apps** (\xA7 Apps) \u2014 one register over one entity and the record each row opens: which\n fields go where, the acts, and the checks that guard them \u2014 or a dashboard of readings.\n How each thing LOOKS is the runtime\'s, one treatment per concept; no key here changes it.\n A screen the job needs and no key states is a `lotics report` \u2014 the job, what the model\n drew, the word wanted \u2014 never keys bent to approximate it.\n\n**The working order**\n\n1. Name the people and each one\'s JOB \u2014 the work they alone decide or write.\n2. The entities and fields those jobs touch, and a `records` entry for every entity an\n app lists, opens or picks.\n3. One app per job in `apps[]`, each one register over one entity.\n4. `lotics model apply model.json` \u2014 the file is checked first, every problem in one run;\n then the tables, then a new version of every app, live\n (`lotics setup model.json --email you@company.com` where no account exists yet).\n5. Change the file and apply it again; `lotics model pull` writes what the workspace holds.\n Rolling an app back (`lotics run rollback_app`) restores its earlier version \u2014 table\n changes and data writes stay.\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 applied \u2014 apply the rest in a second call.\n- **An existing table is adopted.** `lotics model apply` binds an entity whose `label`\n already names a table in the workspace, and adds the fields, options and\n views it is missing. No stored value is ever changed or deleted: a field it\n adopts takes the model\'s `default`, and the `format` and `unit` it reads in,\n where that only relabels \u2014 a date\'s format and a unit converting its figures are\n left. Applying the same model twice changes nothing the second time.\n- **Renaming a field is `lotics run update_table`**, then the same label in this\n file; deleting is `lotics run delete_table`. Neither goes through the file\n (\xA7 What a run remembers).\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 `model apply` 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- **Rows land only where every bound table is empty.** One table already holding\n records and no rows are written anywhere: sample rows landing among a\n customer\'s real ones cannot be told apart from them.\n- **`lotics model apply` checks all of it before anything is written**, and\n reports every problem in one run rather than the first. Each section of this\n page ends its keys with the rules the check enforces there, one sentence each\n under an id; a finding names its rule\'s id (`[register.filter]`), and\n `model/<rule id>` is that one rule\'s page.\n\n<!-- generated:start rules-model -->\n\n#### Rules the check enforces\n\n| Rule | |\n|---|---|\n| `model.schema` | Every key is one its table lists, holding the type its row gives, and every required key is stated. |\n| `model.retired` | `field_roles`, `table_workflows` and `connections` are no longer part of a model: `records` states how a row is recognised, and an app\'s own writes do what a table automation did. |\n| `model.names-declared` | Every alias a key names is declared: an entity of this model, a field of the entity the key reads, an option of the select it names, an act of the app, a template or an app of this model. |\n| `model.tables` | A model declares at least one table and at most 50; apply the rest as a second model. |\n| `model.rows-cap` | Rows are a sample: at most 200 per entity, 2000 per model and 2000 documents attached \u2014 a real data set belongs in an import. |\n| `model.language` | *Noted, never refused:* A label or description written in the other language than the model\'s majority \u2014 with or without diacritics. |\n| `model.alias-unique` | An alias is unique where it is named: entities, roles and templates in the model, fields and views in their entity, options in their select. |\n| `model.label-unique` | A label is unique where apply finds it by label: entities, roles and templates in the model, fields and views in their entity, options in their select \u2014 and no view takes its entity\'s own label, which names the whole-table grid apply makes. |\n| `model.template-sha` | A template\'s `content_sha256`, where stated, is the 64-character lowercase hex sha256 of its content. |\n| `model.template-kind` | An act\'s paper and a register\'s `export` are made from an html or an excel template, never an email one. |\n| `model.filter` | A filter \u2014 a view\'s, a rollup\'s \u2014 tests fields of the entity it reads, through links as `entity.field` hops each standing on the entity the last lands on, by options its select declares. |\n\n<!-- generated:end rules-model -->\n\n## Top level\n\nEvery table of keys on this page is generated from the schema `model apply`\nparses the file with, so it is the whole of what a key may hold.\n\n<!-- generated:start top-level -->\n\n#### Model file\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `entities` | list of [Entity](#entity) | yes | The tables this model creates, with their fields, options and views |\n| `roles` | list of [Role](#role) | no | Workspace groups to create; members are added to them afterwards |\n| `templates` | list of [Template](#template) | no | Document templates: html and email inline, excel made from an uploaded workbook |\n| `rows` | map of alias \u2192 list of [Row](#row) | no | First records, keyed by entity alias \u2014 written only where every table they land in is empty |\n| `records` | map of alias \u2192 [Record](#record) | no | How a row of each entity is recognised, keyed by entity alias. Every entity an app lists, opens or picks has one |\n| `write_rules` | map of alias \u2192 [Entity write rules](#entity-write-rules) | no | Entity alias \u2192 what a create of that entity finds, copies and refuses |\n| `apps` | list of ([App](#app) \\| [Dashboard app](#dashboard-app)) | no | The apps this workspace will have \u2014 each one register over an entity, or a dashboard of readings |\n\n<!-- generated:end top-level -->\n\n**A model carries no** `fixtures`, `knowledge` or `knowledge_expects`, and no\n`word` / `pdf-form` template: file content is uploaded to the workspace, never\nstated in a model \u2014 an `excel` template names its uploaded workbook by `file_id`. `apps` here is what an agent states to make an app,\nnever built code. An unknown top-level key is an error, never ignored.\n\n### Aliases\n\nEvery `alias` is a lowercase slug \u2014 a letter, then letters, digits and\nunderscores (`unit_price`, `so_1001`). Aliases are how the file cross-references\nitself; they are never shown to anyone. `label` is what a person sees.\n\nLabels must be unique within their namespace \u2014 two entities, two fields on one\nentity, two options on one field, two views on one entity, two roles or two\ntemplates cannot share a label, because `apply` matches by label.\n\n## Entity\n\n<!-- generated:start entity -->\n\n#### Entity\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `alias` | alias | yes | Stable entity alias, unique within the contract |\n| `label` | text | yes | The table\'s name in the workspace, which apply names it |\n| `singular` | text | no | One row of this table, in the business\'s own words \u2014 what a create\'s button and panel name |\n| `description` | text | no | The table\'s description, written onto the table in the workspace |\n| `writes` | `false` | no | false: no person writes this table\'s rows \u2014 only the writes that keep it do, and no app opens or edits one |\n| `fields` | list of [Field](#field) (at least one) | yes | The table\'s columns |\n| `read_scope` | [Read scope](#read-scope) | no | Which rows a member reads. Absent, every member with access to the table reads every row. |\n| `unique` | list of list of alias (at least one) (at least one) | no | Sets of fields whose values no two live rows share \u2014 each a list of field aliases (text, number, date, a single select, or a link of cardinality "one"). A create or update landing a second row with the same values is refused. |\n| `views` | list of [View](#view) | no | Saved views, in the order they are listed; with none, the table still opens on its default grid |\n\n#### Read scope\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `any` | list of ([Read scope by role](#read-scope-by-role) \\| [Read scope by member](#read-scope-by-member) \\| [Read scope by option](#read-scope-by-option)) (at least one) | yes | A row is readable when ANY of these holds |\n\n#### Read scope by role\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `member_of` | alias | yes | A role alias: whoever is in the group it binds to reads the row |\n\n#### Read scope by member\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `through` | list of alias (1\u20133) | no | select_record_link aliases from this entity outward, each of cardinality "one" \u2014 the clause\'s field is on the entity the last hop lands on |\n| `field` | alias | yes | A select_member field alias on this entity, or on the entity `through` lands on |\n| `is` | `"self"` | yes | The members this column names on a row read that row |\n\n#### Read scope by option\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `through` | list of alias (1\u20133) | no | select_record_link aliases from this entity outward, each of cardinality "one" \u2014 the clause\'s field is on the entity the last hop lands on |\n| `field` | alias | yes | A single-select field alias on this entity, or on the entity `through` lands on |\n| `is` | list of alias (at least one) | yes | Its option aliases whose rows are readable \u2014 naming none would hide every row |\n\n<!-- generated:end entity -->\n\n<!-- generated:start rules-entity -->\n\n#### Rules the check enforces\n\n| Rule | |\n|---|---|\n| `entity.singular` | An entity a create is mounted over \u2014 a register that adds, a rows block \u2014 states its `singular`: one row of it, in the business\'s words. |\n| `entity.required-cycle` | Required links never wait on each other in a loop: no row of the loop could be created first. |\n| `entity.link-format` | A text field of `format: "link"` holds web addresses; one whose rows hold a phone or a mail address states no format. |\n| `entity.read-scope` | A `read_scope` clause names a declared role (`member_of`) or a field of its entity reached through one-row links: `"self"` a member field, options a single select declares. |\n| `entity.unique` | A `unique` set names two fields or more of its entity once each, each holding one value to compare \u2014 text, a number, a date, a single select, a one-row link \u2014 and is stated once; one field alone is `unique: true` on it. |\n\n<!-- generated:end rules-entity -->\n\n**`unique` is a set of values no two live rows share.** Each entry names fields\nof this entity holding ONE value \u2014 text, number, date, a single select, a link\nof cardinality `"one"` \u2014 and a create or update landing a second row with the\nsame values is refused. A set of one text field is that field\'s own `unique:\ntrue`, so it is refused here. A create carries each set in the names its panel\nsends, so the panel can name the duplicate before the write does.\n\n**`singular` is what a create says** \u2014 `New Order`, `Add Claim line`, `H\u1ED3 s\u01A1\nm\u1EDBi` \u2014 while `label` names the table, so without it the button reads `New\nOrders`. Nothing derives it: English plurals are irregular, and no language is\nexempt. In a language without plural forms it is usually the label itself,\nless any word for the collection. `model apply` REFUSES a model where a table\nsome create opens \u2014 a register\'s own, or a record section\'s add \u2014 states none.\n\n**`writes: false` says only the workspace\'s automations write these rows** \u2014 a\nlog of what the system sent, a copy of what another system holds. No app opens,\nedits or files one: a record listing them keeps the section and opens each row\nat rest, with no Add; a screen over the entity operates at most the rows its\nrecord owns; a party of it is picked, never found or minted; and the generator\nwrites no create and no update for it, and never notes it as created nowhere.\nA `lifecycle` on it is refused \u2014 a row walked\nthrough stages is worked by a person \u2014 and so is a publish desk over it. Absent,\npeople write the rows; `true` is not a value.\n\n**`read_scope` is a ROW rule, enforced by the platform.** It is resolved at\napply into the table\'s own row filters, so an app, a workflow reading for a\nviewer, and the API all answer the same rows \u2014 a per-record visibility field the\napp merely honours is a convention, not a gate. A `"self"` clause reads a column\nof one member or several, and every role alias and option alias a clause names\nmust be one this model declares. `apply` writes the rule onto a table it\nCREATES; a table it adopted that ALREADY CARRIES a rule keeps that one, because\nthe rule is the workspace\'s own statement about its rows \u2014 and a run whose model\nstates a different rule reports the entity rather than leaving the claim silent.\n\n**An app may state that its sharing is its read gate: `"reads": "shared"`.** An\napp reads as its owner, so the rule reaches a viewer only as the predicate every\nquery and editor guard of every app over the entity carries \u2014 right for a desk of\none\'s own rows, wrong for a desk whose audience its sharing already decides, where\nwidening the rule meant a role group nobody remembers to fill. Stated on an APP,\nits queries, pickers and guards carry no entity\'s rule and whoever the app is\nshared with reads and writes every row it draws; every other app and the table\'s\nown filters keep the rule. Share it deliberately. Refused on an app none of whose\ntables states a `read_scope`.\n\n**The rows under a private record INHERIT its rule.** An entity that states no\n`read_scope` and hangs under one that does \u2014 through its `parent` link, over one\nhop or several \u2014 is read by the ancestor\'s rule, answered through that link, on\nits table\'s own filters and in every query and guard alike; nothing is restated,\nso a child needs none of the ancestor\'s columns. Stating a rule on the child\nkeeps that one instead, an ancestor with no rule passes nothing down, and a row\nhanging further under the scoped one than a row filter reaches is refused by\nname \u2014 state a rule on it. So is a hop over a link that names more than one row:\nthe rule would admit a reader any one of them admits while the editor\'s guard\nreads the first, so give the link `"cardinality": "one"` or state a rule on the\nchild.\n\n**ONLY the `parent` role is walked.** A register a scoped record reaches by any\nother link \u2014 the rows that NAME it \u2014 is read by that record\'s id with no rule\ntravelling to it, so it is refused until it states one of its own.\n\n## Field\n\nEvery field carries the keys below, and its `type`\'s section adds the rest; a\ntype whose section names no `default` takes none. `label` may not contain `{` or\n`}` (formulas reference fields by label at the platform level). Every row in `rows` states each\n`required` field it carries (a default is not applied to them), and a required\nLINK is written with its row: the entity it names is created first, and entities\nwhose required links name each other are refused, since none of their rows could\never be created.\n\n<!-- generated:start field -->\n\n#### Field\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `type` | `"text"` \\| `"number"` \\| `"date"` \\| `"boolean"` \\| `"select"` \\| `"select_member"` \\| `"select_record_link"` \\| `"files"` \\| `"formula"` \\| `"rollup"` \\| `"lookup"` \\| `"autonumber"` | yes | What the field holds \u2014 each type takes the further keys its own section lists |\n| `alias` | alias | yes | Stable local alias, unique within the entity |\n| `label` | text | yes | The field\'s name in the workspace, which apply names it |\n| `description` | text | no | The field\'s description, written onto the field in the workspace |\n| `required` | boolean | no | Refuse a record whose cell for this field is empty. Apply writes it onto the field, and every write path \u2014 create, update, an agent\'s tool call, a workflow\'s set \u2014 refuses the row by field name. |\n\n<!-- generated:end field -->\n\n### `text`\n\n<!-- generated:start field-text -->\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `default` | text | no | Value pre-filled into a new record when none is supplied for this field. Applied on create only \u2014 existing records are never backfilled. |\n| `unique` | boolean | no | Unique values required |\n| `format` | `"text"` \\| `"link"` \\| `"markdown"` | no | How the words are drawn \u2014 plain, as a link that opens, or as markdown |\n\n<!-- generated:end field-text -->\n\n### `number`\n\n<!-- generated:start field-number -->\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `default` | number | no | Value pre-filled into a new record when none is supplied for this field. Applied on create only \u2014 existing records are never backfilled. |\n| `format` | `"number"` \\| `"currency"` \\| `"percentage"` | no | What the figure is \u2014 a plain number, money in `currency`, or a percent |\n| `currency` | text | no | ISO 4217 code |\n| `unit` | text | no | What a plain figure counts or measures, drawn after it: a measured code (g, kg, t, l, m3, cbm, mm, cm, m, km, m2, min, h, day), which converts and scales within its dimension, or any other noun of at most 12 characters (ki\u1EC7n, pallet, TEU), which never does. Only beside format "number". |\n| `unit_field` | alias | no | A single select on the same row, or a lookup of one through a one-link, whose chosen option is that row\'s unit: every option label is a unit as `unit` takes one. In place of `unit`; only beside format "number". |\n| `currency_field` | alias | no | A single select on the same row, or a lookup of one through a one-link, whose chosen option is that row\'s currency: every option label is an ISO 4217 code. In place of `currency`; only beside format "currency". |\n\n<!-- generated:end field-number -->\n\n`format` is what the number IS, and every surface reads it: `currency` prints as\nmoney in the code the row or the field states, `percentage` as a whole percent\nwith its sign. An ABSENT number is drawn absent \u2014 the one exception is a\n`sum` or count rollup the plan reads as a **`measure`**: that is the thing\naccumulated toward a bound, so nothing accumulated yet is zero and the meter\ndraws it. The same rollup read as an `amount` keeps its blank, and so does every\nother role: nothing added to what a row is WORTH means unpriced, not free. A\nformula reading only such sums and counts, and reading zero where each does\n(`{received} - {refunded}`), is one too. A `min`, an `avg`, a percentage of\nnothing and every other formula stay blank in any role, because none of them has\nan answer to give. This is why the pair on one\nscreen reads two ways \u2014 what has come in against what is owed \u2014 and why a\nmeasure\'s own LIMIT, an amount, leaves an unquoted row out of the count rather\nthan reporting it as nothing collected. **AND WHERE THAT LIMIT IS ABSENT \u2014 OR\nZERO \u2014 THERE IS NO LEVEL AT ALL**: a level is a reading AGAINST a bound, so a row\nthat states no bound, or a bound of nothing, draws nothing \u2014 cell, fact and all \u2014\nrather than a numerator whose whole meaning was the comparison. "Collected 0"\nbeside a blank total reads as money against a job worth nothing, and "0 of 0"\nagainst a count of nothing owed claims a comparison nobody can make. A measure the model gives no limit is a plain figure\nand is unaffected. A share is stored in percent units \u2014 68.1 is 68.1 % \u2014 and the\ncolumn, the fact behind it, the meter it is judged by and the figure over the\nregister all say so.\n\n`unit` is what a plain figure counts or measures, and stands only beside\n`format: "number"`. A **measured** unit is a code of one catalog \u2014 mass `g`\n`kg` `t`, volume `l` `m3` `cbm` (a cubic metre under freight\'s name), length\n`mm` `cm` `m` `km`, area `m2`, duration `min` `h` `day` \u2014 so a figure typed in\nanother unit of its dimension converts (`12,5 t` into a `kg` field is 12 500),\nand a tile or a chart reads it in the largest unit it reaches (12 500 kg as\n12,5 t\u1EA5n; CBM never scales) while a cell, a fact and a column always read in the\nfield\'s own. Any other noun of at most 12 characters (`ki\u1EC7n`, `pallet`, `TEU`) is a\n**counted** unit: a word after the figure that never converts. A measured unit is\nwritten as its code \u2014 `t\u1EA5n`, `KG` or `m\xB3` is refused, naming the code. A formula\nstates its own in `formula.unit`; a rollup that keeps the value (`sum`, `avg`,\n`median`, `min`, `max`, `range`) and a lookup carry the unit of the figure they\nread, exactly as they carry a currency, and a count carries none. A quantity is a\nnumber with its unit, never words (`3 cartons` in a text field): only a number\nsums, converts and reads down a column.\n\nA field\'s cells always hold figures in its own unit, so moving it between two\nunits of one dimension (`kg` to `t`) converts every stored figure \u2014 a change made\nin the workspace, in the field\'s settings (which say how many first) or through\n`lotics run update_table`. `model apply` never makes it: the model states the\nworkspace\'s unit until then. Any other change of unit\nrelabels.\n\nA figure whose unit or currency varies by row names a single select of its own\nrow in `unit_field` or `currency_field` (a formula in `formula.unit_field` /\n`formula.currency_field`) \u2014 or a lookup of one through a one-link, a line\nreading its shipment\'s currency. That select\'s options are the vocabulary:\neach label a unit as `unit` takes one (`chi\u1EBFc`, `kg`), or an ISO 4217 code\n(`USD`). A label outside it is refused when the figure is written and when the\nselect is \u2014 its options, its type or its deletion while a figure names it. A row\nwhose select is empty reads its figure bare. Every app reads each row\'s figure in\nits own row\'s unit, and a sum never mixes units: totals are one per unit, a\nchart draws one at a time. A rollup that keeps the value (`sum`, `avg`, `min`\u2026)\nover such a figure stands only where the child\'s select is a lookup, through\nthe rollup\'s own link, of a select on this entity \u2014 the rollup reads that\nselect\'s unit; otherwise it is refused, as is any lookup of such a figure (its\nunit lives on the other row). A count is unaffected. Moving a field between a\nfixed and a per-row unit relabels.\n\n### `date`\n\n<!-- generated:start field-date -->\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `default` | text | no | Value pre-filled into a new record when none is supplied for this field. Applied on create only \u2014 existing records are never backfilled. A date string in the field\'s format. |\n| `format` | `"date"` \\| `"datetime"` \\| `"date_range"` \\| `"datetime_range"` | no | Whether the field holds a day or a moment, alone or as a span |\n| `timezone` | text | no | IANA timezone |\n| `derive_from` | `"created_at"` \\| `"updated_at"` | no | Auto-populate from the row\'s system timestamp; the field becomes read-only. |\n\n<!-- generated:end field-date -->\n\nA `default` is refused beside `derive_from`: the platform stamps that date.\n\n### `boolean`\n\n<!-- generated:start field-boolean -->\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `default` | boolean | no | Value pre-filled into a new record when none is supplied for this field. Applied on create only \u2014 existing records are never backfilled. |\n\n<!-- generated:end field-boolean -->\n\n### `select`\n\n<!-- generated:start field-select -->\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `default` | list of alias | no | Value pre-filled into a new record when none is supplied for this field. Applied on create only \u2014 existing records are never backfilled. Option alias(es) this field declares \u2014 one for single-select. |\n| `options` | list of [Select option](#select-option) (at least one) | yes | The choices, in the order every picker and every ladder lists them |\n| `multi` | boolean | no | Allow multiple selections |\n\n#### Select option\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `alias` | alias | yes | Stable local alias, unique within the field |\n| `label` | text | yes | Display label for the option |\n| `color` | [colour](#select) | yes | The colour the option\'s badge is drawn in |\n| `mark` | [mark](#select) | no | The option\'s own mark, drawn in place of its colour dot wherever the option is shown: the brand it is ({kind: "brand", name: one of facebook, instagram, threads, meta, tiktok, google-ads, zalo, linkedin, x, google-meet, youtube, telegram, whatsapp, gmail, google-drive, outlook, kiotviet, misa, lark, payos}) or a kit glyph ({kind: "icon", name: "wrench"}). Every option of a field has one, or none does. A mark a reader does not draw falls back to the dot |\n\n<!-- generated:end field-select -->\n\n`color` is one of: `red`, `orange`, `amber`, `yellow`, `lime`, `green`,\n`emerald`, `teal`, `cyan`, `sky`, `blue`, `indigo`, `violet`, `purple`,\n`fuchsia`, `pink`, `rose`, `slate`, `gray`, `zinc`, `neutral`, `stone`.\nEvery option is drawn in its colour wherever it shows \u2014 a cell, a row\'s line,\na record, a picker, the bar a reading splits the rows by.\n\nAn option may carry its own `mark`, drawn in place of its colour dot wherever\nthe option is shown \u2014 a stage, a chip, a filter, a fact, an entry of a log, a\nreading\'s part, a lookup of the select on another entity. A select a reader scans\ndown a column \u2014 how a payment was made, the channel, the mode \u2014 states one on every\noption, since a glyph reads before its word: the brand it IS\n(`{"kind": "brand", "name": "tiktok"}` \u2014 one of `facebook`, `instagram`,\n`threads`, `meta`, `tiktok`, `google-ads`, `zalo`, `linkedin`, `x`,\n`google-meet`, `youtube`, `telegram`, `whatsapp`, `gmail`, `google-drive`,\n`outlook`, `kiotviet`, `misa`, `lark`, `payos`), or a glyph the kit draws\n(`{"kind": "icon", "name": "wrench"}`; any other name is refused). Every option of a select has one, or none does: a run of chips\nwhere one carries no mark reads as the one missing something. `apply` writes the\nmarks onto the table, sets one an adopted option lacks, and reports one it wears\ndifferently rather than overwrite it. The glyphs:\n\n<!-- generated:start field-select-glyphs -->\n\nactivity, align-center, align-left, align-right, arrow-down, arrow-down-up, arrow-down-wide-narrow, arrow-left, arrow-left-from-line, arrow-right, arrow-right-from-line, arrow-right-left, arrow-up, arrow-up-down, arrow-up-wide-narrow, ban, banknote, bed, bell, bold, bolt, book-marked, book-open, book-text, bot, box, brackets, brain, briefcase, building-2, calculator, calendar, calendar-clock, calendar-off, camera, car, chart-column, check, chevron-down, chevron-left, chevron-right, chevron-up, chevrons-down-up, chevrons-up-down, circle-alert, circle-check, clipboard-list, clock, code, code-xml, columns-3, columns-3-cog, construction, container, copy, credit-card, database, download, ellipsis, eraser, expand, external-link, eye, eye-off, facebook, file, file-csv, file-down, file-question, file-spreadsheet, file-stack, file-text, file-up, folder, folder-closed, folder-open, folder-pen, form, funnel-plus, funnel-x, gauge, globe, gpu, grip-vertical, group, hand-coins, heading, heading-1, heading-2, heading-3, history, house, image, inbox, info, instagram, italic, keyboard, languages, layout-dashboard, layout-grid, library-big, link-2, link-2-off, linkedin, list, list-checks, list-collapse, list-filter, list-filter-plus, list-ordered, loader, lock, lock-keyhole, lock-keyhole-open, lock-open, log-in, log-out, mail, map-pin, maximize-2, megaphone, menu, message-circle, message-circle-question-mark, message-square, messages-square, mic, minimize-2, minus, monitor, mouse, mouse-pointer-click, music, newspaper, notepad-text-dashed, package, paint-bucket, palette, panel-left, panel-left-close, panel-left-open, panel-right, panel-right-close, panel-right-open, paperclip, pause, pencil, phone, pin, pin-off, plane, play, plug, plus, receipt, rectangle-ellipsis, redo, refresh-cw, repeat, rotate-ccw, rotate-cw, scan, search, send, settings, share, share-2, shield, shield-alert, shield-check, shopping-cart, sliders-horizontal, smile, smile-plus, sparkles, split, square, square-check, square-pen, square-sigma, stethoscope, sticky-note, table, table-2, tag, target, text-quote, thumbs-down, thumbs-up, ticket, trash, trending-down, trending-up, triangle-alert, truck, tv-minimal, twitter, underline, undo, upload, user, user-check, user-pen, users, utensils, waypoints, workflow, wrench, x, zap\n\n<!-- generated:end field-select-glyphs -->\n\n```jsonc\n"options": [\n { "alias": "short_video", "label": "Short video", "color": "zinc", "mark": { "kind": "brand", "name": "tiktok" } },\n { "alias": "print", "label": "Print", "color": "amber", "mark": { "kind": "icon", "name": "newspaper" } }\n]\n```\n\n### `select_member`\n\nA person picker over the workspace\'s members. No default: a model cannot name\nmembers of a workspace that does not exist yet. With no role it is still drawn \u2014\nface and name, ranked as a `party` \u2014 in a screen\'s `columns` and in the register\na record draws of these rows.\n\n<!-- generated:start field-select_member -->\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `multi` | boolean | no | Allow multiple selections |\n\n<!-- generated:end field-select_member -->\n\n### `select_record_link`\n\n<!-- generated:start field-select_record_link -->\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `target_entity` | alias | yes | Alias of the entity this field links to |\n| `sync_both_ways` | boolean | no | Create a paired link field on the target entity for bidirectional sync |\n| `paired_field_alias` | alias | no | The pair edge of a bidirectional link: the field alias ON THE TARGET ENTITY that is this link\'s sync partner. Both sides of a pair carry it, each naming the other. Apply creates whichever side it reaches first WITH the pairing (the platform auto-creates the partner) and binds the partner alias to the auto-created field \u2014 without this edge the two contract fields would be created independently and collide with the auto-created partner. |\n| `cardinality` | `"one"` \\| `"many"` | no | How many linked records this field holds. Default \'many\'. \'one\' holds a single row and needs no partner; where the link IS paired, the partner side holds many. |\n| `display_field_aliases` | list of alias | no | Field aliases on the target entity shown as the link\'s display text / picker columns |\n\n<!-- generated:end field-select_record_link -->\n\nA two-way link is declared on BOTH sides, each naming the other as its\n`paired_field_alias`; the pair must be symmetric or the model is refused.\n\n**A single-valued link needs no partner.** `"cardinality": "one"` on its own is a\nlink that holds one row \u2014 one customer on an invoice, one project on a device \u2014\nand nothing is created on the target. The mirror invariant belongs to a PAIRED\nlink: pair a link when the target\'s own record should list what points at it, and\nleave it unpaired when it should not. Either way the record plan draws the\nrelation as a section on the side it points at, so an unpaired link costs the\ntarget nothing.\n\n### `files`\n\nNo keys beyond every field\'s; a row attaches documents to it (\xA7 Rows). Each file\nis drawn by its kind, with no key to choose: a picture (an image, a video) as its\nthumbnail, a document (a PDF, a sheet) as its type\'s badge and its filename.\n\n### `formula`\n\n<!-- generated:start field-formula -->\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `formula` | [Formula](#formula) | yes | Formula config. The expression references other fields on the SAME entity by alias in braces, e.g. `{quantity} * {unit_price}`. |\n\n#### Formula\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `expression` | text | yes | The expression, over fields of THIS entity by alias in braces \u2014 `{quantity} * {unit_price}` |\n| `format` | `"number"` \\| `"currency"` \\| `"percentage"` \\| `"link"` | no | Display format. \'number\' / \'currency\' / \'percentage\' for numeric results; \'link\' for text-output formulas that return a URL \u2014 renders the result as a clickable link. |\n| `currency` | text | no | ISO 4217 currency code, e.g. USD, VND, EUR |\n| `unit` | text | no | What a plain figure counts or measures, drawn after it: a measured code (g, kg, t, l, m3, cbm, mm, cm, m, km, m2, min, h, day), which converts and scales within its dimension, or any other noun of at most 12 characters (ki\u1EC7n, pallet, TEU), which never does. Only beside format "number". |\n| `unit_field` | alias | no | A single select on the same row, or a lookup of one through a one-link, whose chosen option is that row\'s unit: every option label is a unit as `unit` takes one. In place of `unit`; only beside format "number". |\n| `currency_field` | alias | no | A single select on the same row, or a lookup of one through a one-link, whose chosen option is that row\'s currency: every option label is an ISO 4217 code. In place of `currency`; only beside format "currency". |\n| `options` | list of [Select option](#select-option) (at least one) | no | The categories the formula yields, drawn as a single select\'s options are (read-only). The expression yields one of them as `{this_field:option}`, or null \u2014 `{days_idle} > 30 ? {warmth:cold} : {warmth:hot}`. Omit for a formula yielding a plain value. |\n| `output_type` | `"number"` \\| `"text"` \\| `"date"` \\| `"datetime"` \\| `"boolean"` \\| `"select"` | no | What the expression YIELDS \u2014 the kind the platform infers at write time, declared here so the offline checks can read it. `format` beside it is how that result is drawn, not what it is. Ignored on the wire (the platform re-infers it); `lotics model pull` writes the inferred value. |\n\n<!-- generated:end field-formula -->\n\n`output_type` is what lets a role or a screen clause accept a computed value: a\ncaption over a derived name (`output_type: "text"`), a period over a settled date\n(`"date"`). A formula declaring neither it nor a `format` says nothing about its\nresult, and every rule that needs one refuses it by name. A formula stating\n`options` is a computed category: read-only, and read as a single select\nwherever it is drawn \u2014 a column, a filter, a reading\'s `by` or `where`.\n\n**A select reaches a formula as the KEYS of its chosen options**, a list \u2014\nnever their labels, and never their aliases \u2014 and a model has no keys: the\nworkspace mints them when the table is made. So an option is named in a formula\nas `{field:option}`, both aliases, and the copy writes that option\'s key in its\nplace: `includes({kind}, {kind:crate})` for a select holding one or several,\n`{kind}[0] == {kind:crate}` for a single one. A select compared to its own words\n(`{kind} != "Crate"`) matches no row and computes the other branch everywhere,\nso the check refuses it and names the token. A select looked up from another\nentity is tested there, in a formula of its own, and that result looked up.\n\n**The language.** The offline check and the platform compute a formula with one\nengine, and the check refuses a call or a name it cannot run, naming what to write:\n\n<!-- generated:start field-formula-language -->\n\n- Operators: `+ - * / %`, `== != > < >= <=`, `&& || !`; `+` also joins text\n- Conditionals: a ternary only \u2014 `{amount} > 100 ? "High" : "Low"`\n- Not supported: optional chaining (`?.`), nullish coalescing (`??`), template literals, arrow functions \u2014 use `get(obj, "path", default)`, `coalesce(v1, v2)`\n- Helpers are these names, spelled exactly; a spreadsheet function (`IF`, `SUM`, `LEN`, `DATEDIF`) is none of them, and a formula calling one is refused\n- Math: round(n,decimals?), ceil(n), floor(n), abs(n), min(a,b), max(a,b), sum(arr), mean(arr), clamp(n,min,max), percentage(part,total,decimals?), pow(base,exp), sqrt(n), mod(n,divisor)\n- Strings: upper(s), lower(s), trim(s), capitalize(s), length(s), contains(s,search), join(arr,sep), split(s,sep), replace(s,search,rep), replaceAll(s,search,rep), startsWith(s,prefix), endsWith(s,suffix), substring(s,start,end?), padStart(s,len,char), padEnd(s,len,char), numberToWords(n)\n- Lists: includes(list,value), first(list), last(list), unique(list), compact(list) \u2014 length(list) counts one\n- Dates: now(), formatDate(d,fmt), addDays(d,n), subDays(d,n), addHours(d,n), subHours(d,n), addMinutes(d,n), subMinutes(d,n), startOfDay(d), endOfDay(d), differenceInCalendarDays(later,earlier), differenceInHours(later,earlier), differenceInMinutes(later,earlier), isBefore(d1,d2), isAfter(d1,d2), isSameDay(d1,d2), isToday(d), isWithinRange(d,start,end), parseDate(d)\n- Null/type: isNull(v), isEmpty(v) (also true for "" and []), coalesce(v1,v2,...), isString(v), isNumber(v), isBoolean(v), isArray(v), toNumber(v), toString(v)\n- Other: formatCurrency(amount,locale,currency), formatDecimal(value,decimals,locale) (grouped quantity, no symbol), get(obj,"path",default?)\n- Empty cells: a cell nobody filled is null inside a formula, whatever its type; one holding 0, false or "0" is not empty. Test it with `isEmpty({note})` \u2014 `{note} == ""` and `{done} == false` are false on an unset cell\n- Arithmetic over an empty cell: `+` and `-` read it as 0 beside a value (`{fee} + {surcharge}` is `{fee}` when the surcharge is empty, null when both are); `*`, `/`, `%` and a unary `-` yield null (`{price} * {qty}` is null, not 0, when the quantity is empty)\n- No helper throws on an empty cell: the math helpers and toNumber return null, the string helpers "". A value of the wrong type still errors. When EVERY field a formula reads is empty it is null \u2014 unless it reads each only as the argument of isEmpty, isNull or isNotNull (`!isEmpty({file})` is false there, not null)\n\n<!-- generated:end field-formula-language -->\n\n### `rollup`\n\nAggregates the records reached through a link on this entity.\n\n<!-- generated:start field-rollup -->\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `source_field_alias` | alias | yes | Alias of a select_record_link field on this entity to roll up from |\n| `aggregate_option` | [aggregation](#rollup) | yes | Aggregation operation. Its `field_key` names a field alias on the linked entity. |\n| `filter` | [filter](#views) group | no | Only linked records matching this filter are aggregated; one condition on its own is a group of one. Every `field_key` in it names a field alias on the linked entity, and a select condition\'s value names an option alias there. A traversal node reaches past that entity, so its `path` hops and inner `field_key` are fully-qualified `entity.field` aliases. |\n\n<!-- generated:end field-rollup -->\n\n`aggregate_option` is `{ "operation": \u2026, "field_key": \u2026 }` \u2014 `field_key` a field\nalias on the linked entity (`count` may omit it), and `operation` one of `count`,\n`sum`, `avg`, `median`, `min`, `max`, `range`, `empty`, `filled`,\n`percent_empty`, `percent_filled`, `unique`, `percent_unique`, `earliest`,\n`latest`, `date_range`, `checked`, `unchecked`, `percent_checked`,\n`percent_unchecked`. The operation must be one the aggregated field\'s type\nallows \u2014 `sum` over a number, `earliest` over a date, `filled` over any stored or\nformula field. A lookup is never rolled up: roll up the child\'s own field, or a formula\nover it.\n\n### `lookup`\n\nDisplays a field from the linked records.\n\n<!-- generated:start field-lookup -->\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `source_field_alias` | alias | yes | Alias of a select_record_link field on this entity to look up through |\n| `lookup_field_alias` | alias | yes | Alias of the field on the linked entity to display |\n| `order_by` | [Lookup order](#lookup-order) | no | Show ONE linked row\'s value \u2014 the first in this order \u2014 rather than every linked row\'s |\n\n#### Lookup order\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `field_key` | text | yes | A field alias on the linked entity the rows are ordered by |\n| `direction` | `"asc"` \\| `"desc"` | yes | Which end of that order the one row is taken from |\n\n<!-- generated:end field-lookup -->\n\nInside a formula, a lookup holding one value is that value (`{due_soon}` is\n`true`, not `[true]`); several values are a list.\n\n### `autonumber`\n\n<!-- generated:start field-autonumber -->\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `prefix` | text | no | Literal prefix prepended to every display value (e.g. \'KH-\' \u2192 \'KH-001\'). Ignored when `template` is set. |\n| `padding` | integer | no | Zero-pad the integer to this width. Default 1 (no padding). 3 \u2192 \'001\', \'012\', \'123\', \'1234\' (overflow uses the actual width). Ignored when `template` is set. |\n| `template` | text | no | Format template with placeholder tokens evaluated at insert time. Tokens: {N} (raw integer), {N:W} (zero-padded to width W, e.g. {N:3} \u2192 001), {YEAR} (4-digit year), {YEAR:2} (2-digit year), {MONTH} (2-digit month), {DAY} (2-digit day). Date tokens use the workspace timezone. Example: \'HM-{YEAR}-{N:3}\' yields \'HM-2026-001\'. Stored as the composed string; subsequent template edits do NOT re-format existing rows (date tokens would lose the original creation date). |\n\n<!-- generated:end field-autonumber -->\n\n<!-- generated:start rules-field -->\n\n#### Rules the check enforces\n\n| Rule | |\n|---|---|\n| `field.default` | A default names options its select declares \u2014 one on a single select \u2014 and a date stamped by `derive_from` states none. |\n| `field.option-mark` | An option\'s `mark` is one the kit draws, and a select\'s options mark every one or none. |\n| `field.unit-select` | A `unit_field` or `currency_field` is a single select of the same row, or a lookup of one through a one-link, whose every option label is a unit, or an ISO 4217 code. |\n| `field.link` | A link targets a declared entity: its `display_field_alias` a field of it, its `paired_field_alias` a link on it naming this one back \u2014 and of two paired links at most one reads one row. |\n| `field.formula` | A formula parses, reads fields of its own entity by alias, names an option as `{field:option}` of one a select declares, and compares a select to its options\' keys, never their words. |\n| `field.rollup` | A rollup aggregates, through a link of its entity, a field of the linked entity by an operation a model declares and that field\'s type takes \u2014 never a figure read in each row\'s own unit or currency \u2014 filtered on fields of the linked entity. |\n| `field.lookup` | A lookup reads, through a link of its entity, a field of the linked entity \u2014 never one read in each row\'s own unit or currency \u2014 ordered by a field of it. |\n| `field.computed-cycle` | Computed fields never wait on each other in a loop: each is computed after what it reads. |\n\n<!-- generated:end rules-field -->\n\n## Views\n\nSaved views live under the entity they belong to. Every field reference is a\nfield ALIAS on that entity.\n\n<!-- generated:start views -->\n\n#### View\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `alias` | alias | yes | Stable view alias, unique within the entity |\n| `label` | text | yes | Display name of the view |\n| `description` | text | no | The view\'s description, written onto the view in the workspace |\n| `columns` | list of [View column](#view-column) (at least one) | no | The columns the view shows, in this order and no others; absent, every field |\n| `filters` | [filter](#views) | no | The rows the view keeps |\n| `sort` | [sort](#views) | no | The order the view reads its rows in |\n| `summary` | map of text \u2192 text | no | Field alias \u2192 the operation its footer cell states |\n| `frozen_columns` | integer \\| `null` | no | How many leading columns stay in place while the rest scroll |\n\n#### View column\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `field_alias` | alias | yes | A field of this entity |\n| `visibility` | `"visible"` \\| `"hidden"` | yes | Field visibility state: \'visible\' = shown to everyone, \'hidden\' = not shown by default but members can toggle |\n| `width` | number | no | The column\'s width, in pixels |\n\n<!-- generated:end views -->\n\nA filter is a group \u2014 `{ "node_type": "group", "logic": "and" | "or",\n"children": [ \u2026 ] }` \u2014 or one condition on its own, `{ "node_type":\n"condition", "type": "select", "field_key": "tier", "operator": "has_any_of",\n"value": ["gold"] }`. A sort is a list of `{ "field_key": \u2026, "order": "asc" |\n"desc" | null }`. A condition\'s `type` is the field\'s type and its `operator` is\none that type admits \u2014 `has_any_of` / `has_none_of` / `has_all_of` / `is_empty` /\n`is_not_empty` for a select, `equals` / `greater_than` / `less_than` for a\nnumber, `on` / `before` / `after` / `between` for a date, `contains` /\n`is_any_of` for text. A select condition\'s `value` names option ALIASES.\n\n<!-- generated:start rules-view -->\n\n#### Rules the check enforces\n\n| Rule | |\n|---|---|\n| `view.fields` | A view\'s columns, summary, sort and filters name fields of its entity. |\n\n<!-- generated:end rules-view -->\n\n## Roles\n\nA role becomes a workspace group.\n\n<!-- generated:start roles -->\n\n#### Role\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `alias` | alias | yes | Stable role alias, bound to a workspace group when the model is applied |\n| `label` | text | yes | The group\'s name in the workspace |\n\n<!-- generated:end roles -->\n\n## Templates\n\nAn `html` or `email` template carries its content inline, and `{{name}}` in it is filled\nfrom the workflow\'s data; an `excel` template names an uploaded workbook by its `file_id`,\nfilled with the register\'s report by its `export` alone.\n\n<!-- generated:start templates -->\n\n#### Template\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `type` | `"html"` \\| `"email"` \\| `"excel"` | yes | html is a page a workflow renders to a PDF; email is a message a workflow sends; excel is a workbook a register\'s export fills |\n| `alias` | alias | yes | Stable template alias, unique within the contract |\n| `label` | text | yes | The template\'s name in the workspace |\n\n<!-- generated:end templates -->\n\n### `html` and `email`\n\n<!-- generated:start template-inline -->\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `content` | text | yes | Inline template content; placeholders reference field aliases |\n| `content_sha256` | text | no | sha256 (64-char lowercase hex) of the utf-8 content; derived where the template is written when absent |\n\n<!-- generated:end template-inline -->\n\n### `excel`\n\n<!-- generated:start template-excel -->\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `file_id` | text | yes | The uploaded .xlsx (`fil_\u2026`) the template is made from; its markers read what the export fills it with |\n\n<!-- generated:end template-excel -->\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**What an act\'s template is handed** is its record\'s own fields by alias: a\nselect, a member and a link as their words (a link as the linked row\'s title), a\nnumber, a date and a computed value that declares its kind raw (`2800000`,\n`2026-09-14`) \u2014 never a files field or a computed select. On one record it is\nhanded, too, the rows of each child the record draws as `rows` whose alias the\ntemplate names, listed under that alias for `{{#each <alias>}}` in place of the\nrecord\'s own link to them: every row filed under the record, oldest first, each\nby its own fields as the record\'s are, less its link back to the record. Over\nseveral rows the rows are listed under the entity\'s alias, for\n`{{#each <alias>}}`, without their child rows.\n\n## Rows\n\nFirst records, keyed by entity alias. Up to 200 rows per entity and 2000 across\nthe model, attaching at most 2000 documents between them \u2014 a real data set\nbelongs in an import, not a model.\n\n```jsonc\n"rows": {\n "customer": [\n { "ref": "acme", "fields": { "name": "Acme Trading", "tier": "gold" } }\n ]\n}\n```\n\n<!-- generated:start rows -->\n\n#### Row\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `ref` | text | yes | Local handle for this row, referenced by other rows\' link fields |\n| `fields` | map of text \u2192 any value | yes | Field alias \u2192 the value, read against the field\'s declared type |\n\n<!-- generated:end rows -->\n\n<!-- generated:start rules-rows -->\n\n#### Rules the check enforces\n\n| Rule | |\n|---|---|\n| `rows.ref` | A row\'s `ref` is unique within its entity. |\n| `rows.required` | A row states every field its entity requires: it is created with what it states, its fields\' defaults unapplied. |\n| `rows.distinct` | Open rows of an entity a register lists read apart: no two state the same title and the same line under it. |\n| `rows.value` | A row\'s value fits its field: an option alias for a select, `"<entity>:<ref>"` of a row of the linked entity in this file for a link, `"self"` for a member, a relative path beside the file or a `fil_` id for files, a date in the date grammar (an hour only on a datetime), a text, number, yes/no or null otherwise \u2014 and none on a computed field. |\n\n<!-- generated:end rules-rows -->\n\nA `ref` is lowercase letters, digits and underscores, and is never persisted.\n\nA `files` cell attaches documents: paths relative to this file (no `..`, never\nabsolute), which the check proves exist and `apply` uploads into the workspace\nbefore any row is written \u2014 a paperwork business seeds its papers with its\nrows. The server accepts only `fil_` ids of files this workspace owns, which is\nwhat the upload leaves behind. After a run that wrote rows, the WORKSPACE holds\nwhich record each row became, under the row\'s own `<entity>:<ref>`:\n`delete_records` over them is how a seeded set is reset, and applying again\nre-dates it.\n\n`fields` is keyed by field alias, and every value is read against the field\'s\nDECLARED type:\n\n| Field type | Value |\n|---|---|\n| `text` / `number` / `boolean` | the value itself |\n| `date` | `"2026-03-14"`, or a relative expression (below) |\n| `select` | the option ALIAS \u2014 `"gold"`, or `["gold","vip"]` for a multi-select |\n| `select_record_link` | `"<entity-alias>:<ref>"` naming another row in this file \u2014 `"customer:acme"`, or an array for several; a paired link is stated on ONE side (the child\'s link to its parent) and its partner fills itself |\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 `model apply`/`setup` before the model is sent; or `fil_` ids of files already in this workspace |\n| `formula`, `rollup`, `lookup`, `autonumber` | not allowed \u2014 the platform writes these |\n\n### Relative dates\n\nA date cell holds a literal `YYYY-MM-DD`, or an expression relative to the day\nthe model is applied, so a screen that opens on "this month" is not empty a month\nlater:\n\n- `@today` \u2014 the day of the run, in the workspace\'s timezone\n- `@month-start` \u2014 the 1st of that month\n- either with a whole-day offset: `@today-14`, `@month-start+9`\n- on a date that holds its hour (`format: "datetime"`), that hour after the day, and always stated:\n `@today 14:30`, `@today+1 06:00`\n\n`@month-start` exists because `@today-N` cannot promise a month: applied on the\n2nd, `@today-3` lands in the previous one.\n\n## Records\n\n`records` says how a row of each entity is RECOGNISED \u2014 once per entity, keyed by\nentity alias \u2014 and every surface that draws one of its rows reads the same\nstatement: the register\'s row, a picker\'s option, a link\'s chip, a child table\'s\nrow and the record\'s header. Every entity an app lists, opens or picks has one.\n\n```jsonc\n"records": {\n "visit": {\n "title": "container_no",\n "subtitle": ["customer", "arrived_on"],\n "image": "photos",\n "status": { "field": "stage", "closed": ["gone"], "history": "visit_history" },\n "figure": "total_fees",\n "due": ["free_until"]\n },\n // A release order read against what it allows: a meter wherever it shows.\n "release": { "title": "release_no", "figure": "issued", "limits": { "issued": "units" } },\n // A dossier\'s stage is the last date it reached \u2014 no stored select.\n "dossier": { "title": "applicant", "status": { "milestones": ["received_on", { "field": "appraised_on", "when": { "kind": ["loan"] } }, "signed_on"], "closed": ["signed_on"] } }\n}\n```\n\n<!-- generated:start records -->\n\n#### Record\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `title` | alias | yes | The field that names a row \u2014 the first thing every surface shows: who or what it is, a link naming it by the linked row (a job read by its customer). An autonumber is refused; a typed number is the subtitle |\n| `subtitle` | list of alias (1\u20132) | no | Up to two fields read under the title wherever a row is drawn (a code, a date, a party) |\n| `image` | alias | no | A files field that pictures the row \u2014 never one an act keeps the document it makes in |\n| `description` | alias | no | A text field saying what the row is about \u2014 a plan\'s brief, a customer\'s note: read in its record\'s head under the title\'s line, its first lines at rest and whole on a press, and written from the head\'s \u270E. Never on a row\'s line |\n| `party` | `"person"` \\| `"organization"` | no | What each row is where the rows are people (a contact, a patient) or organisations (a customer, a supplier, a carrier); absent, the rows are things (an order, an item, a paper) |\n| `status` | [Status](#status) | no | The stage a row moves through \u2014 a single select or its milestone dates \u2014 drawn as a badge beside its title |\n| `figure` | alias | no | The one number the row stands for, read at its right (a total, a quantity left) |\n| `limits` | map of alias \u2192 (alias \\| number) | no | A number read against a bound \u2014 a field of the same row or a constant \u2014 drawn as a meter wherever it shows |\n| `gates` | map of alias \u2192 (alias \\| number) | no | A pass mark on a meter `limits` bounds \u2014 a field of the same row or a constant; a percent field is its share of the bound, any other number an amount. The meter marks it and fills complete once past it |\n| `tolerance` | map of alias \u2192 number | no | How far past its `limits` bound a meter may stand, in percent of the bound: the bound is an amount expected (received against ordered), not a cap. Without one the bound is a cap, and past it is an overrun |\n| `due` | list of alias (at least one) | no | Dates that are deadlines (stored, or computed as a date): each counts down and turns overdue |\n| `frees` | alias | no | A date ending the row\'s span of days on which what the row holds is free again (a check-out, a hire\'s return): a stay from the 3rd to the 5th holds the 3rd and the 4th, and another may start on the 5th \u2014 on a lanes board, a calendar, a roster and in `no_overlap` alike. Absent, a span of days holds its last day; a span of moments always frees its end |\n| `task` | `true` | no | A row is work someone finishes: wherever rows of it stand in a table \u2014 a register, a record\'s rows, the rows filed under one \u2014 each is led by a ring ticking it done and unticking it by the one write of the app making that move. Its status is a stored select with `closed` |\n| `applies` | map of alias \u2192 map of alias \u2192 (list of alias (at least one) \\| boolean) | no | Fields that apply only while their conditions hold (a length only on a line whose tariff charges by the metre): where one does not apply no add asks it and no surface shows it. Never the title; a required one starts at its `default` |\n| `starts` | map of alias \u2192 `"today"` \\| `"me"` | no | What a field of a row being added starts at, where that start is certain: `today` a date (a moment starts now), `me` a member, the reader adding it; every other field starts empty unless the reader narrowed the register to a value or the field declares a `default` |\n\n#### Status\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `field` | alias | no | The single select that is the row\'s state \u2014 or `milestones` instead |\n| `milestones` | list of (alias \\| [Milestone](#milestone)) (at least 2) | no | Dates in the order the work reaches them, instead of a stored select: the stage is the last one filled, ticking one stamps today, and clearing one clears every later one |\n| `closed` | list of alias (at least one) | no | Options (or milestones) that end the work: a register opens on the rows not at them, and the badge reads muted |\n| `history` | alias | no | A child entity every status change appends one row to (its link to this entity, the new status, the moment and who) \u2014 written by the app\'s own writes, never a table automation; a row the model seeds opens it at its status, the day the rows land, unless the model states its rows; with `field` only |\n\n#### Milestone\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `field` | alias | yes | A date field: the stage is reached on the day it holds |\n| `when` | map of alias \u2192 (list of alias (at least one) \\| boolean) | yes | The stage applies only while each named select holds one of these options, and each named stored yes/no is this value; otherwise the row skips it |\n\n<!-- generated:end records -->\n\n<!-- generated:start rules-records -->\n\n#### Rules the check enforces\n\n| Rule | |\n|---|---|\n| `records.title` | A title names the row by what it is \u2014 never an autonumber; a code is its subtitle. |\n| `records.image` | `image` is a files field, never one an act keeps the documents it makes in (`into`). |\n| `records.description` | `description` is a text field, read in the record\'s head \u2014 never a section\'s too. |\n| `records.status` | A status is one single select, `closed` naming options of it \u2014 or its `milestones`. |\n| `records.history` | A declared status `history` has exactly one link to the row it records, one single select holding every option of the status (or a text), exactly one date and at most one member. |\n| `records.history-derived` | A `history` the model does not declare is derived and keeps one entity\'s moves, labelled by that entity ("L\u1ECBch s\u1EED <entity label>", or "<entity label> history"): no declared table holds that label, and the entity has no other field under the link it adds. |\n| `records.history-scope` | A status history reads by its record\'s `read_scope`, answered through its one-row link to the record within the links a row rule reaches \u2014 or it states its own. |\n| `records.milestones` | Milestones are dates a person ticks, each named once; `closed` names some of them, and a deadline (`due`) is never one. |\n| `records.milestone-add` | An add asks the first milestone at most \u2014 each after it is ticked in order. |\n| `records.figure` | A figure is a number. |\n| `records.applies` | `applies` holds a field of the row other than its title to conditions of the same row \u2014 a yes/no (stored or a formula) by `true` or `false`, a single select by its options, its own or looked up through a one-row link \u2014 never to itself; a required field it holds starts at a `default`. |\n| `records.limits` | `limits` bounds a number by a number field of the row, in its unit or one of the same dimension \u2014 or by a constant, where every row reads the figure in one unit. |\n| `records.gates` | `gates` marks a pass on a meter `limits` bounds: a percent field (its share of the bound), another number in the bound\'s unit, or a constant. |\n| `records.tolerance` | `tolerance` stands on a meter `limits` bounds. |\n| `records.starts` | `starts` starts a field a person writes \u2014 `today` a date or a moment (never a span), `me` a member \u2014 and an add that does not ask a field it starts writes that start. |\n| `records.due` | A deadline (`due`) is a date. |\n| `records.frees` | `frees` names a stored day, never a moment: a span of moments already frees its end. |\n| `records.task` | A task entity (`task`) states a `status` of a stored single select with `closed` options \u2014 its ring ticks a row done by moving it to one. |\n\n<!-- generated:end rules-records -->\n\n- **The title names the row** by who or what it is \u2014 a link by the linked row (a job\n read by its customer). An autonumber is refused as a title; a typed number is the\n subtitle.\n- **The status** is one single select. `closed` options end the work: a register opens\n on the rows not in them. A `history` entity gets one row per move \u2014 its link to this\n entity, the new status (a select holding the same option aliases, or text), the\n moment and who \u2014 appended by the app\'s own writes that move the status, never by a\n table automation. A `history` the model does not declare is derived, and needs no\n `records` entry of its own.\n- **Milestones** are the alternative to a stored select: dates in the order the work\n reaches them, the stage being the last one filled (never stored). A milestone with a\n `when` applies only while its select holds those options and its stored yes/no that\n value; otherwise the row skips it.\n The record draws them as one block where ticking stamps today and clearing one clears\n every later one; every save and act re-checks that a date comes after each earlier one\n that applies. `closed` names the milestones that end the work. A register\'s add may ask\n the first milestone, never a later one; an act may stamp one, never clear it.\n- **A limit** reads a number against a bound \u2014 another field of the row, or a constant.\n A `gates` entry marks a pass on that meter: a percent field is its share of the\n bound, any other number an amount.\n- **A deadline** (`due`) counts down wherever it is drawn and is overdue once past while\n the work is open. A register over the entity says how many rows in view are overdue on\n its summary line, and pressing that count narrows to them \u2014 no reading to state.\n- **A span\'s free day** (`frees`) is the end date on which what the row held is free\n again \u2014 a check-out, a hire\'s return. A stay from the 3rd to the 5th holds two nights:\n a lanes board, a calendar and a roster draw it through the 4th, and `no_overlap` lets\n the next stay start on the 5th. Without it a span of days holds its last day; a span of\n moments always ends as the next may begin.\n- **`starts`** is where an add\'s field begins, stated only where that is certain \u2014 a\n log\'s own day at `today`, a request\'s requester at `me`. Nothing else starts a field\n but its declared `default` and what the reader narrowed the register to.\n\n## Write rules\n\n`write_rules` is what a WRITE meets, keyed by entity alias; each entity\'s is held\nby its table. Every generated write of an\napp (`create_<entity>`, `update_<entity>`, each act) re-checks these on the\nserver; the screen only mirrors them.\n\n```jsonc\n"write_rules": {\n // A customer is RECOGNISED by their address. An add that names one \u2014 from the\n // customers register or from a picker\'s "new" \u2014 reuses the row it matches and\n // opens one only where nothing does, so the book never grows a second Acme.\n "customer": { "natural_key": ["email"] },\n "order_line": {\n "fields": {\n // A line of nothing is not a line. Refused on create and on update.\n "quantity": { "min": 1, "max": 9999 },\n // Shipped inside its order\'s window: a bound read off the row a link names\n // is held from both sides \u2014 moving the order\'s dates never strands a line.\n "ships_on": { "min": "order.placed_on", "max": "order.due_on" },\n // The price is fixed at the moment of ordering \u2014 COPIED off the product,\n // not looked up for ever after.\n "unit_price": { "default_from": "product.price" },\n // Nothing is sold off an empty shelf. The picker reads only the rows that\n // answer this, and the write refuses the same rows again.\n "product": {\n "options_where": {\n "node_type": "group", "logic": "and",\n "children": [{ "node_type": "condition", "type": "number",\n "field_key": "in_stock", "operator": "greater_than", "value": 0 }]\n }\n }\n }\n },\n // One booking per room at a time, while it is held: a create or a save whose\n // span overlaps another held booking of the same room is refused.\n "booking": {\n "fields": { "ends_on": { "min": "starts_on" } },\n "no_overlap": { "from": "starts_on", "to": "ends_on", "by": ["room"], "while": { "state": ["held"] } }\n }\n}\n```\n\n<!-- generated:start write-rules -->\n\n#### Entity write rules\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `natural_key` | list of alias (at least one) | no | The field aliases a row of this entity is RECOGNISED by. A write that names a row of this entity by these values reuses the row it finds, minting one only where nothing matches. |\n| `fields` | map of alias \u2192 [Field write rule](#field-write-rule) | no | Field alias \u2192 what that field\'s value is copied from, bounded by or picked among |\n| `no_overlap` | [No overlap](#no-overlap) | no | No two rows hold overlapping spans \u2014 a create or update that would is refused |\n\n#### Field write rule\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `default_from` | text | no | Copy this field\'s value from the linked row at CREATE time \u2014 `<link alias>.<field alias>`, the link being a one-row link on this entity, required or filled by the add (the record a row is added under). The value is copied rather than looked up, so the source changing later leaves the row alone. |\n| `min` | number \\| text | no | Refuse a create or update whose value is below this: a constant for a number, or a field \u2014 `<field alias>` of the same row, or `<link alias>.<field alias>` of the one row a link names \u2014 of the same type (a return date after the departure, a line\'s day inside its trip). On a figure whose unit or currency is per row (`unit_field`, `currency_field`) a constant bound is only 0; bound it by a field of the row |\n| `max` | number \\| text | no | Refuse a create or update whose value is above this: a constant for a number, or a field of the same row or of the one row a link names |\n| `options_where` | [filter](#views) | no | Which rows of the target this link may point at \u2014 an `and` group of plain conditions over the TARGET entity\'s own fields, each `field_key` a field alias. It narrows the picker\'s read and is refused again where the write lands. |\n| `same` | list of alias (at least one) | no | One-row links this row and each row this link points at must name alike \u2014 a fee\'s invoice is one of its own visit\'s, a box\'s seal one of its own shipping line\'s: each a one-row link of both entities to the same entity. It narrows the picker\'s read to the rows naming what this row names, and a write is refused wherever it lands \u2014 on the link, or on what the two rows compare. |\n| `suggest` | text | no | A text field whose control offers, as the reader types, the distinct values a text field of a catalog holds \u2014 `<entity alias>.<field alias>` (a damage position\'s code among the codes on file); any text is still written |\n\n#### No overlap\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `from` | alias | yes | The stored date (or datetime) a row\'s span starts on |\n| `to` | alias | yes | The stored date (or datetime) a row\'s span ends on \u2014 a day it holds, unless `records` names it the day the row `frees`; a moment another row may start at |\n| `by` | list of alias (at least one) | no | Fields two rows share to compete for a span (the same employee, the same room); absent, every row |\n| `while` | map of alias \u2192 list of alias (at least one) | no | Only rows at these options of these selects hold their span (running, not closed); absent, every row |\n\n<!-- generated:end write-rules -->\n\n<!-- generated:start rules-write -->\n\n#### Rules the check enforces\n\n| Rule | |\n|---|---|\n| `write.natural-key` | A `natural_key` is text or a number a person types back, or a one-row link; a text key is unique \u2014 `unique: true`, or the key\'s fields as a set in the entity\'s `unique`. |\n| `write.default-from` | `default_from` copies `<link>.<field>` off the row a one-row link names, wherever an add fills that link (asked, or the record it is added under), into a field of the same type: a select into one declaring each of its options by alias, several options only into a multi-select. |\n| `write.bounds` | `min` and `max` bound a number or a date \u2014 by a constant a number, by a field of this row or of the one row a one-row link names either, of the same type and unit, never by itself \u2014 and `min` is never above `max`. |\n| `write.bound-per-row-unit` | On a figure whose unit or currency is the row\'s own (`unit_field`, `currency_field`) a constant bound is only 0 \u2014 bound it by a field of the row. |\n| `write.suggest` | `suggest` stands on a stored text field and names `<entity>.<field>`, a stored text field of a declared entity; the app reads that entity, so it has a `records` entry. |\n| `write.same` | `same` stands on a link and names one-row links this entity and the link\'s target both hold to one entity. |\n| `write.options-where` | `options_where` narrows a link by an `and` group of plain conditions over the target\'s own fields \u2014 a number compared in one unit on every row, a select by options it declares. |\n| `write.no-overlap` | `no_overlap` spans two date fields; `by` names values rows share \u2014 a link, a person, one option, a text or a number; `while` names single selects by their options. |\n\n<!-- generated:end rules-write -->\n\n- A `natural_key` is `text` or `number`, because a person types it back, or a\n one-row link, because a row is also recognised by the one row it belongs with (a\n fee by its visit and its kind). A key holding a `text` field is unique: `unique:\n true` on that field, or the key\'s fields as a set in the entity\'s `unique` (a\n site by its customer and its name) \u2014 two rows sharing it would make\n find-or-create pick whichever the read answered first. An add asks the key by\n default.\n- A `min` or `max` naming a field is read off the row as the save would leave it,\n and one naming `<link>.<field>` off the row the link names \u2014 the link\'s side\n re-checks its own rows when that bound moves.\n- A `default_from` copies where the add is sure to hold its link\'s row \u2014 a\n required link, or the record the row is added under (an appointment added under a\n plan takes the plan\'s patient) \u2014 and the two field types match. The copied field is\n not asked there; through an optional link the add leaves empty, it is asked.\n- A `same` link names only rows that name what this row names \u2014 a fee\'s invoice\n is one of its own visit\'s \u2014 through a one-row link both entities hold to one\n entity; its picker offers nothing until the row names one. A row added under a\n record names what it copies off it (`default_from`) from the start, so a block\n expecting one line per row of the link lists that record\'s rows alone. A save\n moving what the two compare, on either row, is refused while they would name apart.\n- An `options_where` link may be left empty; a row it names is refused again\n where the write lands unless it holds the narrowing. A link naming one kind of\n a book\'s rows (a "Shipping line" among the parties) narrows to that kind, or\n its picker offers every row.\n\nA field\'s own `required` is not here: the contract carries it, and every write\npath refuses the row by field name from it. `unique` is the text field\'s own\nclause (\xA7 `text`) \u2014 an add says so at the control before the column does. A\nfield the workspace writes (a formula, a rollup, a lookup, an autonumber, a date\nwith `derive_from`) is never asked, written or edited.\n\n## Apps\n\nAn app is ONE register over one entity and the record each row opens \u2014 or a dashboard\nof readings. The author states COMPOSITION \u2014 which fields go where; the runtime owns\nhow each thing looks, one treatment per concept. Nothing is drawn that the app does not\nname, and each fact appears once on a record. What the vocabulary has no word for is an\nact\'s own `workflow`.\n\n```jsonc\n"apps": [{\n "alias": "gate", "name": "Gate in and out", "entity": "visit",\n "register": { "columns": ["service"], "filters": ["customer", "service"] },\n "record": {\n "sections": [\n { "title": "In", "fields": ["customer", "service"], "blocks": [{ "rows": "fee", "columns": ["amount"] }] },\n { "title": "Out", "at": ["in_yard"], "fields": ["release", "seal"], "blocks": [{ "files": ["photos"] }], "acts": ["gate_out"] }\n ]\n },\n "acts": [{ "alias": "gate_out", "label": "Gate out", "when": { "stage": ["in_yard"] },\n "requires": ["release", "seal"], "set": { "stage": "gone", "left_on": "now" }, "confirm": true }],\n "checks": [{ "field": "unpaid", "blocks": ["gate_out"] }]\n}]\n```\n\n<!-- generated:start apps -->\n\n#### App\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `alias` | alias | yes | The app\'s name within the model \u2014 what `--from model.json#<alias>` picks |\n| `name` | text | yes | The job, in the words of the people who do it |\n| `description` | text | no | What the job is for, in a sentence |\n| `icon` | text | no | A lucide icon name the launcher tile draws |\n| `theme` | [Theme](#theme) | no | The launcher tile\'s colour |\n| `entity` | alias | yes | The entity whose rows this job works \u2014 the register\'s rows |\n| `books` | list of [Book](#book) (at least one) | no | Other entities the register reads as one list with `entity`\'s rows, each opened, edited and acted on in its own table: the same app over each, its fields lined up with `entity`\'s. Only a table or cards |\n| `scope` | alias | no | A required one-row link of the entity: the app works inside one row of the linked entity at a time (a project, a branch), picked from a list of them, and every row it reads, counts and adds is that row\'s |\n| `reads` | `"shared"` | no | Every member reads every row, whatever the entity\'s read scope |\n| `writes` | `"children"` | no | The reader adds and edits the record\'s child rows but not the record\'s own fields; the app\'s acts still move it |\n| `row` | [App row](#app-row) | no | How this job reads the entity\'s rows where it reads them otherwise than `records` (a cashier\'s figure is what is owed): the subtitle, figure and image stated replace the entity\'s wherever this app draws its rows \u2014 the register, a card, the record\'s header, a calendar, lane or roster entry. The title stays the entity\'s |\n| `register` | [Register](#register) | no | The rows: which columns and filters, the order, how a row is added |\n| `record` | [Record page](#record-page) | no | What a row opens: the door, its sections, and beside them its comment thread and its status history where stated |\n| `acts` | list of [Act](#act) | no | What the reader does to a record \u2014 each a press with its conditions and its write |\n| `checks` | list of [Check](#check) | no | Formulas that warn while they stand, or refuse the acts and saves they name |\n\n#### Theme\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `color` | text | yes | The tile\'s colour |\n\n#### App row\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `subtitle` | list of alias (1\u20132) | no | Up to two fields read under the title in this app, in place of the entity\'s |\n| `figure` | alias | no | The one number a row stands for in this app, in place of the entity\'s; a `limits` bound on it reads as its meter |\n| `image` | alias | no | A files field that pictures a row in this app, in place of the entity\'s |\n\n#### Book\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `entity` | alias | yes | Another entity whose rows the register reads beside its own \u2014 the same job over another table (a legacy ledger, a second branch\'s book) |\n| `fields` | map of alias \u2192 alias | no | A field of the app\'s entity \u2192 the field of this entity holding the same fact, where their aliases differ; a field the app reads lines up by its own alias otherwise, and one this entity lacks is blank on its rows and drops from what they open \u2014 an act, check or add needing it is not offered on them. Options line up by alias |\n\n#### Dashboard app\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `alias` | alias | yes | The app\'s name within the model \u2014 what `--from model.json#<alias>` picks |\n| `name` | text | yes | The job, in the words of the people who do it |\n| `description` | text | no | What the job is for, in a sentence |\n| `icon` | text | no | A lucide icon name the launcher tile draws |\n| `theme` | [Theme](#theme) | no | The launcher tile\'s colour |\n| `reads` | `"shared"` | no | Every member reads every row, whatever the entity\'s read scope |\n| `dashboard` | list of ([Metric](#metric) \\| [Breakdown](#breakdown) \\| [Trend](#trend) \\| [Pivot](#pivot) \\| [List](#list)) (at least one) | yes | The readings, in order \u2014 each over every row of its entity, windowed by the one period the reader switches |\n\n<!-- generated:end apps -->\n\n<!-- generated:start rules-app -->\n\n#### Rules the check enforces\n\n| Rule | |\n|---|---|\n| `app.alias-unique` | An app\'s alias is unique among the model\'s apps, and an act\'s among its app\'s acts. |\n| `app.records-entry` | Every entity an app lists, opens or picks \u2014 its own, a block\'s, a linked row\'s, a document\'s \u2014 has a `records` entry. |\n| `app.reads-shared` | `reads: "shared"` stands only where a table the app reads states a `read_scope`. |\n| `app.writes-children` | An app that writes only the record\'s children (`writes: "children"`) adds no row of its own entity. |\n| `app.scope` | `scope` is a required one-row link of the app\'s entity, the same on every row the app lists \u2014 never a column, filter, search field, group, `where`, `opens`, section field or add question. |\n| `app.when` | A `when` holds single selects to options and yes/nos to `true` or `false`, stored or a formula of one \u2014 a milestone\'s `when` only a stored yes/no, read before any formula over it is computed. |\n| `app.asked-written` | What an add, an act or `starts` fills is a field a person writes \u2014 never a formula, rollup, lookup, autonumber or a date the workspace stamps. |\n| `app.own-rows` | A many-link to rows that each belong to one row of this entity is never a section\'s field, an add\'s question or an act\'s ask: those rows are the record\'s own, read as a `rows` block. |\n| `app.create-asks` | An add asks fields of a table people write \u2014 never the link or the folder it files the row under, which the add sets itself. |\n| `app.create-required` | An add (`create`) asks every field its entity requires that nothing else fills \u2014 a `default`, `starts`, the record or folder it files the row under, or a `default_from` over a link it fills \u2014 or the server refuses every add. |\n| `app.books` | A register app\'s `books` each name another entity once \u2014 never the app\'s own \u2014 whose fields line up with the app\'s by alias, else by `fields` (a field the app reads, or one a report prints \u2192 one of the book\'s): read as one column type (a day apart from a moment), one value or several alike, a link to the same entity. Each book holds the title, the status, the folder (`scope`), what `where` keeps and every check locking the rows; the register is a table or cards. Two books holding a field of their own under one alias hold it as one kind of value. A report\'s `books` each name the app\'s entity or one of its books, once, and one of them holds each field its `where` and `per` narrow by. `register.book` reads a register that has books. |\n| `app.book-option-kin` | *Noted, never refused:* A book\'s option labelled as one of the app\'s, under another alias, reads apart from it: the register lists both and counts each on its own. |\n| `app.book-lacks` | *Noted, never refused:* A book lacking a field the app\'s record, an act or an add reads: what reads it drops from the book\'s rows \u2014 an act, a block, a section, the add. A field only a report prints, held by a book as another kind of value, prints blank on its rows. |\n| `app.row` | An app\'s `row` states only what differs from `records`: a line that is not the title, a number for its figure, a files picture no act makes documents into. |\n| `app.files-shown` | Every files field is drawn by some app over its entity \u2014 a section\'s `fields`, a `files` block, a column, the `image`, or where an act making its papers into it (`into`) stands, a section naming the act: the made paper reads under the act, one per template. |\n| `app.document-image` | *Noted, never refused:* A linked entity holding one files field and no `image`: `image` lets a link preview its paper. |\n| `app.tasks` | *Noted, never refused:* A task entity an app lists whose rows no write of the app moves to a closed option (its ring stands disabled), or that an act ticks done and none moves back. |\n\n<!-- generated:end rules-app -->\n\n`writes: "children"` is a desk that adds and edits a record\'s child rows and never\nthe record itself. `reads: "shared"` lifts every read scope the app\'s tables state \u2014\nrefused where none of them states one.\n\n`scope` names a required one-row link of the entity: the app works inside ONE row of\nthe linked entity at a time \u2014 a project, a branch, a season. With no folder remembered\nit opens on the list of them, each with how many of the app\'s rows it holds; inside,\nevery read, count and reading narrows to that one, an add files its row under it (the\nlink is never asked), and the record states its folder in the header. A scope narrows\nthe view; who may read what stays the entity\'s `read_scope`. The folder link is never a\ncolumn, a filter, a search field, a fact or an add\'s question.\n\nA dashboard is `{ "alias", "name", "dashboard": [readings] }` with no entity, register\nor record: each reading reads every row of its entity, and one period the reader\nswitches (this month \xB7 30 days \xB7 this quarter \xB7 this year) windows each reading placed\nin time; one placed in no time reads the rows as they stand now, and its head says so.\nA `list` reading lists rows, each opening in its `app`. The readings answer the one\nquestion the dashboard\'s owner asks, in the order they ask it \u2014 rows to act on lead where\nthe answer is rows; no order of kinds is the rule.\n\n```jsonc\n// The depot owner\'s morning question: which boxes are past their free days, whose are they, is it growing?\n{ "alias": "overview", "name": "Depot overview", "dashboard": [\n { "list": "visit", "where": { "overdue": true }, "columns": ["line"], "app": "gate", "label": "Past free days" },\n { "breakdown": "visit", "by": "line", "where": { "overdue": true }, "label": "Past free days by line" },\n { "trend": "visit", "over": "arrived_on", "where": { "overdue": true }, "label": "Past free days, by arrival" }\n] }\n```\n\n### Register\n\n```jsonc\n"register": { "columns": ["service", "release"], "filters": ["customer", "service"], "create": ["container_no", "customer"] }\n```\n\n<!-- generated:start apps-register -->\n\n#### Register\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `columns` | list of alias | no | Fields read as columns after the row\'s title \u2014 on a calendar\'s entry or a lane\'s block, its facts after its title; never what the row draws itself (its subtitle, image, status, figure, or the bound its figure\'s meter reads against), which the runtime places |\n| `filters` | list of (alias \\| [Tiered filter](#tiered-filter)) (at most 3) | no | The fields the reader narrows the rows by every day, in that order \u2014 each earns its place by that daily use, so none to 3 is normal and 3 is a cap, never a quota (a select, a member, a link, a date, a yes/no, a number as a range, or its `tiers` where the business narrows by those bands daily) \u2014 each on a desk\'s line, in one Filters sheet on a phone. Never the status (its chips); each a field the rows show \u2014 a column, or a part of the row. A threshold the business names ("large orders") is a formula yes/no here; a column\'s header sorts by a day or an amount |\n| `book` | `true` | no | With `books`: a column naming the table each row is of, and a filter by it |\n| `search` | list of alias (at least one) | no | The fields the search box matches (a link by its row\'s title); a pasted list searches each line and names the lines no row matched. Absent, every word of the row |\n| `group` | alias | no | The field the rows open grouped by, the reader\'s grouping starting there \u2014 a period\'s rows read under each period, one per person; absent, ungrouped |\n| `sort` | [Sort key](#sort-key) \\| list of [Sort key](#sort-key) (at least one) | no | The order rows open in: one key, or a list of keys most significant first, each breaking the ties of the ones before it (a select orders by its options\' order); absent, the first deadline soonest first, else the newest first |\n| `tabs` | list of [Register tab](#register-tab) (2\u20135) | no | The lists the register reads its rows as, each a tab with its count over one period the reader picks above them \u2014 the stock at its end beside the arrivals and departures during it, a report\'s sheets; each keeps the register\'s filters and search. Absent, one list. Not with a calendar, roster or lanes, which read their own window |\n| `period` | `"today"` \\| `"week"` \\| `"month"` \\| `"quarter"` \\| `"year"` \\| [Period days](#period-days) | no | With `tabs`: the period they open on \u2014 `today`, `week`, `month`, `quarter` or `year` so far, or `{ days }`, the last that many days through now (a night shift running past midnight reads two); absent, this month so far |\n| `layout` | `"table"` \\| `"cards"` \\| `"calendar"` \\| `"roster"` \\| `"lanes"` \\| `"gantt"` | no | `cards` for rows read by their picture; `calendar` for rows read by their day \u2014 a month, a week, a day or a list, an entry at its hour where its date holds one and over its span where its line holds a second date; `roster` for what each row did on each day of a week or a month (who worked which shift, which vehicle ran), or with `expect` which of a set each row holds; `lanes` for rows booked on a resource over time (an appointment in its chair, a hire on its machine); `gantt` for rows each planned over a run of days, one bar a row on one axis of time (a shipment from its sailing to its arrival, a hire from its start to its return, a task of a project) \u2014 its dates in `gantt`, its lanes the register\'s `group`; absent, a table |\n| `gantt` | [Register gantt](#register-gantt) | no | With `layout: "gantt"`: the dates and facts each row\'s bar is drawn from |\n| `roster` | alias | no | With `layout: "roster"`: the entity whose rows fill the days \u2014 one single link to this entity, and a date on its row line (`records` title or subtitle). A cell reads its row\'s status, else the first single select on its line, several rows a mark each, the cell read by the one latest in that select\'s options; a row whose line holds a second date after the first fills every day to it; a day with none stays empty, never an absence |\n| `expect` | alias | no | With `roster`: a single select of the roster\'s rows whose options are the columns in place of days \u2014 each cell the row holding that option, read by its status, an empty one added there (a checklist of papers per case); the roster then has no period |\n| `of` | alias | no | With `expect`: this entity\'s own multi-select holding the options each row expects \u2014 its other columns stand blank on that row, never added. Its options are among `expect`\'s |\n| `lanes` | alias | no | With `layout: "lanes"`: a one-link of this entity whose target\'s rows are the lanes (a chair, a machine, a room), each drawn even when empty. A row is a block from the first date on its line to the second \u2014 within one day by the hour where the first holds one, else by the day over one day, a week or a month; a free stretch between blocks adds a row there, its lane, its start and the end the stretch reaches filled |\n| `loads` | map of alias \u2192 alias | no | With `lanes`: what a lane carries against what it holds, at most 2 \u2014 a number of this entity each block adds (a weight) to a number of the lane\'s row it is held within (a payload), in one unit or two of one dimension. Each lane reads the most its blocks add up to at one time in the window \u2014 a day\'s rows together \u2014 and names by how much and when a lane runs over |\n| `where` | map of alias \u2192 (list of alias (at least one) \\| boolean) | no | Only the rows whose select holds one of these options and whose yes/no (stored, or a formula) is this value \u2014 read so on the server and never offered as a chip: the rows this app works of an entity other apps read whole (the purchases, of orders both ways). A row added here holds a select\'s one option and a stored yes/no\'s value |\n| `opens` | map of alias \u2192 (list of alias (at least one) \\| boolean \\| `"me"`) | no | The view the register opens on: each entry a narrowing on a field the rows show that stands as a chip the reader may take away (the rows still owed), `"me"` a member field holding the reader (the rows mine). One on the status replaces the chips\' opening on the open work |\n| `remove` | `false` | no | The app edits its records and offers no Delete on them (a row\'s \u22EF, its record\'s \u22EF) \u2014 a desk correcting a date of records another app makes and closes; absent, a record the app writes is deleted where it stands |\n| `create` | list of alias (at least one) \\| `false` | no | The fields asked when a row is added; absent, the title, the subtitle and figure no default fills, the natural key and every required field \u2014 each a person writes; false, rows are not added here |\n| `export` | `true` \\| list of [Register report](#register-report) (at least one) | no | The rows in view saved as the register\'s report \u2014 its title, a line per thing narrowing the rows, the readings and every row: `true`, one workbook of the register\'s columns; a list, the reports a reader picks from one export menu |\n| `readings` | list of ([Metric](#metric) \\| [Breakdown](#breakdown) \\| [Trend](#trend) \\| [Pivot](#pivot)) (at least one) | no | Readings of the register\'s own entity above its rows, over the rows in view \u2014 the search, the status and the filters narrow them, but a picture\'s own field, read as the register opens on it. A press on a breakdown\'s part, a pivot\'s cell or a figure with `where` narrows the rows by its field, as a filter does |\n\n#### Tiered filter\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `field` | alias | yes | A number \u2014 stored, or a formula or rollup reading one \u2014 in one unit for every row |\n| `tiers` | list of number (at least one) | yes | The breakpoints between its bands, ascending, in the field\'s stored unit: under the first, from each to the next, from the last up \u2014 each band worded by the field\'s own format, several picked at once |\n\n#### Sort key\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `field` | alias | yes | The field rows are ordered by |\n| `desc` | `true` | no | Latest or largest first; absent, soonest or smallest first |\n\n#### Register gantt\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `start` | alias | yes | A date of this entity each row\'s bar starts at (a sailing, a hire\'s start, a task\'s start) |\n| `end` | alias | no | The date each row\'s bar ends at, read as `start` is \u2014 two days or two moments; absent, the entity\'s first `due` |\n| `milestones` | list of alias (at least one) | no | Dates of the row marked on its bar\'s line as diamonds, each named by its label (a cut-off, a delivery, the end of free time) \u2014 never `start` or `end` |\n| `planned` | [Gantt plan](#gantt-plan) | no | The plan each row is read against, drawn as a thin bar under its own \u2014 each date read as `start` is; a missing one is the bar\'s own |\n| `progress` | alias | no | A percent of this entity (0\u2013100) filling each row\'s bar as far as the work is done; absent, the bar wears its status\'s tone |\n| `after` | alias | no | A link of this entity to its own rows each row waits on \u2014 it starts once they end (finish-to-start), an arrow from each; a row starting before one ends is drawn in the danger tone |\n\n#### Gantt plan\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `start` | alias | no | The date the row was planned to start at |\n| `end` | alias | no | The date the row was planned to end at (a booked arrival) |\n\n#### Register tab\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `tab` | alias | yes | The tab\'s name: its address, and the key its rows stand under in a report |\n| `label` | text | yes | What the tab holds, in the reader\'s words |\n| `in` | alias | no | A date (stored, or a formula): the tab holds the rows dated within the register\'s period, from its start through its last day, or up to its last minute \u2014 the arrivals of a period |\n| `open` | [Tab open](#tab-open) | no | The tab holds the rows open at the period\'s end: begun before it, and not ended by then \u2014 what is in stock, out on hire or still owed at that moment |\n| `where` | map of alias \u2192 (list of alias (at least one) \\| boolean \\| `"me"`) | no | Only the rows whose select holds one of these options, whose yes/no is this value, or whose member field holds the reader (`"me"`) \u2014 every entry ANDed |\n| `sort` | [Sort key](#sort-key) \\| list of [Sort key](#sort-key) (at least one) | no | The order this tab\'s rows read in, as the register\'s `sort`; absent, the register\'s |\n\n#### Tab open\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `from` | alias | yes | The date a row begins on (stored, or a formula) |\n| `to` | alias | yes | The date a row ends on (stored, or a formula), empty while it is open |\n\n#### Period days\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `days` | integer | yes | How many days through today, at most 366 |\n\n#### Register report\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `label` | text | yes | The report\'s name, in the reader\'s words \u2014 its entry in the export menu and its file\'s title |\n| `description` | text | no | What the report holds, under its name in the menu |\n| `template` | alias | no | A template filled with the report \u2014 `title`, `lines`, `readings`, `at` (when it was made), `dates` (each date filter\'s `from` and `to`, by field), `period` (the tabs\' `from` and `to`), `per` (the value picked), every row in view (at most 20,000) under the entity\'s alias, and each tab\'s under its name: an html one made a PDF, an excel one a workbook; absent, the workbook of the register\'s columns, a sheet per tab |\n| `filename` | text | no | The file\'s name, in the template grammar over one value each \u2014 `title`, `at`, `dates.<field>.from` or `.to` of a date filter, `period.from` or `.to` with tabs, `per` with a `per`, `lines \\| lookup:<n>` \u2014 `Stock {{per}} {{period.to \\| format:"dd.MM.yyyy"}}`; absent, the template\'s name, or the report\'s title |\n| `where` | map of alias \u2192 (list of alias (at least one) \\| boolean \\| `"me"`) | no | The rows the report is of, set on the register as the chips they are before its file is made \u2014 the screen shows what the file holds |\n| `per` | alias | no | A select, a member or a one-link the reader picks one value of from the menu \u2014 the rows narrowed to it, as its chip, and one file of that value |\n| `books` | list of alias (at least one) | no | The rows of these books only \u2014 each the app\'s `entity` or one of its `books`; absent, every book\'s |\n\n<!-- generated:end apps-register -->\n\n<!-- generated:start rules-register -->\n\n#### Rules the check enforces\n\n| Rule | |\n|---|---|\n| `register.export-report` | A register\'s reports each have their own label; `per` picks one option, person or one-link row \u2014 never of the status, the folder or a field its `where` fixes. |\n| `register.export-template` | A template an export fills reads at its root only `title`, `lines`, `readings`, `at`, `dates` and its rows under the register entity\'s alias \u2014 an entity named none of those \u2014 and is the document of no act. |\n| `register.column-drawn` | A column never names what the row draws itself \u2014 its title, subtitle, image, status, figure, or the bound its figure\'s meter reads against \u2014 and names a field once. |\n| `register.filter` | A filter names a select, a member, a link, a date, a yes/no or a number of the register\'s own rows \u2014 a lookup through one-row links as the field it reads \u2014 never the status (its chips), nor one `where` fixes. |\n| `register.filter-split` | *Noted, never refused:* A filter on a field a breakdown or pivot in the band splits the rows by \u2014 a press on its part narrows by it too; a member\'s filter, which narrows to the reader\'s own rows, is never noted. |\n| `register.filter-unshown` | A filter, a narrowing the register `opens` on, and a press in its band (a breakdown\'s part, a pivot\'s cell, a figure\'s `where`) name a field its rows show \u2014 a column; the row\'s title, the line under it, its picture, status or figure, a meter\'s bound, or the unit or currency a drawn figure is read in; a deadline; a warning its line says; the group or lane it stands under. A narrowing to the reader (`"me"`) needs none. |\n| `register.tiers` | `tiers` band a number read in one unit on every row; a figure read in each row\'s own unit or currency is named bare. |\n| `register.search` | `search` names text, a code, or a link \u2014 matched by its row\'s title. |\n| `register.sort` | Each `sort` key names a field of the register\'s rows that holds an order \u2014 never files \u2014 and no field twice. |\n| `register.tabs` | A register\'s `tabs` each name a tab of their own, never a key a report already holds; a tab\'s `in` and `open` dates are dates of its rows, `open`\'s two different; its `where` and `sort` hold as the register\'s do, and never narrow by the status, which its chips split. Only a table or cards reads tabs, and a band reading\'s `tab` names one of them. |\n| `register.group` | `group` names one value per row \u2014 a single select, a one-row link, one member, a date, a yes/no or a text, a lookup through one-row links as the field it reads \u2014 never the status (its chips); only a table groups, and a gantt into its lanes; grouping by the title needs a subtitle to lead each row. |\n| `register.readings-own` | A register\'s `readings` read its own entity; another entity\'s stand on a dashboard. |\n| `register.readings-restate` | A register\'s metric never sums the row\'s figure or counts the rows without a `where`: the summary line totals the figure and the status chips count the rows. |\n| `register.where-opens` | `where` and `opens` hold the register\'s own selects and yes/nos, and `opens` a member field holding the reader (`"me"`); a field `where` fixes is never a filter nor opened otherwise, and `opens` stays among the options `where` keeps. |\n| `register.roster` | `layout: "roster"` and `roster` go together: the roster\'s entity has one single link to this one and a date on its line, a roster draws no `columns`, `expect` needs `roster` and `of` needs `expect`. |\n| `register.roster-expect` | A roster\'s `expect` is a single select of its rows, and `of` this entity\'s multi-select whose every option is one of `expect`\'s. |\n| `register.gantt` | `layout: "gantt"` and `gantt` go together: `start` and `end` (absent, the entity\'s first `due`) are two different dates of the row, both days or both moments; each milestone another date of it, each planned date read as `start` is, `progress` a percent and `after` a link to rows of the same entity. |\n| `register.lanes` | `layout: "lanes"` and `lanes` go together: `lanes` is a one-row link to an entity with a `records` entry, the rows name a date on their line, a block\'s two ends are read alike (two days or two moments), and `loads` needs `lanes`. |\n| `register.loads` | `loads` names one to 2 numbers a block adds \u2014 never a percent \u2014 each against a number of the lane\'s row, in one unit or two of one dimension. |\n| `register.band-pivot` | *Noted, never refused:* A pivot in a register\'s band: its grid reads on a dashboard, and the band leads with a `metric`. |\n| `register.band-headline` | *Noted, never refused:* A register over rows that move (a status or a deadline) stating no `readings`: the band leads with the job\'s headline number. |\n| `register.remove` | A register\'s `remove: false` hides the Delete its records would offer, so it stands only where the app writes its records. |\n\n<!-- generated:end rules-register -->\n\nThe row is the entity\'s `records` entry: its picture, title and the line under it,\nthen the status, the columns, and the figure at the right; the title, the status and a\ncolumn of days or amounts sort the rows by their header. The status is always the chips, with\ncounts, opening on the rows not closed (every row on a calendar or a lanes board, whose\nwindow of time narrows them) \u2014 never one of `filters`. `filters` are the ones a reader\nnarrows by every day \u2014 none to three, never a quota \u2014 in order of use, each a field the\nrows show (a column, a part of the row), each on the line\nwith the search and the chips, the grouping one\nchip at its end; a member filter offers "mine". A date filters by a range of days \u2014 of\nminutes on a `datetime` field, its end not held, so back-to-back shifts read each row\nonce \u2014 and can keep the rows holding no date too (in the yard at a moment: in before it,\nout after it or not yet). A number filters by a range typed in its\nunit or currency \u2014 a figure read in each row\'s own, in the one picked beside it \u2014 with a\nslider over a percent or a figure against a constant `limits`; `{ "field", "tiers" }`\noffers the bands its breakpoints cut in place of a range (`[1000, 5000]` on a weight in\nkg: under 1.000 kg, 1.000\u20135.000 kg, 5.000 kg and over), refused on a figure read in each\nrow\'s own unit. A threshold with a name is a formula yes/no, never a range. A\ncolumn the row already draws is refused. `sort` is one key or a list of keys, most\nsignificant first, each breaking the ties of the ones before it \u2014 a shipping line in its\nselect\'s option order, then the arrival day soonest first:\n`[{ "field": "shipping_line" }, { "field": "arrived_on" }]`; a header\'s press leads them.\nAbsent `sort`, rows open soonest deadline first where the row has a `due`, else newest first. `search` names the fields\nthe search box matches \u2014 a link by its row\'s title \u2014 and a pasted list searches each\nline, naming the lines no row matched; absent, it matches every word of the row.\n`export` saves the register as its report \u2014 the title, a line per chip, the search and\nthe moment it was exported, the readings, and every row in view, at most 20,000 of them.\n`export: true` saves it as a workbook, the readings on a sheet before the rows. A list\nstates the reports the business sends \u2014 one a button, several one menu \u2014 each a `label`,\na `description`, and a `template` filled with `title`, `lines`, `readings`, `at` (the\nmoment it was made), `dates` (each date filter\'s `from` and `to` by field, a day or a\nmoment as the filter bounds the rows) and the rows under the entity\'s alias (an html one\nmade a PDF, an `excel` one a workbook; absent, the workbook). A report\'s `filename` names\nits file in the template grammar over the same keys but the rows \u2014\n`"Stock {{dates.arrived.to | format:\\"dd.MM.yyyy\\"}}"`; absent, the template\'s name, or\nthe label. A report of some rows (`where`), or of one value the reader picks (`per`: one\nline\'s file), first narrows the register as its chips, so the screen shows what the file holds.\n`readings` read the register\'s own rows above them: the rows in view, so the search,\nthe status and the filters narrow each one, but a picture\'s own field, which it reads as\nthe register opens on it. They are optional \u2014 state them where a\npicture answers something the rows cannot \u2014 and stand under the title in one compact\nband: lead with the job\'s headline number (a `metric`), then at most the mix (a\n`breakdown`) and the movement (a `trend`) \u2014 a `pivot` stands under the band as a whole table, every line and its totals. A picture\nstands only where it splits the rows in view into two parts or more, one of them\nholding two rows or more; a split whose rows\nall fall in one part, and a lone figure with no picture beside it, are said on the\nsummary line instead, so a band stands only holding a picture or two figures. The summary line already totals the\nfigure over the rows in view and the status chips count them, so a `metric` summing the\nfigure, or counting the rows of an entity with a status, is refused unless its `where`\nnarrows it.\n\n`where` keeps the rows the app works, read so on the server and never a chip: a\npurchase desk over orders of both directions states `"where": { "direction":\n["purchase"] }`, and a row added there is written holding the one option (a select kept\nat several is asked among them). `opens` is the view the register opens on, each entry a\nchip the reader may take away: a cashier opens on `"opens": { "owes": true }`. Both take\na select\'s options or a yes/no\'s value, stored or a formula; `opens` on the status is\nthe chips\' opening choice. `opens` also takes `"me"` on a member field \u2014 `"opens": {\n"assignee": "me" }` opens on the rows assigned to whoever reads, the server reading the\nreader, never the page; `where` refuses it, since it fixes the rows for every reader\n(who may read a row at all is the entity\'s `read_scope`).\n\n`layout: "calendar"` places each row on its day \u2014 a month, a week, a day or a list (a\nphone reads the list or a day): at its hour where the date holds one, over its span\nwhere the row\'s line holds a second date after it, its line\'s other words and the\nregister\'s `columns` after its title. The calendar reads the window in view, and its\ncount and the status chips count that window, opening on every row.\n\n`layout: "lanes"` with `lanes` naming a one-link draws each row of the linked entity as\na lane (a chair, a machine, a room), empty ones too, and each register row as a block\nfrom the first date on its line to the second: over the hours of one day where the first\nholds its hour, else over the days of one day, a week or a month. A block reads the row\'s\nname and the first of its `columns`; a free stretch between blocks adds a row there, its\nlane and its start set, and its end where the next block bounds it. A phone lists each\nlane\'s blocks and free stretches in clock order. Rows naming no lane stand first, in a lane\nof their own; where the app writes the link, each block moves to another lane from its menu.\nInside a folder (`scope`), the lanes are the ones whose own one-link names that folder \u2014 a\nbranch\'s rooms, never every branch\'s. A span\'s end holds its day unless `records` names it\nthe day the row `frees`.\n\n`loads` names what a lane carries: `{ "weight": "payload", "volume": "box" }` sums each\nblock\'s number and reads it against the lane row\'s own, at most two. A lane holds its\nblocks while they stand, so it reads the most they add up to at one time in the window \u2014\na day\'s rows together \u2014 named, past its bound, by how much and on which day or at which\nhour. The two are in one unit, or two of one dimension (kg against t); a block then reads\nwhat it adds, and the move menu reads each lane as it would stand with the block on it.\nA bound a single row must keep (no parcel heavier than its van) is a `write_rules` `max`\nthrough the link, which refuses the move.\n\n`layout: "gantt"` with `gantt` draws each register row as one bar on one axis of time:\n`{ "start": "etd", "end": "eta", "milestones": ["cut_off"], "planned": { "end": "booked_eta" },\n"progress": "done", "after": "waits_on" }`. `end` defaults to the entity\'s first `due`, and an\nentity with neither is refused; `start` and `end` are two dates of the row, both days or both\nmoments, and each planned date is read as `start` is. `progress` is a percent (0\u2013100);\n`after` is a link to rows of the same entity, one or several, each ending before the row\nstarts. The bars stand in the lanes of the register\'s `group`. Where the app writes the\nrow and a person writes a bar\'s date, the bar is dragged through the row\'s save, which then\nwrites those two dates though no section places them; a computed date, one an act sets, or\nan app with `writes: "children"` drags nothing.\n\n`layout: "roster"` draws the register\'s rows down the side and the days of a week or a\nmonth across; `roster` names the entity whose rows fill the days \u2014 each stands on one\nregister row by its single link to it, and on the first date of its line (a shift on a\nperson\'s day, a run on a vehicle\'s), on every day through the second date where its line\nholds one. A cell reads its row\'s status, else the first single select on its line (a\nshift\'s kind), several rows their count; a week\'s cells say their words and one more\nfact of the line, a month\'s keep their marks. A day holding none stays empty: nothing\nwas due, which is never an absence. A filled cell opens its row; an empty one adds a row\nthere, the register row and the day set, asking what any add of it asks \u2014 unless no\nperson writes those rows (`writes: false`). With `expect` (a single select of those\nrows) the columns are its options in place of days \u2014 a checklist of papers \u2014 and `of`\n(this entity\'s multi-select) names the ones each row expects: a gap among them reads as\none and adds its row, the others stand blank, and each row reads what it holds of them,\n`2/3`. A roster states no `columns` and needs no `readings`: its legend counts each\nstate in view.\n\n```jsonc\n"register": { "layout": "roster", "roster": "run", "filters": ["operator"] }\n```\n\nAn app whose job reads its rows otherwise than the entity\'s `records` restates them in\nits own `row` \u2014 the cashier\'s figure is what is still owed, its line the service and the\nday. The subtitle, figure and image it states replace the entity\'s wherever that app\ndraws a row of it; every other app reads `records`, and the title is the entity\'s.\n\n### Readings\n\nA reading is one number, or one picture of numbers, over rows. Where it stands decides\nwhich rows: a dashboard reads every row, a register\'s `readings` the rows in view, a\nrecord\'s reading block the child rows under the record. The picture is the runtime\'s,\nchosen from what the reading means \u2014 no key picks a chart.\n\n```jsonc\n{ "metric": "visit", "value": "fees", "where": { "paid": false }, "over": "arrived_on", "label": "Unpaid" }\n{ "breakdown": "visit", "by": "line" }\n{ "trend": "visit", "over": "arrived_on", "value": "fees" }\n{ "pivot": "visit", "rows": "size", "columns": "line" }\n{ "list": "visit", "where": { "stage": ["in_yard"] }, "columns": ["line"], "app": "gate", "label": "In the yard" }\n{ "list": "task", "where": { "assignee": "me" }, "label": "My tasks" }\n```\n\nA reading\'s `where` keeps rows by a select\'s options, a yes/no\'s value, or `"me"` on a\nmember field \u2014 the rows that name whoever reads, read so on the server.\n\n<!-- generated:start apps-readings -->\n\n#### Metric\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `metric` | alias | yes | The entity whose rows are counted, or summed, into one number |\n| `value` | alias | no | A number field summed over the rows; absent, the rows are counted |\n| `where` | map of alias \u2192 (list of alias (at least one) \\| boolean \\| `"me"`) | no | Only the rows whose select holds one of these options, whose yes/no (stored, or a formula) is this value \u2014 each its own, or looked up through one-row links \u2014, or whose member field holds the reader (`"me"`) \u2014 every entry ANDed |\n| `tab` | alias | no | On a register\'s band, the tab whose rows it reads, whichever tab is open; absent, the open tab\'s |\n| `over` | alias | no | The date that places a row in time \u2014 its own (stored, or a formula), or its parent\'s through a lookup over a one-link: a dashboard\'s period windows the rows by it (a reading without one reads the rows as they stand now), and a metric reads its recent periods beside the number |\n| `target` | number | no | The number it is read against, where no `limits` bounds its value (a bounded value is read against the sum of its bound) |\n| `better` | `"up"` \\| `"down"` | no | Which way its change over the recent periods `over` reads is good; absent, a sum\'s rise and a count kept by `where` falling |\n| `label` | text | yes | What the reading is, in the reader\'s words |\n\n#### Breakdown\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `breakdown` | alias | yes | The entity whose rows are split into parts |\n| `by` | alias | yes | The single select, one link, member or yes/no (stored, a formula, or a lookup of one through one-row links) each row is counted under |\n| `value` | alias | no | A number field summed over the rows; absent, the rows are counted |\n| `where` | map of alias \u2192 (list of alias (at least one) \\| boolean \\| `"me"`) | no | Only the rows whose select holds one of these options, whose yes/no (stored, or a formula) is this value \u2014 each its own, or looked up through one-row links \u2014, or whose member field holds the reader (`"me"`) \u2014 every entry ANDed |\n| `tab` | alias | no | On a register\'s band, the tab whose rows it reads, whichever tab is open; absent, the open tab\'s |\n| `over` | alias | no | The date that places a row in time \u2014 its own (stored, or a formula), or its parent\'s through a lookup over a one-link: a dashboard\'s period windows the rows by it (a reading without one reads the rows as they stand now), and a metric reads its recent periods beside the number |\n| `label` | text | no | What the reading is, in the reader\'s words; absent, the `by` field\'s label |\n\n#### Trend\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `trend` | alias | yes | The entity whose rows are counted, or summed, per period |\n| `over` | alias | yes | The date each row falls on \u2014 its own (stored, or a formula), or its parent\'s through a lookup over a one-link |\n| `ahead` | `true` | no | Counts forward: the current period first, then the ones after it, over the rows dated today or later (arrivals by their ETA); absent, the periods up to today |\n| `value` | alias | no | A number field summed over the rows; absent, the rows are counted |\n| `where` | map of alias \u2192 (list of alias (at least one) \\| boolean \\| `"me"`) | no | Only the rows whose select holds one of these options, whose yes/no (stored, or a formula) is this value \u2014 each its own, or looked up through one-row links \u2014, or whose member field holds the reader (`"me"`) \u2014 every entry ANDed |\n| `tab` | alias | no | On a register\'s band, the tab whose rows it reads, whichever tab is open; absent, the open tab\'s |\n| `label` | text | no | What the reading is, in the reader\'s words; absent, the value\'s label, else the entity\'s |\n\n#### Pivot\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `pivot` | alias | yes | The entity whose rows are counted, or summed, in a grid |\n| `rows` | alias | yes | The single select, one link, member or yes/no (stored, a formula, or a lookup of one through one-row links) down the side |\n| `columns` | alias | yes | The single select, one link, member or yes/no (stored, a formula, or a lookup of one through one-row links) across the top |\n| `value` | alias | no | A number field summed over the rows; absent, the rows are counted |\n| `where` | map of alias \u2192 (list of alias (at least one) \\| boolean \\| `"me"`) | no | Only the rows whose select holds one of these options, whose yes/no (stored, or a formula) is this value \u2014 each its own, or looked up through one-row links \u2014, or whose member field holds the reader (`"me"`) \u2014 every entry ANDed |\n| `tab` | alias | no | On a register\'s band, the tab whose rows it reads, whichever tab is open; absent, the open tab\'s |\n| `over` | alias | no | The date that places a row in time \u2014 its own (stored, or a formula), or its parent\'s through a lookup over a one-link: a dashboard\'s period windows the rows by it (a reading without one reads the rows as they stand now), and a metric reads its recent periods beside the number |\n| `label` | text | no | What the reading is, in the reader\'s words; absent, the two fields\' labels |\n\n#### List\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `list` | alias | yes | The entity whose rows are listed, each in its `records` anatomy |\n| `where` | map of alias \u2192 (list of alias (at least one) \\| boolean \\| `"me"`) | no | Only the rows whose select holds one of these options, whose yes/no (stored, or a formula) is this value \u2014 each its own, or looked up through one-row links \u2014, or whose member field holds the reader (`"me"`) \u2014 every entry ANDed |\n| `columns` | list of alias | no | Fields read after each row\'s title |\n| `app` | alias | no | An app of this model over the same entity that a row opens in |\n| `label` | text | yes | What the reading is, in the reader\'s words |\n\n<!-- generated:end apps-readings -->\n\n<!-- generated:start rules-reading -->\n\n#### Rules the check enforces\n\n| Rule | |\n|---|---|\n| `reading.value` | A reading sums a number, never a percent \u2014 percents do not add up. |\n| `reading.split` | `by`, `rows` and `columns` split by one value each row holds \u2014 a single select, a one-row link, one member or a yes/no, stored or a formula, a lookup through one-row links as the field it reads \u2014 and a pivot crosses two different fields. |\n| `reading.over` | `over` is a date: the row\'s own, stored or a formula, or its parent\'s through a lookup over a one-row link. |\n| `reading.where` | A reading\'s `where` keeps rows by a select\'s options, a yes/no\'s `true` or `false`, or a member field holding the reader (`"me"`). |\n| `reading.target` | `target` reads a value nothing bounds \u2014 a value `limits` bounds reads against the sum of its bound. |\n| `reading.better` | `better` needs `over`: a change is read over the periods its date places the rows in. |\n| `reading.list-app` | A list\'s `app` is a register app of this model over the list\'s own entity. |\n\n<!-- generated:end rules-reading -->\n\nA reading counts its rows, or sums the number `value` names \u2014 never a percent. `by`,\n`rows` and `columns` split the rows by one value each: a single select, a one-row link,\none member, or a yes/no \u2014 stored or a formula, its parts read as its label and the\nothers. `over` is a date, the row\'s own (stored or a formula) or its parent\'s through a\nlookup over a one-link: a trend\'s periods, a metric\'s recent periods beside its number,\nand what a dashboard\'s period windows. `ahead: true` reads a trend forward from the\ncurrent period over the rows still to come. `where` keeps rows whose select holds one\nof the options, or whose yes/no is the value, every entry ANDed \u2014 a band leads with the\njob\'s exception as a formula\'s yes/no (what is late, what is short), never a stage the\nstatus chips already count. A metric summing a value `limits` bounds reads against the\nsum of its bound (`10 / 14`); `target` reads one against a number nothing bounds. A\nmetric\'s change is green where it moves the good way: a sum\'s rise, a count kept by\n`where` falling; `better` states the other way where that reads wrong \u2014 money going out\n(`"better": "down"`). A register reads only its own entity; a `list` stands only on a\ndashboard.\n\n### Record\n\n```jsonc\n"record": {\n "door": "page",\n "sections": [\n { "title": "Details", "fields": ["size", "line", "built_on"] },\n { "title": "Gate in", "at": ["arriving", "in_yard"], "fields": ["arrived_on", "truck"],\n "blocks": [{ "rows": "fee", "where": { "leg": ["in"] }, "expect": "kind", "columns": ["amount", "paid_by"] }],\n "acts": ["gate_in_paper"] },\n { "title": "Sell to a buyer", "description": "Moves the container to the buyer\'s stock once it has left.", "acts": ["sell"] }\n ]\n}\n```\n\n<!-- generated:start apps-record -->\n\n#### Record page\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `door` | `"page"` \\| `"drawer"` \\| `"beside"` | no | A page for work read at length; a drawer for rows worked one after another from the register; `beside` for rows worked one after another while the list stays in view \u2014 the register\'s list and the open record side by side, over a table register alone. Each stacks its sections, and from three sections on navigates them \u2014 a page by a rail, a drawer by tabs; absent, a page where a section has `at` or is a `page`, the record has more than three sections or one stands beside the rest (`side`), else a drawer |\n| `sections` | list of [Section](#section) (at least one) | no | The record in the order its work reaches it \u2014 each section its fields, then its blocks, then its acts. Absent, one section of every field a person writes that no other place shows |\n| `comments` | `true` | no | The record\'s comment thread beside the sections \u2014 who wrote what and when, with a composer \u2014 stated only where the job\'s people discuss a record or the owner asks for one. Absent, it is drawn nowhere |\n| `history` | `true` | no | The record\'s status history beside the sections \u2014 each move, when and who \u2014 stated only where the owner asks for an audit trail of the moves: the rail already marks the steps passed, and the entity\'s `status.history` is written either way. Absent, it is drawn nowhere |\n\n#### Section\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `title` | text | yes | What the section is, in the reader\'s words \u2014 its heading, and its name on the record\'s rail or tab |\n| `description` | text | no | One sentence under the title: what the section is for, or what its act does |\n| `at` | list of alias (at least one) | no | Options of the record\'s status (or its milestones) during which this section is the work now \u2014 a step of the path the record moves through: its rail item reads now while the status holds one and this app has work there, done once the record\'s history says it went through one \u2014 or it is a step of the path before where that history opens (with none, where the record stands) that no act moves the work past \u2014 and it stands before the section that is the work now, and waiting otherwise; a section every option of which ends the work, past the path\'s end or entered by its own act, is an exit: no item and nothing drawn until the record stands in it, then set apart after the steps and never one; A page opened from the register opens at it. It never locks what the section holds: its fields are written as the write rules and checks allow at any stage. An act it names is offered during these alone: its `when` holds the status to them, or on milestones it stamps the step after one of them \u2014 a detour and an exit are sections of their own, a detour stated before the step it returns to |\n| `fields` | list of alias (at least one) | no | The record\'s own fields in this section, in the order they are read \u2014 never one the header draws: the row\'s title, subtitle, image, status or figure |\n| `blocks` | list of [block](#blocks) (at least one) | no | What the section holds besides its own fields, under them, in order |\n| `acts` | list of alias (at least one) | no | Acts of this app drawn under the section\'s fields, their asks as more of them and what each lacks said above its button; the header draws no act, so every act of the record itself is named by one section \u2014 an act of a child (`of`) stands on the child\'s row and is never named here. An act moving the status, offered at ONE step (the stage its `when` holds), stands at that step\'s foot when it moves the work forward \u2014 to a later option, an exit too \u2014 or back to a step the work passes anyway; one offered at several steps, or moving back into a detour, stands in the section it enters, which draws it while the work waits \u2014 an exit\'s in the header\'s \u22EF, since an exit stands only once the record is in it. A correction \u2014 `danger` and not one of its step\'s outcomes (no other status move offered at the same moment) \u2014 waits in the section heading\'s \u22EF, and one no section names in the header\'s \u22EF; every other act is named by a section |\n| `when` | map of alias \u2192 (list of alias (at least one) \\| boolean) | no | Shown only while each named select of the record holds one of these options, and each named yes/no (stored, or a formula) is this value |\n| `side` | `true` | no | The section the rest are worked from, drawn in a pane beside them rather than in their scroll \u2014 the photos looked at, the pool picked from, the paper typed off; at most one on a record, which opens on a page door. It holds what any section holds |\n| `page` | `true` | no | A page of its own, off the record\'s scroll: its own heading and a way back, reached from the record\'s rail after the scroll\'s sections, or from a link at its place where the record has no rail \u2014 for what makes the record worse stacked in it: a long list of related rows, a workspace of its own. Only on a page door, never a step (`at`) |\n\n<!-- generated:end apps-record -->\n\n<!-- generated:start rules-record -->\n\n#### Rules the check enforces\n\n| Rule | |\n|---|---|\n| `record.placed-once` | A field, a block and an act each stand once on a record: the header draws the title, subtitle, image, status (or milestones), figure and folder, and a field\'s one other place is a section\'s `fields`, a files block or a text block \u2014 save the image, which one files block may draw again, whole. |\n| `record.foot-total` | A rows block\'s foot totals the child\'s figure and each summed column over the rows it lists; the record\'s rollup summing that field over those rows (the same `where`) is that foot, never also one of a section\'s `fields`. |\n| `record.fact-restates` | A section\'s field never looks up what its link already reads where it stands: the linked row\'s title and line beside the link, the figure or bound its meter reads on the header\'s line. |\n| `record.section-holds` | A section holds fields, blocks or acts. |\n| `record.at` | `at` names options of the record\'s stored status, or its milestones; a record with no status has no steps. |\n| `record.page` | A section is a `page` only on a page door \u2014 never a drawer\'s or one beside the list \u2014 and never a step (`at`), its title naming an address no other page shares (a letter or a digit at least), and never every section. |\n| `record.side` | At most one section stands beside the rest (`side`), on a page door, and never also a page of its own. |\n| `record.beside` | A `door: "beside"` record opens beside a table register \u2014 never a board, cards or a calendar, which draw no list to stand beside. |\n| `record.history` | `record.history` draws a status history the entity records (`status.history`); no block reads that history. |\n| `record.section-acts` | A section\'s `acts` name acts of the record itself: an act of a child (`of`) stands on the child\'s row, and one over the register\'s rows (`on`) on the register. |\n| `record.act-in-section` | Every act of the record stands in the `acts` of the section whose work it is; only a correction no section owns \u2014 `danger`, and no other status move offered at the same moment \u2014 waits in the header\'s \u22EF. |\n| `record.move-home` | A status move offered at one step stands at that step\'s foot when it moves the work forward, to an exit, or back to a step it passes anyway; one offered at several steps, or moving back into a detour, stands in the section `at` the status it enters. |\n| `record.act-staged` | An act at a staged section\'s foot is offered only during that section\'s steps: its `when` holds the status to options the section names \u2014 on milestones, it stamps the step after one of them. |\n| `record.act-hidden` | An act is never offered only while its section\'s `when` hides the section. |\n\n<!-- generated:end rules-record -->\n\nThe record is its `sections`, stacked in the order the work reaches them. A section is\nits fields, the acts under them, then its blocks \u2014 the acts at its foot instead where one\nreads what a block holds or none of its fields \u2014 and holds at least one of them. From\nthree sections on, the record is navigated by their titles: a page by a rail that jumps\nto each (a strip of the same names on a phone), a drawer by tabs, the companion the last;\nwith fewer, the sections just stack. `description` is one sentence under the title.\n`at` names the status options (or the milestones) during which the section is the work\nnow \u2014 only on a record that truly moves through those stages: the rail marks it done,\nnow or waiting (an item of no `at` is its title alone), and the section stands even holding\nnothing yet \u2014 it never hides a field, locks it or gates an act, and a record opens at its\nhead. `page: true` makes a section a page of its own under the\nrecord\'s, off its scroll, with a way back \u2014 rarely, for a long related list or a workspace\nof its own that would make the record worse stacked; never on a drawer or a step. The\nheader draws no act: every act on the record is named by the section whose work it is, and one no section names is refused, naming its likely\nsection \u2014 only a correction no section owns waits in the header\'s \u22EF beside Delete. An act\nmoving the status and offered at ONE step (its `when`) stands at that step\'s foot when it\nmoves the work forward \u2014 to the next step, or to an exit later in the status\'s options\n(Reject beside Approve) \u2014 or back to a step the work passes anyway. One offered at SEVERAL\nsteps, or moving back into a detour, is named by the section it enters: a detour draws it and its\ngaps alone while it waits. A `danger` act is a correction \u2014 waiting in the \u22EF of what it acts\non: its section\'s heading, its row, the header\'s where no section names it \u2014 unless it moves\nthe status while another status move is offered at the same moment; then it is one of that\nstep\'s outcomes, a button. Cancel offered at Held and at Confirmed, beside Confirm, is an\noutcome into an exit: state `{ "title": "Cancelled", "at": ["cancelled"], "fields": ["reason"],\n"acts": ["cancel"] }`. An exit stands only once the record is in it, its reason there and its\nrail item apart from the path; until then Cancel waits in the header\'s \u22EF. `comments: true`\nputs the record\'s comment thread beside the sections, and `history: true` its status history \u2014\nthe history never as a section. Both are opt-in: state them only where the job\'s people\ndiscuss a record, or the owner asks for an audit trail of its moves.\n\nA field, a block and an act is each placed ONCE on a record \u2014 the header, one section;\na second place is refused. The header draws the entity\'s `records` title, subtitle,\nimage, status (or milestones) and figure, so none of them is named again in a section\'s\n`fields` or the register\'s `columns` \u2014 save the image, which one `files` block may draw\nagain, whole at reading size (an incident\'s photo, a scan), the header keeping its mark. Refused too: a section holding nothing, an `at`\non a record with no status, and a section\'s act that is a child\'s or runs over the\nregister\'s rows.\nAbsent `sections`, one section holds every field a person writes that the header does\nnot show. An editable field rests as its control and saves as it changes; everything\nelse is plain text. A section or a block with `when` is shown only while each named\nselect of the record holds one of its options. The one layout serves every entity: a\ncustomer or a product is sections without `at`.\n\n### Blocks\n\n```jsonc\n"blocks": [\n { "rows": "fee", "columns": ["kind", "amount"] },\n { "rows": "fee", "where": { "leg": ["out"] }, "columns": ["kind", "amount"], "under": "invoice" },\n { "agenda": "booking", "columns": ["guide"], "start": "arrival" },\n { "timeline": "note" },\n { "files": ["photos", "papers"] },\n { "text": "remarks", "when": { "stage": ["held"] } },\n { "metric": "fee", "value": "amount", "label": "Fees" },\n { "breakdown": "fee", "by": "kind" },\n { "trend": "fee", "over": "charged_on", "value": "amount" },\n { "pivot": "fee", "rows": "kind", "columns": "method" }\n]\n```\n\n<!-- generated:start apps-blocks -->\n\n#### Rows block\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `rows` | alias | yes | A child entity whose rows belong to this record |\n| `via` | alias | no | The child\'s link to this record, where it has more than one |\n| `title` | text | no | The block\'s heading; absent, the child entity\'s or the field\'s own label |\n| `columns` | list of alias | no | The child\'s fields read as columns after its title \u2014 never what its row draws (subtitle, image, status, figure, or the bound its figure\'s meter reads against), though an `expect` block\'s figure is a column, filled in place. |\n| `create` | list of alias (at least one) \\| `false` | no | The child\'s fields asked when a row is added here; absent, its title, its picture, the subtitle and figure no default fills, the block\'s columns, its natural key and every required field \u2014 each a person writes; false, rows are not added here |\n| `where` | map of alias \u2192 list of alias (at least one) | no | Only the child rows whose single select holds one of these options \u2014 every entry ANDed, read so on the server; a row added here holds them (one leg\'s fees, of a record holding both legs\'). Each select is one the child requires, so no row stands in no block |\n| `expect` | alias | no | The child\'s title when the record holds one row per value of it \u2014 a single select (one per option, in order) or a one-row link to a catalog (one per row the link may point at): a value not yet filled reads as its own empty line, and no value holds two rows. The block\'s `columns` name at least one field a person fills in place |\n| `of` | alias | no | With `expect` on a select: the record\'s own multi-select holding the options this record expects a line for \u2014 the options it holds, in order, rather than every option. Its options are among the title\'s |\n| `under` | alias | no | The child\'s one-link to a document that covers its rows (an invoice over its fee lines), whose many-link points back and which links to this record: each document stands as a heading over the lines it covers; a filled line no document covers says so on its own row with a make for one document over it, and two or more waiting take a combined make in the block\'s heading. Never also a rows block of the document |\n| `filters` | list of (alias \\| [Tiered filter](#tiered-filter)) (at most 3) | no | The child\'s fields the reader narrows the block\'s rows by, as a register\'s `filters` \u2014 none to 3, each a field its rows show (a column, or a part of the row), its status among them where the business narrows by it \u2014 never on a task list, whose status chips narrow it |\n| `search` | list of alias (at least one) | no | The child\'s fields the block\'s search box matches (a link by its row\'s title), a pasted list line by line; absent, the block has no search box |\n| `group` | alias | no | The child\'s field the block\'s rows open grouped by, as a register\'s `group` \u2014 the reader\'s grouping starting there; absent, ungrouped |\n| `sort` | [Sort key](#sort-key) \\| list of [Sort key](#sort-key) (at least one) | no | The order the block\'s rows open in, as a register\'s `sort` \u2014 within each group where the block groups them; absent, the child\'s first deadline soonest first, else the newest first |\n| `edits` | list of alias (1\u20132) | no | The child\'s fields each line holds as its control, written where the line stands (a quote line\'s sell price) \u2014 a number, a date, words or one option the row\'s save writes, each a column or the figure, at most 2; every other value reads, and the row opens to write it |\n| `remove` | `false` | no | This list offers no Delete on its rows (its open row and \u22EF); they are deleted where another list of them or their own record offers it \u2014 e.g. lines that are a selection of rows kept elsewhere |\n| `when` | map of alias \u2192 (list of alias (at least one) \\| boolean) | no | Shown only while each named select of the record holds one of these options, and each named yes/no (stored, or a formula) is this value |\n\n#### Timeline block\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `timeline` | alias | yes | A child entity read as a log of dated entries, newest first, with a composer where the app adds entries, each corrected and removed where it stands as a rows block\'s row (entries planned ahead are an `agenda`) \u2014 never the record\'s status history, which `record.history` draws beside the record |\n| `via` | alias | no | The child\'s link to this record, where it has more than one |\n| `title` | text | no | The block\'s heading; absent, the child entity\'s or the field\'s own label |\n| `create` | list of alias (at least one) \\| `false` | no | The child\'s fields asked when a row is added here; absent, its title, the subtitle and figure no default fills, its natural key and every required field \u2014 each a person writes; false, rows are not added here |\n| `when` | map of alias \u2192 (list of alias (at least one) \\| boolean) | no | Shown only while each named select of the record holds one of these options, and each named yes/no (stored, or a formula) is this value |\n\n#### Agenda block\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `agenda` | alias | yes | A child entity read as planned entries on a time axis: by day, soonest first, each entry with its picture and the day that is today marked. The day is the first date on the child\'s row line (its `records` title or subtitle); within a day entries follow a datetime there, else the first single select after the date \u2014 its options in the day\'s order |\n| `via` | alias | no | The child\'s link to this record, where it has more than one |\n| `title` | text | no | The block\'s heading; absent, the child entity\'s or the field\'s own label |\n| `columns` | list of alias | no | The child\'s facts read on an entry\'s line after its title \u2014 never what its line already draws (subtitle, image, status, figure) |\n| `create` | list of alias (at least one) \\| `false` | no | The child\'s fields asked when a row is added here; absent, its title, its picture, the subtitle and figure no default fills, the block\'s columns, its natural key and every required field \u2014 each a person writes; false, rows are not added here |\n| `start` | alias | no | The record\'s date that is the first day: each day\'s heading counts from it (Day 1, Day 2); absent, the weekday and the date alone |\n| `when` | map of alias \u2192 (list of alias (at least one) \\| boolean) | no | Shown only while each named select of the record holds one of these options, and each named yes/no (stored, or a formula) is this value |\n\n#### Files block\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `files` | list of alias (at least one) | yes | Files fields of this record, read as pictures and documents |\n| `title` | text | no | The block\'s heading; absent, the child entity\'s or the field\'s own label |\n| `when` | map of alias \u2192 (list of alias (at least one) \\| boolean) | no | Shown only while each named select of the record holds one of these options, and each named yes/no (stored, or a formula) is this value |\n\n#### Text block\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `text` | alias | yes | A long text field read as prose |\n| `title` | text | no | The block\'s heading; absent, the child entity\'s or the field\'s own label |\n| `when` | map of alias \u2192 (list of alias (at least one) \\| boolean) | no | Shown only while each named select of the record holds one of these options, and each named yes/no (stored, or a formula) is this value |\n\n#### Metric block\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `metric` | alias | yes | The entity whose rows are counted, or summed, into one number |\n| `value` | alias | no | A number field summed over the rows; absent, the rows are counted |\n| `where` | map of alias \u2192 (list of alias (at least one) \\| boolean \\| `"me"`) | no | Only the rows whose select holds one of these options, whose yes/no (stored, or a formula) is this value \u2014 each its own, or looked up through one-row links \u2014, or whose member field holds the reader (`"me"`) \u2014 every entry ANDed |\n| `tab` | alias | no | On a register\'s band, the tab whose rows it reads, whichever tab is open; absent, the open tab\'s |\n| `over` | alias | no | The date that places a row in time \u2014 its own (stored, or a formula), or its parent\'s through a lookup over a one-link: a dashboard\'s period windows the rows by it (a reading without one reads the rows as they stand now), and a metric reads its recent periods beside the number |\n| `target` | number | no | The number it is read against, where no `limits` bounds its value (a bounded value is read against the sum of its bound) |\n| `better` | `"up"` \\| `"down"` | no | Which way its change over the recent periods `over` reads is good; absent, a sum\'s rise and a count kept by `where` falling |\n| `label` | text | yes | What the reading is, in the reader\'s words |\n| `via` | alias | no | The child\'s link to this record, where it has more than one |\n| `when` | map of alias \u2192 (list of alias (at least one) \\| boolean) | no | Shown only while each named select of the record holds one of these options, and each named yes/no (stored, or a formula) is this value |\n\n#### Breakdown block\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `breakdown` | alias | yes | The entity whose rows are split into parts |\n| `by` | alias | yes | The single select, one link, member or yes/no (stored, a formula, or a lookup of one through one-row links) each row is counted under |\n| `value` | alias | no | A number field summed over the rows; absent, the rows are counted |\n| `where` | map of alias \u2192 (list of alias (at least one) \\| boolean \\| `"me"`) | no | Only the rows whose select holds one of these options, whose yes/no (stored, or a formula) is this value \u2014 each its own, or looked up through one-row links \u2014, or whose member field holds the reader (`"me"`) \u2014 every entry ANDed |\n| `tab` | alias | no | On a register\'s band, the tab whose rows it reads, whichever tab is open; absent, the open tab\'s |\n| `over` | alias | no | The date that places a row in time \u2014 its own (stored, or a formula), or its parent\'s through a lookup over a one-link: a dashboard\'s period windows the rows by it (a reading without one reads the rows as they stand now), and a metric reads its recent periods beside the number |\n| `label` | text | no | What the reading is, in the reader\'s words; absent, the `by` field\'s label |\n| `via` | alias | no | The child\'s link to this record, where it has more than one |\n| `when` | map of alias \u2192 (list of alias (at least one) \\| boolean) | no | Shown only while each named select of the record holds one of these options, and each named yes/no (stored, or a formula) is this value |\n\n#### Trend block\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `trend` | alias | yes | The entity whose rows are counted, or summed, per period |\n| `over` | alias | yes | The date each row falls on \u2014 its own (stored, or a formula), or its parent\'s through a lookup over a one-link |\n| `ahead` | `true` | no | Counts forward: the current period first, then the ones after it, over the rows dated today or later (arrivals by their ETA); absent, the periods up to today |\n| `value` | alias | no | A number field summed over the rows; absent, the rows are counted |\n| `where` | map of alias \u2192 (list of alias (at least one) \\| boolean \\| `"me"`) | no | Only the rows whose select holds one of these options, whose yes/no (stored, or a formula) is this value \u2014 each its own, or looked up through one-row links \u2014, or whose member field holds the reader (`"me"`) \u2014 every entry ANDed |\n| `tab` | alias | no | On a register\'s band, the tab whose rows it reads, whichever tab is open; absent, the open tab\'s |\n| `label` | text | no | What the reading is, in the reader\'s words; absent, the value\'s label, else the entity\'s |\n| `via` | alias | no | The child\'s link to this record, where it has more than one |\n| `when` | map of alias \u2192 (list of alias (at least one) \\| boolean) | no | Shown only while each named select of the record holds one of these options, and each named yes/no (stored, or a formula) is this value |\n\n#### Pivot block\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `pivot` | alias | yes | The entity whose rows are counted, or summed, in a grid |\n| `rows` | alias | yes | The single select, one link, member or yes/no (stored, a formula, or a lookup of one through one-row links) down the side |\n| `columns` | alias | yes | The single select, one link, member or yes/no (stored, a formula, or a lookup of one through one-row links) across the top |\n| `value` | alias | no | A number field summed over the rows; absent, the rows are counted |\n| `where` | map of alias \u2192 (list of alias (at least one) \\| boolean \\| `"me"`) | no | Only the rows whose select holds one of these options, whose yes/no (stored, or a formula) is this value \u2014 each its own, or looked up through one-row links \u2014, or whose member field holds the reader (`"me"`) \u2014 every entry ANDed |\n| `tab` | alias | no | On a register\'s band, the tab whose rows it reads, whichever tab is open; absent, the open tab\'s |\n| `over` | alias | no | The date that places a row in time \u2014 its own (stored, or a formula), or its parent\'s through a lookup over a one-link: a dashboard\'s period windows the rows by it (a reading without one reads the rows as they stand now), and a metric reads its recent periods beside the number |\n| `label` | text | no | What the reading is, in the reader\'s words; absent, the two fields\' labels |\n| `via` | alias | no | The child\'s link to this record, where it has more than one |\n| `when` | map of alias \u2192 (list of alias (at least one) \\| boolean) | no | Shown only while each named select of the record holds one of these options, and each named yes/no (stored, or a formula) is this value |\n\n<!-- generated:end apps-blocks -->\n\n<!-- generated:start rules-block -->\n\n#### Rules the check enforces\n\n| Rule | |\n|---|---|\n| `block.child-link` | A block reads a child linked to the record; `via` names the link where the child links more than once. |\n| `block.column-drawn` | A child block\'s column never names what the child\'s row draws \u2014 its title, subtitle, image, status, figure, or the bound its figure\'s meter reads against \u2014 nor its link back to the record, and names a field once; an `expect` block\'s figure is its filled-in-place column. |\n| `block.title-back` | Where a child\'s title is its link back to the record a block lists it under, its first other subtitle names it there \u2014 a text, a code, a select, a link or a member, never a number or a date. |\n| `block.where` | A rows block\'s `where` names single selects the child requires, by options they hold; one held at a single option is never also a column. |\n| `block.under` | `under` names the child\'s one-row link to a document entity that pairs a many-link back and is read under this record by its one link; the lines\' columns never read the document (its heading does) nor hold long text. |\n| `block.under-once` | Documents drawn over their lines (`under`) are never listed again as a block of their own. |\n| `block.edits` | A rows block\'s `edits` name at most two of the child\'s columns or its figure that the row\'s own save writes \u2014 a number, a date, a few words or one option \u2014 never on an `expect` block, whose lines are filled in place already. |\n| `block.remove` | A rows block\'s `remove: false` hides the Delete its rows would offer, so it stands only where the app writes those rows. |\n| `block.narrow` | A rows block\'s `filters`, `search`, `group` and `sort` follow the register\'s rules over the child\'s rows, its status among them but on a task list (its chips); never on the link back to the record or a select its `where` holds at one option, never on an `expect` block, and an `under` block is grouped by its documents. |\n| `block.expect` | `expect` names the child\'s title \u2014 a single select or a one-row link a person writes, never the link back \u2014 on a child added in the block; the block\'s `columns` name at least one field a person fills in place, and its `create` takes the file where the child is pictured by one. |\n| `block.expect-fills` | An expected line is made from its key and its first filled column, so every other field the child requires has a default, a start or a `default_from`, or is read first. |\n| `block.expect-of` | A block\'s `of` needs `expect` on a select, and names the record\'s multi-select whose every option is one of the title\'s. |\n| `block.agenda` | An agenda\'s child names a date on its row line (`records` title or subtitle); `start` is a date of the record. |\n| `block.timeline` | A timeline\'s child holds a date to place its entries by. |\n| `block.field-kind` | A files block names files fields of the record, and a text block a text field. |\n| `block.read-scope` | A child read under a record whose rows a `read_scope` narrows states its own \u2014 a row rule is not inherited, save the status history\'s, which takes its record\'s. |\n| `block.agenda-note` | *Noted, never refused:* Rows each on a day and within it (at an hour, in a part of the day) listed as a table: an `agenda` draws them by day. |\n| `block.under-note` | *Noted, never refused:* Rows holding a paper that cover another block\'s lines, listed apart with no add of their own stated: `under` draws them over the lines they cover. |\n\n<!-- generated:end rules-block -->\n\nA `rows`, `agenda` or `timeline` block reads a child entity through its link to the\nrecord \u2014 `via` names it where the child links more than once. A rows block adds rows\n(asking the child\'s title, its picture, the block\'s columns and its required fields)\nand opens each in a drawer. An `agenda` draws planned entries by day, soonest first:\nthe first date on the child\'s row line is the day, a datetime there or the first single\nselect after the date orders a day, and `start` \u2014 the record\'s date \u2014 is day 1. `under`\nnames the child\'s one-link to a document covering its lines (an invoice over its fees,\nits many-link paired back, linking to the record): each document heads the lines it\ncovers, the uncovered lines first \u2014 the record never lists the documents again as a\nblock of their own. A filled line no document covers says so on its own row, where one\npress makes a document over that line alone, its figure starting at the line\'s amount;\nwhere two or more wait, the block\'s heading makes one over those ticked in its dialog.\nA timeline reads its child\'s first date as the moment and its member field as who, with\na composer where the app writes it. An agenda\'s or a timeline\'s entry with no day yet is\na plan, listed first and dated in place. `create` names the child\'s fields an add here asks \u2014\nthe link to this record is filled, never asked \u2014 and `false` adds none. `expect`\nnames the child\'s title where the record holds one row per value of it \u2014 a select\'s\noptions, or the rows a link to a catalog may point at (`options_where`): every value\nis a line, one not yet filled a quiet line whose first column makes its row \u2014 or, where\nthe child is pictured by a file a person brings (its `records` `image`), a placeholder\nnaming the paper whose upload makes the row with its file \u2014 the add\noffers only the values not yet held, and every write path refuses a second row of one\nvalue under one record. A field that starts from the catalog row (`default_from`)\nshows that value as its hint, taken with one press. `where` keeps only the child rows\nwhose single select holds one of its options \u2014 read so on the server, a row added\nthere holding them, its lines expected once per value among them (one leg\'s fees\nbeside the other\'s), and its foot totalled by the record\'s sum narrowed alike. A `metric`, `breakdown`, `trend`\nor `pivot` block is a reading (\xA7 Readings) of a child entity\'s rows under this record,\nread only \u2014 `via` names the link where the child has more than one.\n\n### Tasks\n\nAn entity whose `records` states `"task": true` \u2014 its status a stored select with `closed` \u2014 is\nwork someone finishes. Every table of its rows draws it alike \u2014 a rows block of the record it\nbelongs to, its own register, the rows filed under one of its rows (its steps) \u2014 each row led by a\nring. Ticking the ring moves the row to a closed option by the one write the app already has for\nthat move \u2014 the act of that entity setting a closed option (its first preferred), offered on an\nopen row and asking nothing, else its own save where people write the status \u2014 stamping what\nthat act stamps and appending the status history. Unticking runs the act moving a closed row\nback (its `when` holding a closed option), else the save back to the status\'s default. A move no\nwrite reaches leaves the ring disabled, never hidden, and the check notes it. The status is moved\nin place on the row by the same moves; every other field is edited where the row opens. A\nfiles column reads on a task\'s line as how many files it holds. An act `of` the child with\n`on: "rows"` completes several at once: the list\'s heading offers to select rows, and the act\nruns on those picked. A block with `expect` or `under` draws its own lines, never rings.\n\n```json\n{\n "entities": [\n {\n "alias": "project",\n "label": "Projects",\n "singular": "Project",\n "fields": [\n { "alias": "name", "label": "Name", "type": "text", "required": true },\n { "alias": "lead", "label": "Lead", "type": "select_member" }\n ]\n },\n {\n "alias": "task",\n "label": "Tasks",\n "singular": "Task",\n "fields": [\n { "alias": "title", "label": "Title", "type": "text", "required": true },\n {\n "alias": "project",\n "label": "Project",\n "type": "select_record_link",\n "target_entity": "project",\n "cardinality": "one",\n "required": true\n },\n { "alias": "assignee", "label": "Assignee", "type": "select_member" },\n {\n "alias": "status",\n "label": "Status",\n "type": "select",\n "required": true,\n "options": [\n { "alias": "todo", "label": "To do", "color": "slate" },\n { "alias": "done", "label": "Done", "color": "green" }\n ],\n "default": ["todo"]\n },\n { "alias": "due", "label": "Due", "type": "date", "format": "date" },\n { "alias": "done_on", "label": "Done on", "type": "date", "format": "date" }\n ]\n }\n ],\n "records": {\n "project": { "title": "name" },\n "task": {\n "title": "title",\n "subtitle": ["assignee"],\n "status": { "field": "status", "closed": ["done"] },\n "due": ["due"],\n "task": true,\n "starts": { "assignee": "me" }\n }\n },\n "apps": [\n {\n "alias": "projects",\n "name": "Projects",\n "entity": "project",\n "record": {\n "sections": [{ "title": "Work", "fields": ["lead"], "blocks": [{ "rows": "task", "columns": ["due"] }] }]\n },\n "acts": [\n { "alias": "complete", "label": "Complete", "of": "task", "when": { "status": ["todo"] }, "set": { "status": "done", "done_on": "now" } },\n { "alias": "reopen", "label": "Reopen", "of": "task", "when": { "status": ["done"] }, "set": { "status": "todo", "done_on": null } },\n { "alias": "complete_all", "label": "Complete", "of": "task", "on": "rows", "when": { "status": ["todo"] }, "set": { "status": "done", "done_on": "now" } }\n ]\n },\n {\n "alias": "my_tasks",\n "name": "My tasks",\n "entity": "task",\n "register": {\n "opens": { "assignee": "me" },\n "readings": [{ "metric": "task", "where": { "status": ["todo"] }, "label": "Open" }]\n },\n "record": { "door": "drawer" },\n "acts": [\n { "alias": "finish", "label": "Finish", "when": { "status": ["todo"] }, "set": { "status": "done", "done_on": "now" } },\n { "alias": "undo", "label": "Undo", "when": { "status": ["done"] }, "set": { "status": "todo", "done_on": null } }\n ]\n }\n ]\n}\n```\n\n### Acts\n\n```jsonc\n"acts": [{\n "alias": "gate_out", "label": "Gate out",\n "when": { "stage": ["in_yard"] }, "requires": ["release", "seal"],\n "asks": ["seal", { "input": "note", "label": "Note", "type": "long_text" }],\n "set": { "stage": "gone", "left_on": "now", "left_by": "me", "remark": "input:note" },\n "confirm": true\n}]\n```\n\n<!-- generated:start apps-acts -->\n\n#### Act\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `alias` | alias | yes | The act\'s name within its app \u2014 its workflow is `act_<alias>` |\n| `label` | text | yes | The verb, as the reader says it |\n| `on` | `"record"` \\| `"rows"` \\| `"view"` | no | `rows` runs on the rows ticked in the register \u2014 with `of`, on the child rows ticked together in a rows block of the record; `view` on every row the register shows (offered while the view is narrowed to its `when`; it states no `requires`); absent, on one record (its page and its row\'s menu) |\n| `of` | alias | no | A rows block\'s child entity the act works on: offered on each child row \u2014 or, `on: "rows"`, on the rows ticked together in the block \u2014 its conditions and its write that row\'s |\n| `when` | map of alias \u2192 (list of alias (at least one) \\| boolean) | no | The act works only while each named select holds one of these options, and each named yes/no (stored, or a formula) is this value |\n| `requires` | list of alias (at least one) | no | Fields that must be filled first, each drawn where the act stands (its section\'s fields, a block\'s, or the act\'s asks \u2014 never the header alone); a blocked act names each one missing |\n| `recommends` | list of alias (at least one) | no | Fields the act runs without and reads when filled: while one is empty, the act says what it will leave blank |\n| `asks` | list of (alias \\| [Act input](#act-input)) (at least one) | no | What the reader states when pressing it \u2014 fields of the record (written by the act) or inputs |\n| `set` | map of alias \u2192 (text \\| number \\| boolean \\| `null` \\| [Set formula](#set-formula)) | no | What the act writes: an option alias, a value, `"now"`, `"me"` or `"input:<name>"` \u2014 each the act\'s outcome, read-only elsewhere; or `{ "formula": \u2026 }`, computed over each acted row as it stands when the act runs and written then (a sell price at cost and markup): a starting value, which a person may change after and the act never recomputes |\n| `workflow` | alias | no | An authored workflow (src/workflows/<alias>.ts) the act runs after its checks, for what `set` cannot say |\n| `template` | alias | no | A document template the act makes a file from \u2014 an html one made a PDF, an excel one a workbook; over several rows, one file of them all, the rows listed under the entity\'s alias |\n| `templates` | list of [Act template](#act-template) (at least 2) | no | Several papers the act makes into `into` (a case\'s forms), instead of one `template`: drawn where the act stands as a list of its papers \u2014 each made one as its file, each not yet made as a placeholder naming it \u2014 the reader ticks which to make, and one press makes those, each replacing the paper its template made before |\n| `into` | alias | no | The files field of the one record the made document is kept in \u2014 remade, it replaces the paper its template made before, never a file a person put there |\n| `intake` | alias | no | A child the record lists in a rows block whose rows are its papers: the press takes papers, an agent reads them, and each is filed as a row of this child \u2014 its kind the child\'s title, its file in the child\'s files field |\n| `record` | [Act record](#act-record) | no | The press records a call, visit or meeting through the host \u2014 Stop ends it \u2014 and files it as a new row of a child the record lists: its audio, its screen where captured, its transcript |\n| `fills` | list of text (at least one) | no | With `intake`, what the papers read may fill: a field of the record, `<link>.<field>` on the row a required one-row link of the record names, or a child whose rows the papers add \u2014 shown before and after, and saved where it differs. With `record`, fields of the new row an agent fills from the transcript, saved as they come |\n| `confirm` | `true` | no | The press asks first; the question lists every active warning |\n| `danger` | `true` | no | The act destroys or cannot be undone |\n\n#### Act input\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `input` | alias | yes | A value the act asks for that is not a field of the record |\n| `label` | text | yes | What the reader is asked, in their words |\n| `type` | `"text"` \\| `"long_text"` \\| `"number"` \\| `"date"` \\| `"select"` \\| `"link"` \\| `"member"` | yes | What the reader states |\n| `options` | list of [Select option](#select-option) (at least one) | no | The choices a `select` input offers, one picked \u2014 written into a select holding the same option aliases |\n| `entity` | alias | no | The entity a `link` input picks one row of |\n| `required` | `true` | no | The act is refused without it |\n| `default` | alias | no | A field of the row whose value the input starts from, still changed at will |\n\n#### Act template\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `template` | alias | yes | A document template the act makes a paper of, kept in `into` \u2014 an html one made a PDF, an excel one a workbook |\n| `when` | map of alias \u2192 (list of alias (at least one) \\| boolean) | no | The paper starts ticked while each named select of the record holds one of these options and each named yes/no (stored, or a formula \u2014 how a multi-select is tested: `includes({needs}, {needs:x})`) is this value \u2014 a suggestion the reader changes, never a refusal; absent, it starts ticked |\n\n#### Set formula\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `formula` | text | yes | A formula over the acted row\'s fields, `{alias}` each, read as the entity\'s own formulas read them |\n\n#### Act record\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `into` | alias | yes | A child the record lists in a timeline or rows block, linking back to it by a single link: each recording is filed as a new row of it |\n| `audio` | alias | yes | The child\'s files field the recording\'s audio is kept in |\n| `transcript` | alias | yes | The child\'s text field the transcript is kept in, a speaker\'s turn per line |\n| `video` | alias | no | The child\'s files field the screen is kept in, where the recording captured it |\n| `at` | alias | no | The child\'s date field stamped with the moment the recording started |\n| `by` | alias | no | The child\'s member field set to the person who recorded |\n\n<!-- generated:end apps-acts -->\n\n<!-- generated:start rules-act -->\n\n#### Rules the check enforces\n\n| Rule | |\n|---|---|\n| `act.set-not-asked` | A field an act `set`s \u2014 an option, a value, `now`, `me`, an input \u2014 is the act\'s outcome: read-only everywhere else and never asked by an add (`create`); a required one takes a `default`. A field an act computes by a `formula` is a starting value, which a person may change after. |\n| `act.set-formula` | `set: { <field>: { formula } }` reads fields of the acted row and computes what the field holds \u2014 a number, a date, text or a yes/no, typed as the entity\'s own formulas are \u2014 never the status or a milestone; a bound a write rule holds the field to is checked on the computed value as the act runs. |\n| `act.of` | An act\'s `of` names a child the record lists in a rows block; it works on the one child row it is pressed on \u2014 or, `on: "rows"`, on the rows ticked together where the record lists that child as tasks \u2014 never on the rows in view. |\n| `act.writes-line-key` | An act never writes the link or the key naming an expected line under its record \u2014 a line\'s key is stated by filling it. |\n| `act.makes-or-runs` | An act makes a document (`template`, `templates`) or runs an authored `workflow`, never both \u2014 the workflow makes what it needs. |\n| `act.view` | An act over every row in view (`on: "view"`) writes (`set`), makes a document or runs a workflow, and states no `requires`. |\n| `act.requires-drawn` | Every field an act `requires` is drawn where the act stands \u2014 its section\'s fields, a block\'s, its asks, or an unstaged section\'s where its own is unstaged \u2014 never the header alone. |\n| `act.requires-written` | A field an act requires that only an act writes is written by an act offered together with it, or asked by the act itself. |\n| `act.recommends` | A field an act `recommends` is not also in its `requires`. |\n| `act.set` | `set` writes each field a value it holds \u2014 an option alias, a number, a yes/no, a text, `"now"` on a date, `"me"` on a member, `"input:<name>"` of an input the act asks of the same kind \u2014 or clears an optional one with `null`. |\n| `act.set-milestone` | An act stamps a milestone, never clears one \u2014 unticking clears it and every later one. |\n| `act.into` | `into` is a files field of the one record the act is pressed on, beside its `template` or `templates`. |\n| `act.templates` | `templates` names each template once, kept in `into`, on one record pressed alone \u2014 never beside `template`, `asks`, `of` or an `on` over several rows. |\n| `act.history-copy` | What a status move asks is copied onto the history\'s field of the same name, which holds the same kind of value. |\n| `act.intake` | `intake` names a child the record lists in a rows block that expects its title \u2014 a single select, the paper\'s kind \u2014 and is narrowed by no `where`, holding exactly one files field and needing nothing an added line does not fill; the act runs on one record, states `fills`, and states nothing but its `label`, `when` and `requires` beside them. |\n| `act.record` | `record` names a child the record draws in a timeline block or a rows block with no `expect`, `under` or `where`, linking back by one single link, whose row nothing requires beyond what the recording writes: `audio` and `video` its files fields, `transcript` a text field, `at` a date, `by` a member field, each a field of its own; the act runs on one record and states nothing but its `label`, `when`, `requires` and `fills` beside it. |\n| `act.fills` | `fills` names each once, only beside `intake` or `record`. Beside `record`, each is a field of the recording\'s row that the recording itself does not write. Beside `intake`: a field of the record, `<link>.<field>` on the row a required one-row link of the record names, or a child the record adds rows of in a rows block with no `expect`, `under` or `where` \u2014 its rows filled in the fields that block adds them with, among them every field an added row cannot be made without. Each field filled is one a person writes holding text, a number, a date, a yes/no or options, never a status, a milestone or a field an act writes. |\n| `act.fills-drawn` | *Noted, never refused:* A field `fills` writes that the record draws nowhere: the review shows the change, the record does not. |\n| `act.fills-new` | *Noted, never refused:* A child `fills` adds rows of, whose natural key the papers cannot state: every row read is added, so the same papers read twice list them twice. |\n| `act.input` | An input has a name of its own \u2014 never another ask\'s, a field\'s, or the record input its workflow takes \u2014 `options` exactly when it is a select, `entity` exactly when it is a link, and a `default` field holding what it states. |\n\n<!-- generated:end rules-act -->\n\nAn act whose `when` does not hold is not offered \u2014 each select named holding one of its\noptions, each yes/no named (stored, or a formula: a period that has ended) its `true` or\n`false`; every one that holds stands at the\nfoot of the section naming it in `acts` \u2014 a correction in that section heading\'s \u22EF, one\nof a child row on its row (a correction in the row\'s \u22EF). An act\'s `requires` are fields\nits section draws \u2014 its `fields`, a block\'s, the act\'s `asks`, or those of another section\nwith no `at` where its own has none; one shown only in the header or a staged section\nelsewhere is refused. One whose `requires` are\nempty is offered disabled, naming each; one whose `recommends` are empty names what\nit will leave blank and still presses; one a standing check blocks is offered\ndisabled in the check\'s words. An input\'s `default` is the row\'s field it starts from. The generated `act_<alias>` workflow re-checks all three on the\nserver, writes `set` and what was asked, and appends the status history where the\nact moves the status \u2014 each thing it asked copied onto the history row where the\nhistory has a field of the same name. `template` makes a document from the row and\nkeeps it in `into`; over several rows it makes one document of them all, the rows\nlisted under the entity\'s alias, and hands it back. `templates` instead names a set of\npapers kept in `into`, listed where the act stands \u2014 each made one as its file, each\nnot made yet as a placeholder \u2014 those whose `when` holds ticked to start; the press\nmakes the ticked ones (sent in `templates`), each replacing the file its template made\nbefore (the file whose `document_template_id` is that template \u2014 never a file a person\nput there), and the set downloads as one archive (`archive_<alias>`). A field an act writes is read-only everywhere else, never asked by an add \u2014 a required one takes a `default`. A `select` input offers its own options and is written into\na select holding the same option aliases; a `link` input picks one row of its `entity`\nand a `member` input one person, each written into a field of its kind. A field an act\nboth `requires` and `asks` never disables the press \u2014 the act asks it, required.\n\n`on: "view"` runs on every row the register shows \u2014 its search, status and filters \u2014\neach row checked before any is written. `of` names a rows block\'s child entity: the\nact is offered on each child row (its menu and its drawer), its `when`, `requires`,\n`set` and checks are the child\'s, it runs on that row, and a check of the record that\nlocks the child rows locks it too. With `on: "rows"` it runs on the child rows ticked\ntogether in the record\'s task list (\xA7 Tasks) \u2014 every row checked before any is written;\nit is refused where the record lists no tasks of that child.\n\n`intake` reads papers: the press takes files, an agent (`act_<alias>` among the app\'s\nagents) reads them, and each is filed as the record\'s line of its kind in the named\nchild \u2014 that child\'s title \u2014 or a new line, its file in the child\'s one files field.\nWhat they state of each field `fills` names is shown beside what the row holds; the save\nwrites only what differs \u2014 the record\'s fields, `<link>.<field>` on the row a required\none-row link names, a child\'s rows (one found by the child\'s `natural_key` changed, else\nadded) \u2014 each refused as its own save is, and nothing written while one refuses.\n\n```jsonc\n{ "alias": "read_papers", "label": "Read papers", "intake": "customer_paper", "fills": ["phone", "contact.email", "branch"] }\n```\n\n### Checks\n\n```jsonc\n"checks": [{ "field": "release_warning" }, { "field": "unpaid", "blocks": ["gate_out"] }, { "field": "locked", "blocks": ["edit"] }]\n```\n\n<!-- generated:start apps-checks -->\n\n#### Check\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `field` | alias | yes | A yes/no or text formula of the record (or of the child `of` names): true or non-empty means the check stands; its label is its short name on a row\'s line, and a text formula\'s words are the sentence the record and a tooltip say |\n| `of` | alias | no | A rows block\'s child entity this check is a formula of: standing on a child row, it refuses what it blocks of that row |\n| `blocks` | `"all"` \\| list of (alias \\| `"edit"` \\| `"delete"`) (at least one) | no | Acts, "edit" (the saves and the Delete), "delete" (the Delete alone) or "all" that the standing check refuses; absent, it only warns |\n| `resolve` | alias | no | An act of this app, on the rows the check stands on, that clears it: the check\'s line links to the act where it stands, and the act keeps its own conditions |\n\n<!-- generated:end apps-checks -->\n\n<!-- generated:start rules-check -->\n\n#### Rules the check enforces\n\n| Rule | |\n|---|---|\n| `check.field` | A check\'s `field` is a yes/no or text formula of the record, or of the child `of` names. |\n| `check.of` | A check\'s `of` names a child the record lists in a rows block. |\n| `check.not-placed` | A check\'s field is never also placed on the record \u2014 the line under the header says it. |\n| `check.blocks` | A check of the record blocks its acts, `"edit"`, `"delete"` or `"all"`; a check of a child blocks that row\'s saves, its Delete, the acts `of` that child, or all \u2014 never the record\'s acts. |\n| `check.resolve` | `resolve` names an act of this app on the rows the check stands on, pressed on one row, not refused by the check, and drawn as a button \u2014 never a correction. |\n| `check.restates-meter` | A warning reading a gated meter\'s figure against its bound or pass mark is refused \u2014 the meter draws it. |\n\n<!-- generated:end rules-check -->\n\nA check is a yes/no or text formula of the record: it stands while true or\nnon-empty, and a text formula\'s words are what the reader sees. Without `blocks`\nit warns; with them it refuses the acts named, `"edit"` the record\'s own saves (and\nits Delete), `"delete"` its Delete alone, or `"all"` \u2014 on the server, in every write it\nnames. A check `of` a rows block\'s child stands on each child row: it refuses that\nrow\'s saves (`edit`), its Delete (`delete`), the acts `of` that child it names, or\n`all` of them \u2014 never the record\'s acts. A warning reading the figure of a meter that\n`gates` marks against its bound or its pass mark is refused: the meter already draws it.\n\nThe check field\'s `label` is its short name \u2014 "Over budget", "Release expired": a row\'s\nline says it in the check\'s tone (the most severe of several, and how many more), while\nits words whole are the row\'s tooltip and stand under the record\'s header. So label it\nas the reader names the problem, in a few words, and let a text formula say the\nsentence. The compiler records the fields a formula reads: a row drawing each of them\nas a control says nothing of the check on its line (the empty control says it), and a\ncheck with no `resolve` links to the first of them the record draws.\n\n## What a run remembers\n\nThe WORKSPACE remembers what each entity, field, select option, role and\ntemplate alias became here, plus which record each first row landed on and the\nfile each document path was uploaded as. The model file itself holds no live id\n\u2014 it is the portable half, and the same file applies to a demo workspace and to\na customer\'s \u2014 so the join lives where the things it names live, and one model\napplied to two workspaces holds two bindings that know nothing of each other.\n\nIt is the workspace\'s and not the file\'s because a model file is never\ncommitted: memory kept beside it is one author\'s disk, absent for a teammate, on\na second machine or after a delete \u2014 and every one of those goes quietly back to\nbinding by label, which is what grows the second table.\n\nEvery later apply binds through it: it takes the remembered id first and falls\nback to a label only for an alias nothing has bound \u2014 a field you have just\nadded, or a workspace nothing has applied this model to. That is what makes a\nrelabel on EITHER side a rename rather than one thing the workspace lacks and\none the model lacks: `apply` binds the thing it has always meant and reports the\nmove \u2014 which side is right is yours to decide, not a reason to refuse the run.\n\nA bound target the workspace no longer holds IS a refusal, by name: re-binding\nto whatever carries that label today is how the model comes to point at\nsomebody else\'s table. `lotics run restore_table` puts a deleted table back.\n\n`lotics model pull` writes a workspace that already works as a model file \u2014 the\nstarting point for another business\'s model, never a source of truth: it carries\none business\'s words and stops describing that workspace the moment either\nchanges.\n\n## A complete model\n\n```json\n{\n "entities": [\n {\n "alias": "customer",\n "label": "Customers",\n "singular": "Customer",\n "fields": [\n { "alias": "name", "label": "Name", "type": "text", "required": true },\n { "alias": "logo", "label": "Logo", "type": "files" },\n {\n "alias": "tier",\n "label": "Tier",\n "type": "select",\n "options": [\n { "alias": "standard", "label": "Standard", "color": "slate" },\n { "alias": "gold", "label": "Gold", "color": "amber" }\n ],\n "default": ["standard"]\n },\n {\n "alias": "orders",\n "label": "Orders",\n "type": "select_record_link",\n "target_entity": "order",\n "cardinality": "many",\n "sync_both_ways": true,\n "paired_field_alias": "customer",\n "display_field_aliases": ["code"]\n },\n {\n "alias": "total_ordered",\n "label": "Total ordered",\n "type": "rollup",\n "source_field_alias": "orders",\n "aggregate_option": { "operation": "sum", "field_key": "amount" }\n }\n ],\n "views": [\n {\n "alias": "gold",\n "label": "Gold customers",\n "filters": {\n "node_type": "condition",\n "type": "select",\n "field_key": "tier",\n "operator": "has_any_of",\n "value": ["gold"]\n },\n "sort": [{ "field_key": "name", "order": "asc" }]\n }\n ]\n },\n {\n "alias": "order",\n "label": "Orders",\n "singular": "Order",\n "fields": [\n { "alias": "code", "label": "Order no.", "type": "text", "unique": true },\n { "alias": "placed_on", "label": "Placed on", "type": "date", "format": "date" },\n {\n "alias": "amount",\n "label": "Amount",\n "type": "number",\n "format": "currency",\n "currency": "VND"\n },\n {\n "alias": "total",\n "label": "Total with VAT",\n "type": "formula",\n "formula": { "expression": "{amount} * 1.1", "format": "currency", "currency": "VND" }\n },\n {\n "alias": "customer",\n "label": "Customer",\n "type": "select_record_link",\n "target_entity": "customer",\n "cardinality": "one",\n "sync_both_ways": true,\n "paired_field_alias": "orders",\n "display_field_aliases": ["name"]\n },\n { "alias": "note", "label": "Note", "type": "text" }\n ]\n }\n ],\n "roles": [{ "alias": "sales", "label": "Sales" }],\n "records": {\n "customer": { "title": "name", "image": "logo", "party": "organization", "figure": "total_ordered" },\n "order": { "title": "customer", "subtitle": ["code", "placed_on"], "figure": "amount", "starts": { "placed_on": "today" } }\n },\n "apps": [\n {\n "alias": "customers",\n "name": "Customers",\n "entity": "customer",\n "register": {\n "columns": ["tier"],\n "readings": [{ "breakdown": "customer", "by": "tier", "value": "total_ordered" }]\n },\n "record": {\n "sections": [\n { "title": "Account", "fields": ["tier"] },\n { "title": "Orders", "blocks": [{ "rows": "order", "columns": ["total"], "create": ["code", "amount"] }] }\n ]\n }\n },\n {\n "alias": "orders",\n "name": "Orders",\n "entity": "order",\n "register": {\n "filters": ["customer"],\n "create": ["code", "customer", "placed_on"],\n "readings": [{ "trend": "order", "over": "placed_on", "value": "amount" }]\n },\n "record": { "door": "drawer" }\n }\n ],\n "rows": {\n "customer": [\n { "ref": "acme", "fields": { "name": "Acme Trading", "tier": "gold" } },\n { "ref": "bluebird", "fields": { "name": "Bluebird Foods", "tier": "standard" } }\n ],\n "order": [\n {\n "ref": "so_1001",\n "fields": {\n "code": "SO-1001",\n "placed_on": "@month-start+2",\n "amount": 4200000,\n "customer": "customer:acme"\n }\n },\n {\n "ref": "so_1002",\n "fields": {\n "code": "SO-1002",\n "placed_on": "@today-3",\n "amount": 1150000,\n "customer": "customer:bluebird"\n }\n }\n ]\n }\n}\n```\n\nEach app of this file, as its reader will see it:\n\n```\nApp customers \u2014 "Customers" over Customers (customer)\n Register \u2014 table (default), newest first (default)\n row [Logo] \xB7 Name \xB7 figure Total ordered\n columns Tier\n filters \u2014\n readings "Tier" \u2014 Total ordered summed per Tier, over the rows in view (default)\n add Name (default)\n Record \u2014 a drawer (default)\n header image Logo \xB7 title Name \xB7 figure Total ordered\n checks \u2014\n header \u22EF Delete\n sections 1. "Account"\n fields Tier\n 2. "Orders"\n block Orders (default) \u2014 rows of Orders through Customer; columns Total with VAT; add asks Order no., Amount\n thread \u2014\n history \u2014\n acts \u2014\n Reads Customers, Orders\n\nApp orders \u2014 "Orders" over Orders (order)\n Register \u2014 table (default), newest first (default)\n row Customer \xB7 under it Order no., Placed on \xB7 figure Amount\n columns \u2014\n filters Customer\n readings "Amount" \u2014 Amount summed per period by Placed on, over the rows in view (default)\n add Order no., Customer, Placed on \u2014 starting Placed on at today\n Record \u2014 a drawer\n header title Customer \xB7 subtitle Order no., Placed on \xB7 figure Amount\n checks \u2014\n header \u22EF Delete\n sections 1. the record\'s details (default)\n fields Note\n thread \u2014\n history \u2014\n acts \u2014\n Reads Orders, Customers\n\n```\n\nEach `(default)` is a value the model left to the system: the door a record opens\nthrough \u2014 a drawer, since neither record has a stage or more than three sections \u2014 the\norder rows open in, what an add asks, and the order\'s one section \u2014 every field a\nperson writes that the header does not already show. The customer\'s orders\nare a block in its "Orders" section, never its own `Orders` link as a field as well:\none fact, one place. An order is titled by its customer and read by its number under\nit; the block names what its add asks, since a subtitle or a figure is never asked by\ndefault. `Total ordered` is a rollup, so it is read and never asked; the\norders app\'s record opens in a drawer, because its rows are worked one after another.\n';
|
|
40253
40285
|
|
|
40254
40286
|
// AGENTS.md
|
|
40255
40287
|
var AGENTS_default = "# @lotics/cli \u2014 agent index\n\nThe model an agent needs before driving this CLI: which surface answers which question, what the\nconventions are, and where the traps are.\n\n| Read | For |\n|---|---|\n| `lotics --help` | The verb inventory (\xA7 COMMANDS) and global flags. The verb LIST is generated and never stale; the prose beside each verb is hand-written, so where it disagrees with `docs/cli_reference.md`, the reference wins. |\n| `lotics tools` \xB7 `lotics tools <name>` | The agent tool registry and one tool's full JSON Schema. |\n| `lotics docs` \xB7 `lotics docs <area>[/<section>]` | This CLI's references, carried inside it so each describes the binary answering. Capped at a page: a doc that does not fit hands back its opening and the addresses into it, so the next call is smaller than the last. A custom-code app's SDK reference is `node_modules/@lotics/app-sdk/AGENTS.md` inside the app. |\n| `lotics docs model` \xB7 `lotics docs model/<section>` | How to write a `model.json` \u2014 the file a workspace is built from: its tables, how a row of each is recognised (`records`), and the apps stated over them. Every top-level key, every field type with the config it needs, the row format, the rules, and a worked example; complete example models of several trades are listed at `https://lotics.ai/presets/index.json`. `lotics model apply` checks the file (every problem in one run), applies its tables and mints a version of each app; the WORKSPACE remembers what each alias became, so every later apply binds by id and a relabel on either side is a rename it reports rather than a second table it adds. `lotics model pull` goes the other way \u2014 the workspace's model, rebuilt from what owns each part. |\n| [docs/building_an_app.md](./docs/building_an_app.md) | The SEQUENCE \u2014 clarify, model, apply or build, prove, look \u2014 for an app stated in a model and for a custom-code app. Read it once before starting an app. Looking is `lotics run screenshot_app`, then `lotics download` for each PNG. |\n| [docs/cli_reference.md](./docs/cli_reference.md) | Per-command contracts, flags, exit codes, and gotchas \u2014 the detail `--help` compresses. |\n| [docs/data_model.md](./docs/data_model.md) | Tables, their fields, and how they relate \u2014 one fact per column, one entity per table and the NAME-OVERLAP probe that says when a split has broken, one vocabulary wherever values are copied, a copy boundary that accounts for every source field, provenance as a link, a declared natural key, history as rows, and why derived DEPTH costs more than row count; then every field type, its properties, computed fields, and what `update_fields` takes. Separate from building_an_app because every workspace starts with tables and many never get an app. |\n| [docs/filters.md](./docs/filters.md) | The one filter grammar every filter-taking tool, view and rollup reads \u2014 conditions, groups, and the operators per field type. |\n| [docs/workflows.md](./docs/workflows.md) | Writing a workflow body \u2014 triggers, steps, what an expression reads, the helpers, approvals and agent steps, and table lifecycle workflows. |\n| [docs/app_bindings.md](./docs/app_bindings.md) | The queries, workflows and agents an app binds \u2014 the query tree, typed inputs and outputs, and an agent's declaration. |\n| [docs/document_templates.md](./docs/document_templates.md) | Generating PDF/Excel/Word/email from reusable templates. |\n| [docs/knowledge_docs.md](./docs/knowledge_docs.md) | Authoring the workspace facts an agent can't guess; who can read a doc; catalog-then-stage retrieval. |\n| [docs/migration.md](./docs/migration.md) | What to DO when something an earlier CLI wrote to disk no longer matches what it does \u2014 local app projects are gone, and each verb that read one has a replacement. |\n| [README.md](./README.md) | Install, auth, and worked examples. |\n\n## Two surfaces, and the trap between them\n\n**`lotics tools` is not the CLI's capability list.** There are two disjoint surfaces:\n\n- **Tools** (`lotics tools`, `lotics run <tool>`) \u2014 the *agent tool registry*: what an agent, workflow,\n or automation may call. Workspace data, templates, knowledge, admin.\n- **Commands** (`lotics --help` \xA7 COMMANDS) \u2014 the CLI's *own verbs*: auth and org/workspace scoping,\n file upload and download, and the four that touch a local file for an app \u2014 `model apply`,\n `model pull`, `app create --custom` and `app deploy`.\n\nSeveral capabilities exist **only** as commands and appear nowhere in `lotics tools` \u2014 downloading a\nfile is the one most often mistaken for missing. Concluding \"the platform can't do X\" from the tool\nlist alone is a mistake; check both.\n\n**`lotics <verb> --help` prints that verb's entries from \xA7 COMMANDS** (`lotics model --help`,\n`lotics file download --help`); `lotics report --help` prints the report frame. To find out whether\nsomething exists, read `lotics --help` \xA7 COMMANDS \u2014 the whole section, not a narrow grep.\n\n## Conventions that hold across every command\n\n- **Scope is resolved per invocation.** `LOTICS_ORG` / `LOTICS_WORKSPACE` (or `LOTICS_API_KEY`) scope a\n single call without changing the active org or a directory pin \u2014 the safe way to touch one tenant\n from a shell serving many. Every command that resolves a workspace echoes its target to **stderr**\n (`lotics \u2192 <org> / <workspace>`); read it back before trusting a write. Resolution precedence is\n in README \xA7 Organizations.\n- **A machine with no key can still sign in, and the sign-in never blocks you.** `lotics auth\n login <email>` prints the page a person opens (also mailed) and the code that page must show,\n records the request, and EXITS. They press Confirm whenever they get to it; the next command that\n NEEDS a credential collects the key before doing its own work, so \"sign in\" costs you one command\n and then re-running what you wanted. Never wrap it in a timeout waiting for a human \u2014 `--wait`\n exists if you really want one blocking command, and killing that one is safe too (the request\n survives and the next command claims it). A command run before Confirm exits 1 naming the page and\n code again; once the 15 minutes are up it says to ask again. `lotics setup` falls into the same\n flow by itself when the email it was given already has an account: it stops having created\n nothing, and the SAME command run again carries on.\n- **A credential is either a SIGN-IN or an API KEY, and `logout` treats them differently.** A profile\n from `auth login` / `auth signup` acts as the person who confirmed it and is theirs \u2014 `lotics auth\n logout` revokes it server-side, and it lapses on its own after 90 idle days (each use pushes that\n out). A profile from `auth api-key` holds a key an ADMIN issued. A key created in Settings carries\n its OWN access \u2014 every app and table, or only the ones chosen on the key, so a listing that comes\n back short is the key's reach, not a bug \u2014 while a key created FOR a person carries that\n person's access and dies with their membership. Either is routinely also on a server and on\n other machines, so logout only forgets it locally and says so; only an admin revokes it. A profile that states no kind (saved before the field existed) is resolved against the SERVER\n and revoked only if the answer is a sign-in; a bare `--api-key` / `LOTICS_API_KEY` names no\n profile to remove at all. Nothing is ever revoked on a guess \u2014 between two, the destructive one is\n wrong. `lotics auth whoami` prints the kind, asking the server when the store cannot say.\n- **A key created in Settings never administers the organization, whatever its access.** The verbs\n `docs/cli_reference.md` marks *admin only* split in two under a key: `workspace doctor`, which only\n reads inside a workspace the key reaches, runs as before, while applying or pulling a model\n (`model apply`, `model pull`, `setup`), managing people, sharing or\n ownership, creating or deleting a workspace, changing workspace settings, setting credit limits,\n reading the access log and publishing an app's API answer `403` and name the remedy: an admin signed in, so\n `lotics auth login <email>` and run it again. A sign-in acts as that person and is refused none of\n them. Do not retry a `403` with the same credential and do not ask for a wider key \u2014 no answer on\n the key's own screen grants this.\n- **A 401 names its remedy \u2014 act on the hint, do not retry.** \"This credential expired / was\n revoked / belongs to a member who is no longer active\" carries the one remedy that ends this\n credential: run `lotics auth login <email>` for a sign-in, or ask the admin who issued it for a\n new key. A credential minted before that was recorded carries BOTH, because nothing on the row\n tells them apart \u2014 so on a box with no browser, take the second. Only an UNRECOGNIZED key gets\n the generic \"Invalid or disabled API key\", and that one is generic on purpose, so re-sending it\n teaches nothing. A `reason` rides on the body for a script to branch on, since the code stays\n `unauthorized` for every 401.\n- **Large payloads bypass `ARG_MAX`** \u2014 `lotics run <tool> @args.json`, or piped stdin behind `-`. A leading `@` is\n unambiguously a file path (JSON args start with `{`).\n- **stdout is the payload, stderr is the narration.** Progress, status lines, and the target echo go to\n stderr; the result goes to stdout, so piping stays clean. `--json` switches stdout from the\n agent-readable summary to the full structured object.\n- **Every tool is invoked one way \u2014 `lotics run <tool>`.** Including the ones that RUN something\n (`run_app_workflow`, `run_app_agent`, `run_app_query`) and every one that changes an app\n (`set_app_query`, `set_app_workflow`, `set_app_agent`, `update_app`, `rollback_app`). A command\n exists only for work that touches a local file.\n- **Every change to an app mints a version of it, and rolling back is the undo.** An apply, a\n deploy, a single `set_app_*` or `remove_app_*`: each is a new version, and `rollback_app` makes an\n earlier one current again. It restores the app \u2014 never a table change or a row a workflow wrote,\n so try a write on a throwaway record.\n- **Exit codes are assertable, and they report the WORK rather than the call.** `lotics run` exits\n non-zero when a `run_app_workflow` or `run_app_agent` result's own envelope carries a failed\n `status` (`error`/`failed`/`cancelled`) \u2014 any other tool's top-level status is data and exits 0 \u2014\n so `lotics run \u2026 && next-step` cannot walk past a refused run; `workspace doctor` exits non-zero on\n findings. An unrecognized status exits 0 \u2014 the list is an allowlist of failure, so a status added\n later never turns a working script red \u2014 and a parked run (`awaiting_input`) is not a failure.\n- **An app that PUBLISHES an API turns every later binding write into a release.** Publishing\n snapshots what the app's queries, workflows and agents promise to callers outside it \u2014 a\n customer's own site or server, which nobody here can redeploy. From then on an additive change\n re-snapshots silently and a breaking one is REFUSED, naming each change; the tool's\n `acknowledge_breaking_api_change` carries it out and snapshots the break as a new contract version.\n- **Exposure is per app, all or nothing** \u2014 a public share or a key reaches every alias an app\n declares, so what outsiders may call is a second app over the same tables\n ([docs/building_an_app.md](./docs/building_an_app.md) \xA7 7).\n- **`--print-created` / `--cleanup` on any call that reports `side_effects`.** The first prints the\n records created plus a paste-ready cleanup plan and what cannot be auto-undone; the second runs\n those deletes (records only \u2014 never files, integrations or notifications). Neither is a rollback.\n- **Text output is the default and is built for reading**; reach for `--json` only when a field is\n needed programmatically.\n- **`lotics report '<json>'` is the channel for what nothing else records.** Reach for it\n when the platform is genuinely missing something (a capability that does not exist \u2014 no\n command ran), when a success was wrong (exited 0, wrong effect), or when an error did\n not name the remedy \u2014 not for your own mistakes, which the logs already show.\n **It is a frame, not a paragraph** \u2014 `{goal, actual, expected?, tried?, wanted?}`, because a log\n reconstructs what you RAN and never what you WANTED, and that gap is the report. `goal` and\n `actual` are required; there is no severity or category to pick. The\n session's commands attach themselves \u2014 do not retype them. Run it bare for the full prompt; a\n long one rides `@file.json` or an explicit `-` for stdin (bare NEVER reads stdin).\n- **`LOTICS_TELEMETRY=1` correlates a whole session** so the authoring loop's rough edges can be\n found and fixed. Off by default; set it in the shell profile, not per command (each invocation is\n its own process). It sends no arguments, no file contents, and no record data \u2014 see README\n \xA7 Diagnostics.\n\n## Where this CLI is not the answer\n\n- **Editing an app in a local directory.** An app stated in a model is changed by applying the\n model again; its bindings through their `set_app_*` tools; a custom-code app's code by deploying\n its directory again. Nothing reads a local copy of a live app back.\n- **OAuth connections.** Attaching a connected account is web-only; the CLI can list them.\n";
|
|
@@ -40258,7 +40290,7 @@ var AGENTS_default = "# @lotics/cli \u2014 agent index\n\nThe model an agent nee
|
|
|
40258
40290
|
var building_an_app_default = '# Building an app, end to end\n\nThe other references here describe **contracts** \u2014 what a model may state, what a tool takes. This\none describes the **sequence**: the order the steps go in, and why. Read it once for the shape, then\nreach for the area doc (`lotics docs`) whenever you need the detail.\n\n**The live app is the only edit surface.** Every change to an app \u2014 applying a model, deploying a\nbuild, setting one query or workflow \u2014 mints a new version of it, and rolling back to an earlier\nversion is the undo. Nothing about an app lives in a local directory the platform reads back, so\nthere is no project to keep in sync and no deploy to find out whether something works.\n\n**Rolling back restores the app, not the data.** A table change the model made, and every row a\nworkflow wrote while you tried it, stay where they are. Try a write on a throwaway record.\n\n---\n\n## 1 \u2014 Two kinds of app\n\n- **An app stated in a model** \u2014 the default, and the right one for almost every job. `model.json`\n says how a row of each entity is recognised (`records`) and, in `apps`, one register over one\n entity and the record its rows open: its columns and filters, its sections in the order its work\n reaches them, its acts and the checks that guard them (`lotics docs model/apps`). The platform\n compiles each app and draws it with the runtime every such app shares, so how each piece looks is\n the platform\'s, one way per concept, and no key changes it. Every write the app makes is a\n generated workflow that re-checks on the server what the model states.\n- **A custom-code app** \u2014 React you write, for a surface the model\'s vocabulary cannot state. It\n reads and writes through `@lotics/app-sdk` (queries, workflows, files, AI) and draws with whatever\n React you choose.\n\n## 2 \u2014 Clarify what is being asked, before modelling it\n\n**A metric name is not a definition.** "Revenue", "in stock", "active", "overdue" \u2014 each is a\nbusiness rule the person asking owns, and the cost of guessing is a screen that is confidently\nwrong. Ask until there is no ambiguity left:\n\n- Which rows count, keyed off which field \u2014 a date, a status, a flag?\n- Does the same metric need a **different rule per table**? One table may key off a date and\n another off a status; one rule rarely covers both.\n- Snapshot or flow? "Current stock" is as-of-now; "revenue this month" is a window. They compile\n to different filters.\n- If the data cannot support the definition asked for \u2014 the field simply is not there \u2014 **say so\n and show the options.** Silently substituting a near-miss produces a number nobody can trace.\n\nThis is the step that gets skipped under time pressure, and it is the only one whose mistakes are\ninvisible in review: every later artifact is correct with respect to the wrong definition.\n\n## 3 \u2014 The data model, before any app\n\nGet this wrong and nothing above it can be precise. Each entity is its own table with links into\nthe spine; attributes and evidence are fields on their owner. A single table with a `type` column\nstanding in for three entities collapses the distinctions every later query needs.\n\n**Verify real VALUES, never just that a field exists.** `lotics run query_records` a sample and\nlook at fill rates \u2014 a field that is present and empty on 90% of rows will not support the screen\nyou are about to design.\n\n**That includes imagery.** If the entity has a likeness \u2014 a product, a property, a vehicle, a\nperson \u2014 its picture is the strongest identifier a register row can carry, and an empty image field\nis a data gap to fill before you design around it: `lotics file upload`, then `update_records`.\n\n**One fact, one column \u2014 and check before you add one.** Read the table\'s existing fields before\nadding any, because the fact is often already there in another shape: a place written as text\nbeside a link to the place record, a status word beside the select that decides it, a total beside\nthe formula that computes it. Two columns for one fact never stay equal. Prefer the link, the\nselect, or the formula, and compose the text when you READ.\n\n**Match on ids and option keys, never on rendered text.** Resolve a name to its `rec_\u2026` or `opt_\u2026`\nonce, at the boundary, and compare those. Treat an unresolved name as UNKNOWN, never as a wildcard.\n\n**How the tables RELATE is `lotics docs data_model`** \u2014 read it before designing a schema; those\ndecisions outlive any one app.\n\n**Empty is not the same as redundant.** A field nothing fills may still be the only home for a real\ndistinction. Read what a field MEANS before you remove it.\n\n## 4 \u2014 An app stated in a model\n\n```\nlotics docs model # how to write model.json, with a worked example\nlotics model apply model.json # check it, apply the tables, mint a version of every app\nlotics model apply model.json --app orders # only the apps named; the tables are applied whole\nlotics model apply model.json --plan # what the apply would change, writing nothing\nlotics model pull -o model.json # the workspace\'s model, as the file apply reads\n```\n\n`model apply` checks the whole file first \u2014 every problem in one run, before anything is uploaded\nor written. It then adopts or creates each table (the table this workspace bound it to, else one of the same\nlabel, is adopted and given what it lacks; no stored value changes), writes the first rows only where every bound\ntable is empty, and mints one version per app, printing each app\'s id, the version minted\n(`unchanged` when there was nothing to mint) and its address. Applying the same file again mints nothing.\nA table change is not undone by rolling an app back, so `--plan` first says what the apply would\ncreate or change in the tables and which apps it would create, update or refuse \u2014 writing nothing.\n\n**The model changes after the app exists, and applying it again is how it lands.** Edit the file,\napply it. An act whose write the model cannot say names its own `workflow`; that workflow\'s body is\nthe live one, and `lotics run set_app_workflow` changes it. An act that reads papers runs an agent\napply owns: made, replaced and removed with the act, and an agent of that alias apply did not make\nis refused, never rewritten.\n\n## 5 \u2014 A custom-code app\n\n```\nlotics app create "<name>" --custom # the app, plus a Vite + React + TS project using @lotics/app-sdk\ncd <dir>\n# edit src/App.tsx \u2014 node_modules/@lotics/app-sdk/AGENTS.md is the reference\nnpm run typecheck && npm run lint && npm test\nlotics app deploy -m "<what changed>" # build, upload, a new version live\n```\n\nWhat the app reads and writes is bound on the app, never in the project: `lotics run\nset_app_query` binds a named query, `lotics run set_app_workflow` a workflow body, and each mints a\nversion. `useQuery("<alias>")` and `useWorkflow("<alias>")` call them. A deploy uploads the build\nand carries every binding forward unchanged.\n\n**Named queries.** Author them as `kind: "project"` with a `filter`, naming each projected column\n(`{ "source": "fld_\u2026", "output": "total" }`), so a row reads as `r.total`. Scope per-user reads with\n`is_current_member` **inside the query** \u2014 a member id passed from the client is chosen by the\ncaller. Write one `description` per alias: it is the line a chat or MCP caller chooses by.\n\n**Workflows are the only way an app writes.** Every workflow bound to an app is also the chat\nagent\'s write surface, so its shape is an agent-facing decision: take a list where one job covers\nmany records, say in an optional input\'s `description` what omitting it means, make the write\nsurvive running twice, and gate anything irreversible with `wait_for_approval`.\n\n## 6 \u2014 Prove it, without a screen\n\nStatic checks prove parse, types and names \u2014 they evaluate nothing. Rehearse a write first:\n\n```\nlotics run dry_run_workflow \'{"trigger_type":"app_workflow","trigger_payload":{\u2026},"live_reads":true}\'\n```\n\nIt walks the real step tree and returns the resolved plan plus expression and tool-input errors,\ndispatching no write. **`live_reads: true` matters whenever the body reads anything**: without it\nevery read returns a stub, and every data-gated branch takes the empty path.\n\nThen run it end to end, on a throwaway record:\n\n```\nlotics run run_app_query \'{"app_id":"app_\u2026","alias":"\u2026","params":{\u2026}}\'\nlotics run run_app_workflow \'{"app_id":"app_\u2026","alias":"\u2026","inputs":{\u2026}}\'\n# exits non-zero when the run failed, so it is assertable\n```\n\nA workflow is also how an app **produces a document** \u2014 the `generate_*_from_template` tools fill a\ntemplate you registered once (`lotics docs document_templates`).\n\n## 7 \u2014 Look at it, then name it\n\nLook at every app you apply, at a desktop width and at a phone\'s \u2014 every check above reads the\ndefinition, none of them the pixels:\n\n```\nlotics run screenshot_app \'{"app_id":"app_\u2026"}\'\n# one PNG per width, saved as a workspace file; "path":"/rec_\u2026" opens a screen inside the app\nlotics download <file_id>\n```\n\nEach shot is one screen, as a member sees it; `"full_page":true` grows it to the list scrolling\ninside the app. Beside each image comes its `snapshot`, the screen\'s accessibility tree as text \u2014\nread it to check labels and values without opening the picture.\n\nThe app is drawn live, as you, read-only: a screen that writes when it opens shows that write\nrefused, and `errors` lists what failed on the page. A version that looks wrong is one\n`lotics run rollback_app` away from the one before.\n\nThen set the icon, the colour and the app\'s own `description` through `lotics run update_app`. The\n`description` heads the capability listing the member\'s chat agent reads on **every** turn, so a\nstanding process the app expects that agent to carry out belongs there and nowhere else.\n\n**A caller outside the team gets its own app.** Sharing an app publicly, or giving an API key\naccess to it, reaches every alias the app declares; no alias can be held back. So whatever\noutsiders may call is a SECOND app over the same tables: only the queries they may read and the\nworkflows they may run. The desk the team works in stays a separate, private app.\n';
|
|
40259
40291
|
|
|
40260
40292
|
// docs/cli_reference.md
|
|
40261
|
-
var cli_reference_default = "# @lotics/cli \u2014 CLI Command Reference\n\nPer-command syntax, flags, contracts, and gotchas for the public `lotics` CLI. Start at [AGENTS.md](../AGENTS.md) for the model this reference assumes; `lotics --help` is the authoritative, always-current verb list.\n\n| Command | What it does |\n|---|---|\n| `lotics` / `lotics --help` | Show full help: capabilities, the verb list (\xA7 COMMANDS), flags, config. `lotics <verb> --help` prints that verb's entries alone (`lotics model --help`, `lotics file download --help`); `lotics report --help` prints the report frame. |\n| `lotics auth signup <email>` | Create account + org + API key, sends magic link email. Registers the new org as a profile; `--local` pins this directory to it (pointer) instead of setting the global default. |\n| `lotics auth login <email>` | Sign in an account that already exists, on a machine holding no key. **Two steps, and it does not wait for the person.** The first prints the page to open \u2014 `https://lotics.ai/cli_login/<request_id>`, also mailed \u2014 and the code that page must show, records the request, and exits 0. They sign in there if asked, check the code and press Confirm. **Then the next command that needs a credential collects the key** before it does its own work, so the second step is just re-running whatever was wanted; a command run before Confirm exits 1 naming the page and the code again, and once the 15 minutes are up it says to ask again. The handful that run WITHOUT a credential \u2014 `docs` among them \u2014 claim nothing, so one of those run after Confirm still answers as though signed out. `--wait` keeps one command instead, holding the terminal until Confirm; `--local` pins this directory to that org rather than setting the global default, and implies `--wait` (a pin names THIS directory, so only the terminal that stays in it can write one). `--json` prints `organization_id`, `workspace_id` and `organization_name` when it finishes signed in, and `request_id`, `confirm_url`, `code`, `email`, `expires_at` when it is the first step. The request's secret is never printed and the org's key never leaves the store. |\n| `lotics auth api-key [key]` | `whoami` \u2192 **upsert** the key's org as a profile in the global store (never overwrites). The profile records the instance the key was verified against (`LOTICS_API_URL`, default `https://api.lotics.ai`), and every later command for that org goes there. `--local` additionally pins this directory to it (pointer) instead of setting the global default. |\n| `lotics auth web` | Send a magic link email to access the web app (requires auth) |\n| `lotics auth whoami` | Print active account name, email, org, resolved workspace, the instance the credential belongs to, which **kind** of credential this machine holds (a sign-in from `auth login`, or an API key \u2014 read from the saved profile, and from the server when the profile does not say, which covers `--api-key`/`LOTICS_API_KEY` and a profile saved before the field existed; unknown only when neither can say), and the resolution **source** (flag/env/local/app-manifest/global). `--json` adds `workspace_id`, `api_url`, `credential_kind` + `source`. |\n| `lotics auth logout [<name\\|id>]` | In a pinned dir: delete the local pin. Else: remove the profile (default the active org), `--all` for every one. What happens server-side depends on which KIND of credential it is. A **sign-in** (`auth login` / `auth signup`) is revoked \u2014 logging that terminal out ends its credential rather than leaving a live one behind; a server that cannot be reached, or a credential already dead, never blocks the local forget, and one line names the org and Settings \u2192 Security \u2192 *Keys and terminals*. An **API key** (`auth api-key`) is only forgotten here \u2014 an admin issued it and it is routinely on a server and on other machines, so one terminal signing out must not kill it for everyone; the line says it is still active and names both pages, because Settings \u2192 API keys is admin-only and the credential may well be the holder's own sign-in, which they revoke themselves at Settings \u2192 Security \u2192 *Keys and terminals*. A profile saved before the kind was recorded states nothing, so the SERVER is asked (`auth whoami`) and it is revoked only if the answer is a sign-in: an older server, a credential minted before the column, and a request that fails all leave it alone. |\n| \u2014 | **A refused credential says which of three ways it is dead, and names the remedy that ends its kind.** `This credential expired.` / `was revoked.` / `belongs to a member who is no longer active in this organization.` carries `Run \\`lotics auth login <email>\\` to sign in again.` for a sign-in and `Ask an admin for a new API key (Settings \u2192 API keys).` for an issued key. A credential minted before that was recorded still gets BOTH in one sentence, because nothing on the row tells them apart \u2014 so a headless box is never sent looking for a browser alone. A key the server does not recognize at all gets one flat `Invalid or disabled API key.` \u2014 deliberately, so a guessed key learns nothing, not even that it named a row. The body carries `reason` for a script to branch on, since the code stays `unauthorized` for every 401. |\n| `lotics org` | List saved orgs (profiles) from the global store with the instance each belongs to, marks active for this directory (a local pin wins over the global default). |\n| `LOTICS_ORG=<name\\|id>` | Scope every command in this shell to one saved org. **Resolved once, before any command dispatches**, so a value matching no saved credential refuses every verb with one sentence \u2014 a read, a write, and a local check that needs no credential alike \u2014 and refuses it before the first byte is written. It refuses even when a credential arrives another way, because `--api-key` / `LOTICS_API_KEY` outrank it in the precedence chain and a write must never fall through to whatever THOSE name while the variable says otherwise; when the variable resolves and a key is also given, the key decides and the command says so. The refusal lists the orgs this machine holds, so it is answerable without another command (`lotics org` is refused by the same rule). A name is whatever the credential was SAVED under \u2014 a server-side rename never moves it, and the new name resolves too, so both keep working and `lotics org` prints the pair. |\n| `lotics org use <name\\|id> [--local]` | Switch the active org by org name (case-insensitive, ambiguous \u2192 error) or id. No flag \u2192 global `active_org`; `--local` \u2192 a `.lotics/config.json` pointer in the current dir. |\n| `lotics workspace` | List workspaces in the active org, marks current with `(current)` |\n| `lotics workspace select <id>` | Set the workspace in the **active scope** \u2014 a local pin if the dir has one, else the active org's global profile. Records the workspace's NAME beside its id, which is what the `lotics \u2192 <org> / <workspace>` echo prints; `workspace list`, `workspace create`, `workspace rename` and `org use` record it too, so a target is named rather than identified. Until one command has listed it, the echo prints the id and says the name is not known yet. |\n| `lotics workspace create <name> [--timezone <Area/City>] [--currency <ISO>]` | Create a new workspace (admin only), auto-switches to it. Neither flag is defaulted from THIS machine, unlike signup: an extra workspace is routinely created by an operator for somebody else. Without `--timezone` the new workspace inherits the zone of the org's OLDEST workspace; without `--currency` it takes the org's default. Both ride the create, so the workspace is never briefly denominated in a currency nobody asked for. `--currency` takes an ISO-4217 code (case-insensitive; anything else is refused). |\n| `lotics workspace rename <name>` | Rename the **current** workspace (admin only) \u2014 the endpoint takes its target from the request's workspace, never a path id, so switch with `workspace select <id>` first and read the `lotics \u2192 <org> / <workspace>` echo before trusting it \u2014 both halves are names, and the rename moves the cached one in the same act. Carries the workspace's existing `default_currency` and `timezone` through unchanged: the endpoint takes the whole settings triple, so sending only a name would blank the other two. |\n| `lotics workspace settings [--name <n>] [--currency <ISO>] [--timezone <Area/City>]` | Change the CURRENT workspace's name, default currency or timezone \u2014 `PATCH /v1/workspace`, admin only. Only what you name changes; the endpoint takes the whole triple, so the CLI carries the two you did not. `rename` is this verb with the name alone, which is why it can never forget the other two. Both values are invisible once they are wrong: the currency decides how every money field RENDERS and the zone decides how every date BUCKETS, on a workspace whose whole purpose may be to look like the customer's own. `--json` prints the updated workspace. |\n| `lotics workspace delete <id> --yes` | Delete a workspace by id (admin only). **Soft delete** \u2014 `archived_at` is set, so it drops out of listings, can no longer be selected, and its tables/records go dark, while the data is retained and recoverable. Its **apps are cascade-archived** too \u2014 every app entry point (embedded, public link, standalone subdomain, incl. anonymous public links) stops serving. Refuses the org's **only** active workspace (400) and any workspace outside the caller's org (404). Requires `--yes` to confirm (destructive; the CLI is used non-interactively). |\n| `lotics workspace doctor` | Report workspace-wide dangling schema references via `GET /v1/workspaces/dangling-references` \u2014 every active app/workflow artifact whose prefixed schema id no longer resolves, printed as `<referent.kind> \"<name>\" (<id>) \u2192 <namespace> <id> (missing)`; healthy prints a one-line all-clear. **Exits non-zero (exit 1) on findings** so scripts can gate on it. Resolves the first workspace like every data command (runs before the global workspace resolution). Admin-only. |\n| `lotics tools` | List tools by category with descriptions |\n| `lotics tools <name>` | Full description + JSON Schema for one tool |\n| `lotics run <tool> '<json>'` | Execute a tool (text output via toModelOutput). Args may also come from a file (`lotics run <tool> @args.json`) or stdin behind the `-` sentinel (`cat args.json \\| lotics run <tool> -`) \u2014 both bypass the OS `ARG_MAX` limit for large payloads (a knowledge-doc `content`, a bulk update). A leading `@` on the args is unambiguously a file path (JSON args start with `{`). **Stdin is asked for, never guessed.** `lotics run <tool>` with no payload runs the tool with no arguments and returns at once. `lotics report` takes the same sentinel. In PowerShell use `@file`: quotes inside an inline argument are consumed by the shell, and the CLI reports the JSON it received with its quotes gone \u2014 the error names both escapes. |\n| `lotics run <tool> --json '<json>'` | Execute a tool (full JSON output) |\n| `lotics run <tool>` \u2014 **file cells** | A file in a tool's result carries its `fil_\u2026` id and metadata and **no `url`**, on every tool and in both output modes. That is not a broken file \u2014 this surface resolves no URL for a cell. Reach the bytes with `lotics file download <file_id>`, which takes the id straight from the cell; the text output says so whenever a result carries one. |\n| \u2014 | **Every tool is invoked here, including the ones that RUN something** (`run_app_workflow`, `run_app_agent`, `run_app_query`) and every one that changes an app (`set_app_query`, `set_app_workflow`, `set_app_agent`, `update_app`, `rollback_app`). A command exists only for work that touches a local file: `model apply`, `model pull`, `app create --custom`, `app deploy`. |\n| \u2014 | **The exit code reports the WORK, not just the call \u2014 for the two tools that RUN one.** `run_app_workflow` and `run_app_agent` whose envelope carries a failed `status` (`error`/`failed`/`cancelled`) exit non-zero and print `<tool> \u2192 <status>: <message>` to stderr, so `lotics run \u2026 && next-step` cannot walk past a refused run. The rule is an allowlist of FAILURE \u2014 an unrecognized status exits 0, so a status added later never turns a working script red. A parked run (`awaiting_input`) is not a failure: it is waiting for an answer and the work is still live. Only a TOP-LEVEL `status` counts; one inside the data belongs to the data. Any OTHER tool's `status` is data, and exits 0. |\n| `lotics run <tool> --print-created` | Report the records the call created, grouped by table, with a paste-ready `delete_records` per table and the mandatory caveat naming what cannot be auto-undone (external integrations, notifications, possible sub-workflows). Works for any tool that returns a `side_effects` block, not workflows alone. |\n| `lotics run <tool> --cleanup` | Implies `--print-created`, then runs those deletes \u2014 harvested records **only**, never files / external calls / notifications. **Not a rollback**; a rollback is structurally impossible here. A partial cleanup exits non-zero so a script cannot read it as success. |\n| `lotics file upload <file\\|dir...>` (alias `lotics upload`) \xB7 `--stdin` \xB7 `--base64` \xB7 `--url <url>` | Upload files/directories. **The transport is chosen by size and is not a flag**: under 8 MiB the file is POSTed to `/v1/files` in one request, and several such files go in the same one; at or above it the CLI takes presigned part URLs and PUTs the bytes straight to object storage, so they never pass through the API. That threshold matches the AWS CLI's own `multipart_threshold`, and the number matters less than there being nothing to choose \u2014 one verb, any size, up to the 2 GiB a workspace may store. A large upload reads one part at a time, so memory stays flat regardless of file size, and a failure part-way abandons the parts already sent rather than leaving them billable and invisible. A directory expands to its immediate files; `--as <name>` renames a single upload. **Three alternative byte sources, for a caller that never had the bytes on disk** \u2014 an attachment decoded in memory, a generated document, a signed download link \u2014 each mutually exclusive with the others and with a path argument: `--stdin` takes raw bytes on stdin, `--base64` takes base64 on stdin (the shape attachments arrive in), `--url <url>` fetches the URL first. `--stdin`/`--base64` REQUIRE `--as`, because stdin carries no filename and the mime type is derived from it; `--url` falls back to `Content-Disposition` then the URL's last path segment. `--base64` decodes STRICTLY \u2014 `Buffer.from(s, \"base64\")` silently skips invalid characters and truncates on bad padding, so a corrupted pipe would otherwise store a short file that only fails when a human opens it. The `--url` fetch happens in the CLI, not the server: the URL comes from the operator running the command, so routing it through the backend would add an SSRF surface to buy what `curl` already does. |\n| `lotics file download <file_id> [<path>]` \xB7 `-o <dir>` | (alias `lotics download`) Download a stored file: `GET /v1/files/{id}/signed_url` \u2192 fetch the presigned URL and write it where you asked. **The two spellings mean two different things, and neither is read by shape: the positional `<path>` is the FILE to write, `-o <dir>` is the DIRECTORY to save into.** That is `cp` and `curl -o`, so nothing here consults an extension. A named file is written as named, its parent created, overwriting what is there \u2014 the point of naming it is that the next command opens that exact path. A directory is created if missing and written into under the stored filename (the response's `Content-Disposition`), taking a free spelling beside a file of that name already there so a repeat download never clobbers the first; with no destination at all, that filename lands in cwd. Give the destination once \u2014 a positional and `-o` together is refused, as is a positional that names an existing directory or ends in a separator (`a directory goes in -o`). The first argument is a **file id**, so a path in that slot is refused rather than sent as an id. The written path goes to **stdout** (under `--json`, `{file_id, path, filename, stored_filename}`) and the narration to stderr, so a download pipes into whatever opens it. `lotics file download record <record_id> <field_key> [-o <dir>]` spreads every file on a record's file field over a DIRECTORY \u2014 there is no single file for N files to be. |\n| `lotics file list [--limit <n>] [--cursor <token>]` | The workspace's files, newest first \u2014 id, upload time, bytes, MIME type, filename on stdout, one per line (`--json` for the object). `GET /v1/files` with no `file_ids`. A file holding the content of a knowledge doc or template you cannot use is left out. **A page, not a dump**: the store only ever grows, so the last line prints the command for the next page and `next_cursor` is null on the last one. The cursor is opaque and keyset \u2014 pass it back as given \u2014 so an upload landing mid-sweep cannot make a walk skip or repeat a row. Every other file verb takes an id, so this is the only answer to \"what is in here\" short of reading Postgres. |\n| `lotics file delete <file_id>` | Archive a stored file, over the `delete_file` tool. **Refused while a record cell, a comment, a knowledge doc, a document template or a voice session still references it** \u2014 the refusal names the referents, so this is safe to try. The bytes are left in object storage; the row no longer serves them, which is what \"deleted\" means here. There is no `lotics delete`: the verb needs its noun. |\n| `lotics knowledge list [--include-hidden]` | `GET /v1/knowledge_docs` \u2014 a table of id, name, tags, description (`--json` for the docs). **REST, not the `list_knowledge` tool**: the tool answers what the ASSISTANT may browse, and a hidden doc is out of that corpus by definition, so a tool-backed listing could never show one and the person who hid it would have no way back to it. Hidden docs are left out unless `--include-hidden` asks; those rows are marked `(hidden)`. |\n| `lotics knowledge create --name <n> [--description <d>] [--tags <a,b>] (--from <file.md> \\| --content <str>)` | Read the body client-side (a file XOR an inline string \u2014 exactly one required), then call `create_knowledge` with `{ name, description, content, tags? }` (description defaults to `\"\"`). `--tags` files the doc as it is made, which is the only moment a corpus reliably gets labelled. Prints the new id to stdout. Large files ride the POST body fine. |\n| `lotics knowledge get <id> [-o <file.md>]` | `GET /v1/knowledge_docs/{id}` (`getKnowledgeDoc`) \u2192 the doc with its **hydrated `content`** (the one content-read path for a non-sandbox client). `-o` writes the body via `writeFileAtomic`; else the body goes to stdout. `--json` prints the full doc instead. |\n| `lotics knowledge update <id> [--from <file.md> \\| --content <str>] [--name <n>] [--description <d>] [--tags <a,b>]` | Call `update_knowledge` with **only** the provided fields (a body from --from/--content becomes `content`; the tool diffs + CASes the content change internally, so the CLI passes no `expected_content_file_id`). `--tags` REPLACES the doc's label set \u2014 the single-doc form, where the caller is looking at one doc and can state what it should carry. At least one field required; --from and --content are mutually exclusive. |\n| `lotics knowledge tag <id...> [--add <a,b>] [--remove <c,d>]` | `PATCH /v1/knowledge_docs` with `{ knowledge_doc_ids, add_tags?, remove_tags? }` \u2014 one transaction over the whole set. A **DIFF applied to each doc's own labels**, never a replacement: the docs named on one command line carry different labels, so one array across them would strip whatever the others were filed under. Removal matches case-insensitively; adding a label a doc already carries writes nothing. Ids may be separate arguments or comma-separated. At least one of --add/--remove required. |\n| `lotics knowledge hide <id...>` / `lotics knowledge unhide <id...>` | `PATCH /v1/knowledge_docs` with `{ knowledge_doc_ids, hidden }`. Hiding takes docs out of every **listing** \u2014 the Library's list, `list_knowledge`, and the corpus `grep_knowledge` searches \u2014 while leaving IAM untouched and keeping them readable **by id** (`read_knowledge` with an id, a code run staging one, an app agent's declared set). So it can never silently break an app that depends on a doc, and unhiding costs nothing. Refuses the no-argument form rather than reading it as \"everything\". |\n| `lotics knowledge rm <id>` | Archive the doc via `delete_knowledge` (`{ knowledge_doc_id }`). The REST execute path does not gate `needsApproval`, so this runs unattended. |\n| `lotics setup <model.json> [--email <addr>] [--json]` | **The whole first run, in one command.** Creates an account when this machine has no credential (the same call `auth signup` makes \u2014 `--name` and `--timezone` apply), then applies the model to its workspace, then prints the one-time sign-in link. **When that email already has an account it hands over to the `lotics auth login` flow** \u2014 it prints the sign-in page to open and the code it must show, and **exits 1 having created nothing**; the person presses Confirm and runs the same command again, which collects the key and carries on into the model. (`--wait` holds the terminal through the Confirm instead, finishing in one command.) The re-run is not refused for naming an `--email` it is now signed in as \u2014 that address IS the account it holds, not a second one. The file is a workspace MODEL, and it is read and checked before an account is created, because a file with a typo in it must not leave an organization behind. Then it is `lotics model apply` run on the new workspace: its tables, rows and apps; the sign-in link lands on its app when it has one, else on the workspace's app list. It exists because the two-command form has a seam where the FIRST command exists only to produce a credential for the second, and a caller pasting a prompt has to get both right. **`--email` is only for creating an account**: with a credential already resolvable it is REFUSED rather than obeyed, because the two can name different organizations and preferring either one silently writes into an org the caller did not name \u2014 the message says how to do each thing on purpose. Without it, `setup` applies the model to the account you already have. A path positional after the file is accepted and IGNORED with a warning \u2014 `setup` writes nothing to disk \u2014 so a prompt that passes one still runs. **`--json` prints one object on stdout and nothing else** \u2014 what `lotics model apply --json` does (`tables`; `apps`, each with `alias`, `app_id`, `version_id`, `origin`, `address` and `findings`; and `findings`) plus `organization_id`, `workspace_id` and `signin_url`, and a `warnings` array carrying everything the prose form would have said out of band, such as a sign-in link that could not be minted. A warning is never merely silenced: when the command fails with an error before it can emit, the ones it had collected go to stderr alongside it. Reachable with no install: `npx -y @lotics/cli setup \u2026`. |\n| `lotics model apply <model.json> [--app <alias> ...] [--plan] [--json]` | **The model, applied to this workspace, through the `apply_model` tool.** The file is read and checked with the validator the server runs, every problem in one run, before anything is uploaded. Documents a row attaches by a path beside the file are uploaded first and the rows sent with their `fil_` ids; a path this workspace already recorded keeps its id, so a re-apply uploads nothing twice. Then the tool adopts or creates every table (the table this workspace bound the entity to, else an existing table of the same label, is ADOPTED and given the fields, options and views it lacks; no stored value changes), writes first rows only where every bound table is empty, and mints a new version of each app the model declares \u2014 `--app` (repeatable, comma-separated) narrows which apps, while the tables are applied whole. Prints one line per app \u2014 alias, `app_id`, `created`/`updated` with the version minted or `unchanged`, and the address it is served at \u2014 then the model's notes once, as the server states them. **`--plan` writes nothing and uploads nothing**: it prints what the apply would do to the tables (what it would create, what it leaves as the workspace has it, what the two disagree about), which apps it would create or update, and what it would refuse an app for, exiting 1 when it would refuse one; workflow bodies are checked only at apply, since they name fields a plan has not created. **A rollback restores an app's earlier version** (`lotics run rollback_app`); table changes and data writes stay. Resolves and ANNOUNCES its workspace first. `--json` prints `{workspace_id, tables, apps, findings}` on stdout (with `--plan`, each table and app is what the apply would do), or `{ok: false, findings}` when the file does not check. |\n| `lotics model pull [-o <model.json>]` | **This workspace's model, rebuilt from what owns each part** \u2014 the tables, fields, options, templates and roles the workspace holds, how rows are recognised, and each app's body from its current version \u2014 through the `get_model` tool, as the file `model apply` reads: to stdout, or to the file `-o` names. What the workspace holds that a model cannot state is printed on stderr, never written into the file. Applying what it wrote changes nothing. |\n| `lotics app create <name> --custom [path]` | **A custom-code app**: creates the app (`POST /v1/apps`), scaffolds a Vite + React + TypeScript project into `[path]` (default `./<name>`, refused when not empty \u2014 before the app row exists) that depends on `@lotics/app-sdk` alone and draws with plain React, and installs it (`npm install --ignore-scripts`), then writes the declarations of the app's live bindings (`get_app_types`) into `.lotics/`, which the project's `tsconfig.json` includes. `package.json#lotics` names the app and its workspace, which is how `app deploy` in that directory finds both. The app has no version until the first `lotics app deploy`. `--custom` is required: an app the runtime draws from a model is made by `lotics model apply`. The SDK's reference is `node_modules/@lotics/app-sdk/AGENTS.md` inside the project. |\n| `lotics app deploy [-m <message>]` | **Build this directory and upload it as a new version of the live app.** Rewrites `.lotics/` with the declarations of the app's live bindings (`get_app_types`, replacing each file there), then runs the project's `npm run typecheck` (warned about when absent) and `npm run build`, tars the source (without `node_modules`, `dist`, `.git`, `*.tsbuildinfo`) and `dist/`, and posts both to `POST /v1/apps/{id}/versions` on the version `package.json#lotics.current_version_id` names, then stamps the new one there. The version carries the app's queries, workflows, agents and capabilities forward unchanged \u2014 those are written through their tools. A project with no `build` script, or whose `package.json#lotics` still declares `queries`, `workflows`, `agents` or `capabilities`, is refused before anything is built; the refusal names the tool that sets each. **A 409 because another version went live since this directory's last deploy** (a deploy from elsewhere, a rollback) prints the server's sentence and the version that is live; to ship this directory over it, set `current_version_id` to that version and deploy again. `-m` (or a bare positional) is the version's message, optional. The workspace comes from `package.json#lotics.workspace_id` unless `--workspace` / `LOTICS_WORKSPACE` names another. |\n| `lotics docs` \\| `lotics docs <area>[/<section>]` | **This CLI's own references, carried inside the binary** \u2014 the model reference, this index, and every doc under `docs/` \u2014 so the doc a reader opens always describes the binary answering. Capped at ONE PAGE: a doc that does not fit prints its opening and the addresses of what it holds (`lotics docs <area>/<section>`, each section's size beside it, or a table's row names), and every address prints within a page. A custom-code app's SDK reference ships inside `@lotics/app-sdk` in the app's `node_modules`. |\n| `lotics upgrade` | Update this CLI in place. Runs the same installer a person would, chosen by how THIS copy arrived: an npm install upgrades through npm, a script install re-runs the script \u2014 the runtime knows which (the executable is compiled, the npm bin runs under node), so nobody has to. It downloads nothing itself; resolving a version, verifying the checksum and replacing a running executable already exist in the installers, and a second copy of that inside the binary would be a second thing to get right. Replacing the binary while it runs is safe \u2014 a rename leaves the running image mapped on unix, and on Windows the installer moves the old aside precisely because the file is in use. Already current is a no-op that says so. Needs no auth. |\n| `lotics docs model` \\| `lotics docs model/<section>[/\u2026]` | **The model reference, from inside the binary** \u2014 the one doc this CLI carries rather than resolves, because it describes this CLI's own model checker; listed first by `lotics docs`, at this CLI's version. Its first page is what a model composes with, the working order (jobs \u2192 entities and fields \u2192 `records` \u2192 one app per job \u2192 `model apply`) and the section addresses; every page of it is whole. Every top-level key of a `model.json`, every field `type` the contract admits with the config each one needs, the option / view / role / inline-template shapes, `records` (how a row of each entity is recognised), `write_rules`, `apps` (each register, record, act and check), the row format (relative dates `@today` / `@month-start` with whole-day offsets; links as `\"<entity-alias>:<ref>\"`), the rules, and one complete worked example; it points at `https://lotics.ai/presets/index.json` for complete example models of several trades. **Offline, no account.** |\n| `lotics report '<json>'` \\| `lotics report @report.json` | File a report with the Lotics team about what got in your way. **Covers the classes telemetry structurally cannot see**: a capability that does not exist (no command ran, so nothing was recorded), a command that exited 0 having done the wrong thing, an error whose message did not name the remedy, and anything that made authoring slower than it should be. **A frame, not a paragraph** \u2014 `{goal, actual, expected?, tried?, wanted?}`, `goal` and `actual` required, unknown keys dropped rather than refused. **No severity or category.** Ingest is inline JSON, `@file`, or `-` for stdin. A bare sentence is refused with the frame printed beside it, so the fix is one step; a bare invocation prints the frame BEFORE asking for a credential, since someone whose key will not resolve is exactly who has something to report. **Not spooled**: unlike telemetry it posts inline, prints whether it landed, and exits non-zero if it did not, echoing the report back so a failed send never loses it. Runs regardless of `LOTICS_TELEMETRY` \u2014 invoking it IS the consent that passive collection needs an opt-in for \u2014 but with telemetry off there are no recorded commands to attach, and it says so rather than implying context it does not have. Requires auth. Never paste records, file contents, or credentials. **Prints the id of each frame filed** \u2014 a filing nobody can cite cannot be answered about. The ids come from the server, so an instance that only logs the frames prints the count alone; the CLI never mints one of its own, which would hand back a token that resolves to nothing. |\n\n";
|
|
40293
|
+
var cli_reference_default = "# @lotics/cli \u2014 CLI Command Reference\n\nPer-command syntax, flags, contracts, and gotchas for the public `lotics` CLI. Start at [AGENTS.md](../AGENTS.md) for the model this reference assumes; `lotics --help` is the authoritative, always-current verb list.\n\n| Command | What it does |\n|---|---|\n| `lotics` / `lotics --help` | Show full help: capabilities, the verb list (\xA7 COMMANDS), flags, config. `lotics <verb> --help` prints that verb's entries alone (`lotics model --help`, `lotics file download --help`); `lotics report --help` prints the report frame. |\n| `lotics auth signup <email>` | Create account + org + API key, sends magic link email. Registers the new org as a profile; `--local` pins this directory to it (pointer) instead of setting the global default. |\n| `lotics auth login <email>` | Sign in an account that already exists, on a machine holding no key. **Two steps, and it does not wait for the person.** The first prints the page to open \u2014 `https://lotics.ai/cli_login/<request_id>`, also mailed \u2014 and the code that page must show, records the request, and exits 0. They sign in there if asked, check the code and press Confirm. **Then the next command that needs a credential collects the key** before it does its own work, so the second step is just re-running whatever was wanted; a command run before Confirm exits 1 naming the page and the code again, and once the 15 minutes are up it says to ask again. The handful that run WITHOUT a credential \u2014 `docs` among them \u2014 claim nothing, so one of those run after Confirm still answers as though signed out. `--wait` keeps one command instead, holding the terminal until Confirm; `--local` pins this directory to that org rather than setting the global default, and implies `--wait` (a pin names THIS directory, so only the terminal that stays in it can write one). `--json` prints `organization_id`, `workspace_id` and `organization_name` when it finishes signed in, and `request_id`, `confirm_url`, `code`, `email`, `expires_at` when it is the first step. The request's secret is never printed and the org's key never leaves the store. |\n| `lotics auth api-key [key]` | `whoami` \u2192 **upsert** the key's org as a profile in the global store (never overwrites). The profile records the instance the key was verified against (`LOTICS_API_URL`, default `https://api.lotics.ai`), and every later command for that org goes there. `--local` additionally pins this directory to it (pointer) instead of setting the global default. |\n| `lotics auth web` | Send a magic link email to access the web app (requires auth) |\n| `lotics auth whoami` | Print active account name, email, org, resolved workspace, the instance the credential belongs to, which **kind** of credential this machine holds (a sign-in from `auth login`, or an API key \u2014 read from the saved profile, and from the server when the profile does not say, which covers `--api-key`/`LOTICS_API_KEY` and a profile saved before the field existed; unknown only when neither can say), and the resolution **source** (flag/env/local/app-manifest/global). `--json` adds `workspace_id`, `api_url`, `credential_kind` + `source`. |\n| `lotics auth logout [<name\\|id>]` | In a pinned dir: delete the local pin. Else: remove the profile (default the active org), `--all` for every one. What happens server-side depends on which KIND of credential it is. A **sign-in** (`auth login` / `auth signup`) is revoked \u2014 logging that terminal out ends its credential rather than leaving a live one behind; a server that cannot be reached, or a credential already dead, never blocks the local forget, and one line names the org and Settings \u2192 Security \u2192 *Keys and terminals*. An **API key** (`auth api-key`) is only forgotten here \u2014 an admin issued it and it is routinely on a server and on other machines, so one terminal signing out must not kill it for everyone; the line says it is still active and names both pages, because Settings \u2192 API keys is admin-only and the credential may well be the holder's own sign-in, which they revoke themselves at Settings \u2192 Security \u2192 *Keys and terminals*. A profile saved before the kind was recorded states nothing, so the SERVER is asked (`auth whoami`) and it is revoked only if the answer is a sign-in: an older server, a credential minted before the column, and a request that fails all leave it alone. |\n| \u2014 | **A refused credential says which of three ways it is dead, and names the remedy that ends its kind.** `This credential expired.` / `was revoked.` / `belongs to a member who is no longer active in this organization.` carries `Run \\`lotics auth login <email>\\` to sign in again.` for a sign-in and `Ask an admin for a new API key (Settings \u2192 API keys).` for an issued key. A credential minted before that was recorded still gets BOTH in one sentence, because nothing on the row tells them apart \u2014 so a headless box is never sent looking for a browser alone. A key the server does not recognize at all gets one flat `Invalid or disabled API key.` \u2014 deliberately, so a guessed key learns nothing, not even that it named a row. The body carries `reason` for a script to branch on, since the code stays `unauthorized` for every 401. |\n| `lotics org` | List saved orgs (profiles) from the global store with the instance each belongs to, marks active for this directory (a local pin wins over the global default). |\n| `LOTICS_ORG=<name\\|id>` | Scope every command in this shell to one saved org. **Resolved once, before any command dispatches**, so a value matching no saved credential refuses every verb with one sentence \u2014 a read, a write, and a local check that needs no credential alike \u2014 and refuses it before the first byte is written. It refuses even when a credential arrives another way, because `--api-key` / `LOTICS_API_KEY` outrank it in the precedence chain and a write must never fall through to whatever THOSE name while the variable says otherwise; when the variable resolves and a key is also given, the key decides and the command says so. The refusal lists the orgs this machine holds, so it is answerable without another command (`lotics org` is refused by the same rule). A name is whatever the credential was SAVED under \u2014 a server-side rename never moves it, and the new name resolves too, so both keep working and `lotics org` prints the pair. |\n| `lotics org use <name\\|id> [--local]` | Switch the active org by org name (case-insensitive, ambiguous \u2192 error) or id. No flag \u2192 global `active_org`; `--local` \u2192 a `.lotics/config.json` pointer in the current dir. |\n| `lotics workspace` | List workspaces in the active org, marks current with `(current)` |\n| `lotics workspace select <id>` | Set the workspace in the **active scope** \u2014 a local pin if the dir has one, else the active org's global profile. Records the workspace's NAME beside its id, which is what the `lotics \u2192 <org> / <workspace>` echo prints; `workspace list`, `workspace create`, `workspace rename` and `org use` record it too, so a target is named rather than identified. Until one command has listed it, the echo prints the id and says the name is not known yet. |\n| `lotics workspace create <name> [--timezone <Area/City>] [--currency <ISO>]` | Create a new workspace (admin only), auto-switches to it. Neither flag is defaulted from THIS machine, unlike signup: an extra workspace is routinely created by an operator for somebody else. Without `--timezone` the new workspace inherits the zone of the org's OLDEST workspace; without `--currency` it takes the org's default. Both ride the create, so the workspace is never briefly denominated in a currency nobody asked for. `--currency` takes an ISO-4217 code (case-insensitive; anything else is refused). |\n| `lotics workspace rename <name>` | Rename the **current** workspace (admin only) \u2014 the endpoint takes its target from the request's workspace, never a path id, so switch with `workspace select <id>` first and read the `lotics \u2192 <org> / <workspace>` echo before trusting it \u2014 both halves are names, and the rename moves the cached one in the same act. Carries the workspace's existing `default_currency` and `timezone` through unchanged: the endpoint takes the whole settings triple, so sending only a name would blank the other two. |\n| `lotics workspace settings [--name <n>] [--currency <ISO>] [--timezone <Area/City>]` | Change the CURRENT workspace's name, default currency or timezone \u2014 `PATCH /v1/workspace`, admin only. Only what you name changes; the endpoint takes the whole triple, so the CLI carries the two you did not. `rename` is this verb with the name alone, which is why it can never forget the other two. Both values are invisible once they are wrong: the currency decides how every money field RENDERS and the zone decides how every date BUCKETS, on a workspace whose whole purpose may be to look like the customer's own. `--json` prints the updated workspace. |\n| `lotics workspace delete <id> --yes` | Delete a workspace by id (admin only). **Soft delete** \u2014 `archived_at` is set, so it drops out of listings, can no longer be selected, and its tables/records go dark, while the data is retained and recoverable. Its **apps are cascade-archived** too \u2014 every app entry point (embedded, public link, standalone subdomain, incl. anonymous public links) stops serving. Refuses the org's **only** active workspace (400) and any workspace outside the caller's org (404). Requires `--yes` to confirm (destructive; the CLI is used non-interactively). |\n| `lotics workspace doctor` | Report workspace-wide dangling schema references via `GET /v1/workspaces/dangling-references` \u2014 every active app/workflow artifact whose prefixed schema id no longer resolves, printed as `<referent.kind> \"<name>\" (<id>) \u2192 <namespace> <id> (missing)`; healthy prints a one-line all-clear. **Exits non-zero (exit 1) on findings** so scripts can gate on it. Resolves the first workspace like every data command (runs before the global workspace resolution). Admin-only. |\n| `lotics tools` | List tools by category with descriptions |\n| `lotics tools <name>` | Full description + JSON Schema for one tool |\n| `lotics run <tool> '<json>'` | Execute a tool (text output via toModelOutput). Args may also come from a file (`lotics run <tool> @args.json`) or stdin behind the `-` sentinel (`cat args.json \\| lotics run <tool> -`) \u2014 both bypass the OS `ARG_MAX` limit for large payloads (a knowledge-doc `content`, a bulk update). A leading `@` on the args is unambiguously a file path (JSON args start with `{`). **Stdin is asked for, never guessed.** `lotics run <tool>` with no payload runs the tool with no arguments and returns at once. `lotics report` takes the same sentinel. In PowerShell use `@file`: quotes inside an inline argument are consumed by the shell, and the CLI reports the JSON it received with its quotes gone \u2014 the error names both escapes. |\n| `lotics run <tool> --json '<json>'` | Execute a tool (full JSON output) |\n| `lotics run <tool>` \u2014 **file cells** | A file in a tool's result carries its `fil_\u2026` id and metadata and **no `url`**, on every tool and in both output modes. That is not a broken file \u2014 this surface resolves no URL for a cell. Reach the bytes with `lotics file download <file_id>`, which takes the id straight from the cell; the text output says so whenever a result carries one. |\n| \u2014 | **Every tool is invoked here, including the ones that RUN something** (`run_app_workflow`, `run_app_agent`, `run_app_query`) and every one that changes an app (`set_app_query`, `set_app_workflow`, `set_app_agent`, `update_app`, `rollback_app`). A command exists only for work that touches a local file: `model apply`, `model pull`, `app create --custom`, `app pull`, `app deploy`. |\n| \u2014 | **The exit code reports the WORK, not just the call \u2014 for the two tools that RUN one.** `run_app_workflow` and `run_app_agent` whose envelope carries a failed `status` (`error`/`failed`/`cancelled`) exit non-zero and print `<tool> \u2192 <status>: <message>` to stderr, so `lotics run \u2026 && next-step` cannot walk past a refused run. The rule is an allowlist of FAILURE \u2014 an unrecognized status exits 0, so a status added later never turns a working script red. A parked run (`awaiting_input`) is not a failure: it is waiting for an answer and the work is still live. Only a TOP-LEVEL `status` counts; one inside the data belongs to the data. Any OTHER tool's `status` is data, and exits 0. |\n| `lotics run <tool> --print-created` | Report the records the call created, grouped by table, with a paste-ready `delete_records` per table and the mandatory caveat naming what cannot be auto-undone (external integrations, notifications, possible sub-workflows). Works for any tool that returns a `side_effects` block, not workflows alone. |\n| `lotics run <tool> --cleanup` | Implies `--print-created`, then runs those deletes \u2014 harvested records **only**, never files / external calls / notifications. **Not a rollback**; a rollback is structurally impossible here. A partial cleanup exits non-zero so a script cannot read it as success. |\n| `lotics file upload <file\\|dir...>` (alias `lotics upload`) \xB7 `--stdin` \xB7 `--base64` \xB7 `--url <url>` | Upload files/directories. **The transport is chosen by size and is not a flag**: under 8 MiB the file is POSTed to `/v1/files` in one request, and several such files go in the same one; at or above it the CLI takes presigned part URLs and PUTs the bytes straight to object storage, so they never pass through the API. That threshold matches the AWS CLI's own `multipart_threshold`, and the number matters less than there being nothing to choose \u2014 one verb, any size, up to the 2 GiB a workspace may store. A large upload reads one part at a time, so memory stays flat regardless of file size, and a failure part-way abandons the parts already sent rather than leaving them billable and invisible. A directory expands to its immediate files; `--as <name>` renames a single upload. **Three alternative byte sources, for a caller that never had the bytes on disk** \u2014 an attachment decoded in memory, a generated document, a signed download link \u2014 each mutually exclusive with the others and with a path argument: `--stdin` takes raw bytes on stdin, `--base64` takes base64 on stdin (the shape attachments arrive in), `--url <url>` fetches the URL first. `--stdin`/`--base64` REQUIRE `--as`, because stdin carries no filename and the mime type is derived from it; `--url` falls back to `Content-Disposition` then the URL's last path segment. `--base64` decodes STRICTLY \u2014 `Buffer.from(s, \"base64\")` silently skips invalid characters and truncates on bad padding, so a corrupted pipe would otherwise store a short file that only fails when a human opens it. The `--url` fetch happens in the CLI, not the server: the URL comes from the operator running the command, so routing it through the backend would add an SSRF surface to buy what `curl` already does. |\n| `lotics file download <file_id> [<path>]` \xB7 `-o <dir>` | (alias `lotics download`) Download a stored file: `GET /v1/files/{id}/signed_url` \u2192 fetch the presigned URL and write it where you asked. **The two spellings mean two different things, and neither is read by shape: the positional `<path>` is the FILE to write, `-o <dir>` is the DIRECTORY to save into.** That is `cp` and `curl -o`, so nothing here consults an extension. A named file is written as named, its parent created, overwriting what is there \u2014 the point of naming it is that the next command opens that exact path. A directory is created if missing and written into under the stored filename (the response's `Content-Disposition`), taking a free spelling beside a file of that name already there so a repeat download never clobbers the first; with no destination at all, that filename lands in cwd. Give the destination once \u2014 a positional and `-o` together is refused, as is a positional that names an existing directory or ends in a separator (`a directory goes in -o`). The first argument is a **file id**, so a path in that slot is refused rather than sent as an id. The written path goes to **stdout** (under `--json`, `{file_id, path, filename, stored_filename}`) and the narration to stderr, so a download pipes into whatever opens it. `lotics file download record <record_id> <field_key> [-o <dir>]` spreads every file on a record's file field over a DIRECTORY \u2014 there is no single file for N files to be. |\n| `lotics file list [--limit <n>] [--cursor <token>]` | The workspace's files, newest first \u2014 id, upload time, bytes, MIME type, filename on stdout, one per line (`--json` for the object). `GET /v1/files` with no `file_ids`. A file holding the content of a knowledge doc or template you cannot use is left out. **A page, not a dump**: the store only ever grows, so the last line prints the command for the next page and `next_cursor` is null on the last one. The cursor is opaque and keyset \u2014 pass it back as given \u2014 so an upload landing mid-sweep cannot make a walk skip or repeat a row. Every other file verb takes an id, so this is the only answer to \"what is in here\" short of reading Postgres. |\n| `lotics file delete <file_id>` | Archive a stored file, over the `delete_file` tool. **Refused while a record cell, a comment, a knowledge doc, a document template or a voice session still references it** \u2014 the refusal names the referents, so this is safe to try. The bytes are left in object storage; the row no longer serves them, which is what \"deleted\" means here. There is no `lotics delete`: the verb needs its noun. |\n| `lotics knowledge list [--include-hidden]` | `GET /v1/knowledge_docs` \u2014 a table of id, name, tags, description (`--json` for the docs). **REST, not the `list_knowledge` tool**: the tool answers what the ASSISTANT may browse, and a hidden doc is out of that corpus by definition, so a tool-backed listing could never show one and the person who hid it would have no way back to it. Hidden docs are left out unless `--include-hidden` asks; those rows are marked `(hidden)`. |\n| `lotics knowledge create --name <n> [--description <d>] [--tags <a,b>] (--from <file.md> \\| --content <str>)` | Read the body client-side (a file XOR an inline string \u2014 exactly one required), then call `create_knowledge` with `{ name, description, content, tags? }` (description defaults to `\"\"`). `--tags` files the doc as it is made, which is the only moment a corpus reliably gets labelled. Prints the new id to stdout. Large files ride the POST body fine. |\n| `lotics knowledge get <id> [-o <file.md>]` | `GET /v1/knowledge_docs/{id}` (`getKnowledgeDoc`) \u2192 the doc with its **hydrated `content`** (the one content-read path for a non-sandbox client). `-o` writes the body via `writeFileAtomic`; else the body goes to stdout. `--json` prints the full doc instead. |\n| `lotics knowledge update <id> [--from <file.md> \\| --content <str>] [--name <n>] [--description <d>] [--tags <a,b>]` | Call `update_knowledge` with **only** the provided fields (a body from --from/--content becomes `content`; the tool diffs + CASes the content change internally, so the CLI passes no `expected_content_file_id`). `--tags` REPLACES the doc's label set \u2014 the single-doc form, where the caller is looking at one doc and can state what it should carry. At least one field required; --from and --content are mutually exclusive. |\n| `lotics knowledge tag <id...> [--add <a,b>] [--remove <c,d>]` | `PATCH /v1/knowledge_docs` with `{ knowledge_doc_ids, add_tags?, remove_tags? }` \u2014 one transaction over the whole set. A **DIFF applied to each doc's own labels**, never a replacement: the docs named on one command line carry different labels, so one array across them would strip whatever the others were filed under. Removal matches case-insensitively; adding a label a doc already carries writes nothing. Ids may be separate arguments or comma-separated. At least one of --add/--remove required. |\n| `lotics knowledge hide <id...>` / `lotics knowledge unhide <id...>` | `PATCH /v1/knowledge_docs` with `{ knowledge_doc_ids, hidden }`. Hiding takes docs out of every **listing** \u2014 the Library's list, `list_knowledge`, and the corpus `grep_knowledge` searches \u2014 while leaving IAM untouched and keeping them readable **by id** (`read_knowledge` with an id, a code run staging one, an app agent's declared set). So it can never silently break an app that depends on a doc, and unhiding costs nothing. Refuses the no-argument form rather than reading it as \"everything\". |\n| `lotics knowledge rm <id>` | Archive the doc via `delete_knowledge` (`{ knowledge_doc_id }`). The REST execute path does not gate `needsApproval`, so this runs unattended. |\n| `lotics setup <model.json> [--email <addr>] [--json]` | **The whole first run, in one command.** Creates an account when this machine has no credential (the same call `auth signup` makes \u2014 `--name` and `--timezone` apply), then applies the model to its workspace, then prints the one-time sign-in link. **When that email already has an account it hands over to the `lotics auth login` flow** \u2014 it prints the sign-in page to open and the code it must show, and **exits 1 having created nothing**; the person presses Confirm and runs the same command again, which collects the key and carries on into the model. (`--wait` holds the terminal through the Confirm instead, finishing in one command.) The re-run is not refused for naming an `--email` it is now signed in as \u2014 that address IS the account it holds, not a second one. The file is a workspace MODEL, and it is read and checked before an account is created, because a file with a typo in it must not leave an organization behind. Then it is `lotics model apply` run on the new workspace: its tables, rows and apps; the sign-in link lands on its app when it has one, else on the workspace's app list. It exists because the two-command form has a seam where the FIRST command exists only to produce a credential for the second, and a caller pasting a prompt has to get both right. **`--email` is only for creating an account**: with a credential already resolvable it is REFUSED rather than obeyed, because the two can name different organizations and preferring either one silently writes into an org the caller did not name \u2014 the message says how to do each thing on purpose. Without it, `setup` applies the model to the account you already have. A path positional after the file is accepted and IGNORED with a warning \u2014 `setup` writes nothing to disk \u2014 so a prompt that passes one still runs. **`--json` prints one object on stdout and nothing else** \u2014 what `lotics model apply --json` does (`tables`; `apps`, each with `alias`, `app_id`, `version_id`, `origin`, `address` and `findings`; and `findings`) plus `organization_id`, `workspace_id` and `signin_url`, and a `warnings` array carrying everything the prose form would have said out of band, such as a sign-in link that could not be minted. A warning is never merely silenced: when the command fails with an error before it can emit, the ones it had collected go to stderr alongside it. Reachable with no install: `npx -y @lotics/cli setup \u2026`. |\n| `lotics model apply <model.json> [--app <alias> ...] [--plan] [--json]` | **The model, applied to this workspace, through the `apply_model` tool.** The file is read and checked with the validator the server runs, every problem in one run, before anything is uploaded. Documents a row attaches by a path beside the file are uploaded first and the rows sent with their `fil_` ids; a path this workspace already recorded keeps its id, so a re-apply uploads nothing twice. Then the tool adopts or creates every table (the table this workspace bound the entity to, else an existing table of the same label, is ADOPTED and given the fields, options and views it lacks; no stored value changes), writes first rows only where every bound table is empty, and mints a new version of each app the model declares \u2014 `--app` (repeatable, comma-separated) narrows which apps, while the tables are applied whole. Prints one line per app \u2014 alias, `app_id`, `created`/`updated` with the version minted or `unchanged`, and the address it is served at \u2014 then the model's notes once, as the server states them. **`--plan` writes nothing and uploads nothing**: it prints what the apply would do to the tables (what it would create, what it leaves as the workspace has it, what the two disagree about), which apps it would create or update, and what it would refuse an app for, exiting 1 when it would refuse one; workflow bodies are checked only at apply, since they name fields a plan has not created. **A rollback restores an app's earlier version** (`lotics run rollback_app`); table changes and data writes stay. Resolves and ANNOUNCES its workspace first. `--json` prints `{workspace_id, tables, apps, findings}` on stdout (with `--plan`, each table and app is what the apply would do), or `{ok: false, findings}` when the file does not check. |\n| `lotics model pull [-o <model.json>]` | **This workspace's model, rebuilt from what owns each part** \u2014 the tables, fields, options, templates and roles the workspace holds, how rows are recognised, and each app's body from its current version \u2014 through the `get_model` tool, as the file `model apply` reads: to stdout, or to the file `-o` names. What the workspace holds that a model cannot state is printed on stderr, never written into the file. Applying what it wrote changes nothing. |\n| `lotics app create <name> --custom [path]` | **A custom-code app**: creates the app (`POST /v1/apps`), scaffolds a Vite + React + TypeScript project into `[path]` (default `./<name>`, refused when not empty \u2014 before the app row exists) that depends on `@lotics/app-sdk` alone and draws with plain React, and installs it (`npm install --ignore-scripts`), then writes the declarations of the app's live bindings (`get_app_types`) into `.lotics/`, which the project's `tsconfig.json` includes. `package.json#lotics` names the app and its workspace, which is how `app deploy` in that directory finds both. The app has no version until the first `lotics app deploy`. `--custom` is required: an app the runtime draws from a model is made by `lotics model apply`. The SDK's reference is `node_modules/@lotics/app-sdk/AGENTS.md` inside the project. |\n| `lotics app pull [app_id] [path]` | **A custom-code app's live source, as a project ready to deploy on it.** The target is `[path]`, else this directory when it is the app's project (or no app is named), else `./<name>`. **The app's own project is brought up to date in place** \u2014 but only when it holds no edit since the version `package.json#lotics.current_version_id` names: its source (packed as a deploy packs it) is compared with that version's archive, ignoring `.lotics/` and `package.json#lotics`, and any difference refuses the pull, naming the changed files; a pull never merges, so local work is never lost. Already at the live version is a no-op that says so. **An empty or new directory receives the source whole**; any other directory is refused. Downloads come from `GET /v1/apps/{id}/versions/{version_id}/source`. `package.json#lotics` is then set to exactly the app, its workspace and the pulled version, dropping every other key an older CLI wrote there, so the next `lotics app deploy` builds on the live version; then `.lotics/` is written (`get_app_types`) and dependencies installed (`npm ci` with a lockfile, else `npm install`, both `--ignore-scripts`). A JSON app has no source tree: the server's refusal names `get_model`, and `lotics model pull` is its pull. |\n| `lotics app deploy [-m <message>]` | **Build this directory and upload it as a new version of the live app.** Rewrites `.lotics/` with the declarations of the app's live bindings (`get_app_types`, replacing each file there), then runs the project's `npm run typecheck` (warned about when absent) and `npm run build`, tars the source (without `node_modules`, `dist`, `.git`, `*.tsbuildinfo`) and `dist/`, and posts both to `POST /v1/apps/{id}/versions` on the version `package.json#lotics.current_version_id` names, then stamps the new one there. The version carries the app's queries, workflows, agents and capabilities forward unchanged \u2014 those are written through their tools. A project with no `build` script, or whose `package.json#lotics` still declares `queries`, `workflows`, `agents` or `capabilities`, is refused before anything is built; the refusal names the tool that sets each. **A 409 because another version went live since this directory's last deploy** (a deploy from elsewhere, a rollback) prints the server's sentence and the version that is live, and names `lotics app pull`: run in this directory, it brings an unedited project up to date, and lists the files a project with edits changed, to carry over into a fresh pull. `-m` (or a bare positional) is the version's message, optional. The workspace comes from `package.json#lotics.workspace_id` unless `--workspace` / `LOTICS_WORKSPACE` names another. |\n| `lotics docs` \\| `lotics docs <area>[/<section>]` | **This CLI's own references, carried inside the binary** \u2014 the model reference, this index, and every doc under `docs/` \u2014 so the doc a reader opens always describes the binary answering. Capped at ONE PAGE: a doc that does not fit prints its opening and the addresses of what it holds (`lotics docs <area>/<section>`, each section's size beside it, or a table's row names), and every address prints within a page. A custom-code app's SDK reference ships inside `@lotics/app-sdk` in the app's `node_modules`. |\n| `lotics upgrade` | Update this CLI in place. Runs the same installer a person would, chosen by how THIS copy arrived: an npm install upgrades through npm, a script install re-runs the script \u2014 the runtime knows which (the executable is compiled, the npm bin runs under node), so nobody has to. It downloads nothing itself; resolving a version, verifying the checksum and replacing a running executable already exist in the installers, and a second copy of that inside the binary would be a second thing to get right. Replacing the binary while it runs is safe \u2014 a rename leaves the running image mapped on unix, and on Windows the installer moves the old aside precisely because the file is in use. Already current is a no-op that says so. Needs no auth. |\n| `lotics docs model` \\| `lotics docs model/<section>[/\u2026]` | **The model reference, from inside the binary** \u2014 the one doc this CLI carries rather than resolves, because it describes this CLI's own model checker; listed first by `lotics docs`, at this CLI's version. Its first page is what a model composes with, the working order (jobs \u2192 entities and fields \u2192 `records` \u2192 one app per job \u2192 `model apply`) and the section addresses; every page of it is whole. Every top-level key of a `model.json`, every field `type` the contract admits with the config each one needs, the option / view / role / inline-template shapes, `records` (how a row of each entity is recognised), `write_rules`, `apps` (each register, record, act and check), the row format (relative dates `@today` / `@month-start` with whole-day offsets; links as `\"<entity-alias>:<ref>\"`), the rules, and one complete worked example; it points at `https://lotics.ai/presets/index.json` for complete example models of several trades. **Offline, no account.** |\n| `lotics report '<json>'` \\| `lotics report @report.json` | File a report with the Lotics team about what got in your way. **Covers the classes telemetry structurally cannot see**: a capability that does not exist (no command ran, so nothing was recorded), a command that exited 0 having done the wrong thing, an error whose message did not name the remedy, and anything that made authoring slower than it should be. **A frame, not a paragraph** \u2014 `{goal, actual, expected?, tried?, wanted?}`, `goal` and `actual` required, unknown keys dropped rather than refused. **No severity or category.** Ingest is inline JSON, `@file`, or `-` for stdin. A bare sentence is refused with the frame printed beside it, so the fix is one step; a bare invocation prints the frame BEFORE asking for a credential, since someone whose key will not resolve is exactly who has something to report. **Not spooled**: unlike telemetry it posts inline, prints whether it landed, and exits non-zero if it did not, echoing the report back so a failed send never loses it. Runs regardless of `LOTICS_TELEMETRY` \u2014 invoking it IS the consent that passive collection needs an opt-in for \u2014 but with telemetry off there are no recorded commands to attach, and it says so rather than implying context it does not have. Requires auth. Never paste records, file contents, or credentials. **Prints the id of each frame filed** \u2014 a filing nobody can cite cannot be answered about. The ids come from the server, so an instance that only logs the frames prints the count alone; the CLI never mints one of its own, which would hand back a token that resolves to nothing. |\n\n";
|
|
40262
40294
|
|
|
40263
40295
|
// docs/data_model.md
|
|
40264
40296
|
var data_model_default = '# The data model \u2014 tables, their fields, and how they relate\n\nThe decisions here outlive any one app, and most become expensive the moment a second screen depends\non them. They stand apart from building an app on purpose: **every workspace starts with tables\nand many never get an app**, so schema design is not a chapter of app building.\n\nThe rules below share one signature. **Both sides read correctly on their own**, so nothing reports\nthe problem \u2014 no error, no empty column, no failing query. Each is found by looking for it. Then,\nunder **Fields**, what each field type takes.\n\n## One fact, one column\n\n**Read the table\'s existing fields first, and add nothing that stores a fact the table already\nstores.** A value written as text and the same value held as a `select_record_link` are one fact in\ntwo columns \u2014 a customer\'s city typed into a text box beside a link to the city record, a status\nword beside the select that decides it, a total beside the formula that computes it.\n\nTwo columns for one fact do not stay equal. Some writer sets only one of them, and nothing reports\nthe divergence: both rows still look correct on their own. A reader that then matches on the text\nhalf treats "Acme" and "Acme Ltd" as different records, so an import creates a duplicate every time\nit runs.\n\n- **Prefer the link, the select, or the formula.** Text is a RENDERING of a record; compose it when\n you read, rather than storing it a second time.\n- **Renaming, retyping or re-pointing the existing field beats adding another.** A field\'s key is\n stable, so a rename breaks nothing that addresses it by key.\n- **Superseding a field means DELETING it**, not leaving it beside its replacement with a\n description that says which one is real.\n- **Empty is not the same as redundant.** A field nothing fills may still be the only home for a\n real distinction \u2014 read what it MEANS before removing it.\n\n## One entity, one table \u2014 and the test is measurable\n\nVariation belongs in a column \u2014 a multi-select role, a kind, a stage \u2014 not in a second table. Two\ntables for one kind of thing give the same real-world entity two rows, two ids and two halves of its\nhistory, and each screen shows whichever half it happens to link to.\n\nSplit tables are often right. A supplier book beside a customer book is a normal shape, and merging\non suspicion is a large repoint bought for nothing. So do not argue it in the abstract:\n\n> **List both tables\' names and look for one that appears in both.**\n\nNone means the split is holding. One means it has broken \u2014 the usual cause is a party you begin to\ninvoice as well as buy from \u2014 and the fix is to merge before a second screen depends on the copy.\n\nWorth writing as a test rather than a note, because a note about a condition nobody re-checks goes\nstale in silence.\n\n## One vocabulary wherever values are COPIED between tables\n\nTwo `select` fields for one concept carry DIFFERENT option keys even when their labels match \u2014 keys\nare minted per field. So anything moving a value between them needs a hand-written key map.\n\nThat map is code. Put it in one named module with a test; written inline at the copy it is invisible,\nuntested, and silently wrong the first time somebody renames an option, because a rename leaves the\nkey intact and the map still compiling. Prefer a link to a shared reference table where the set is\nopen or growing; keep a map only for a small closed set.\n\nWhere one side genuinely holds MORE values than the other, that is not drift \u2014 it is the model\ntelling the truth. The wider side must **refuse** what the narrower one cannot express rather than\nquietly picking the nearest value.\n\n## A copy boundary accounts for EVERY source field\n\nEach field on the source gets a column on the destination, a deliberate drop with the reason written\ndown, or a refusal.\n\nA field with nowhere to land is data destroyed at the boundary, and it is invisible afterwards: the\ndestination is not empty and not obviously wrong \u2014 just a number that no longer agrees with where it\ncame from.\n\n## Provenance is a LINK, not a flag and not a copy\n\nA row created BY another row carries a link to it.\n\nThat link is what makes "is this the estimate or the actual", "where did this come from" and "have we\nalready imported this" answerable at all. A boolean records that something was true once; a link\nstays true, survives a rename, and lets the next write UPDATE the original instead of adding a second\nrow beside it.\n\n## Say what makes two rows the SAME row\n\nDeclare the natural key in the table\'s description.\n\nAnything that imports, reconciles or de-duplicates has to decide identity, and with no declared key\nit falls back to comparing displayed text \u2014 which is how one company arrives three times under three\nspellings. Name the key: a reference number, a tax id, a link plus a period. Then match on `rec_\u2026`\nand `opt_\u2026`, never on rendered labels.\n\n## A state\'s HISTORY is rows, not columns\n\nA `changed at` column says only how long a row has been where it is now \u2014 the next move overwrites\nit \u2014 and a date column per state holds until something re-enters a state it already left.\n\nMeasuring time-in-state or conversion needs one ROW per move: a link to the subject, the state left,\nthe state entered, when. Hold those states as the source field\'s own `opt_` keys so the log carries\nno second vocabulary, and write the rows from that table\'s own lifecycle workflows, which covers\nevery writer rather than one app\'s.\n\n## Keep derived chains shallow\n\nFormulas and rollups are computed and STORED when a row is written, and one that reads another\nrecomputes with it. A rollup over a formula over a formula is paid three times on every touch, and\nagain for every row upstream of it.\n\n**Depth costs more than row count.** This is the optimisation lever that actually exists here; row\nscanning is the platform\'s problem, chain depth is yours.\n\n## Changing a money formula on a live table\n\nFormula edits recompute every row, so the only honest proof that one changed nothing it should not is\nthe numbers themselves:\n\n1. Snapshot the affected totals to a file.\n2. Make the change.\n3. Diff. Identical is the pass.\n\nAnd when a formula gains a new field, **test that the value IS the one you want, never that it\ndiffers from it** \u2014 an empty cell reads as `""`, which differs from every option key, so the inverted\nspelling silently zeroes every row written before the field existed.\n\n## Fields\n\nWhat `create_table` and `update_table` take in `add_fields`, and `update_table` in `update_fields`.\n\n### Types and formats\n\n`type` is one of `text`, `number`, `date`, `boolean`, `select`, `select_member`,\n`select_record_link`, `files`, `formula`, `rollup`, `lookup`, `autonumber`. `button` is retired: an\nexisting button field keeps running, but none is created, converted to or edited.\n\nA URL, an email, markdown, a checkbox, a datetime, a currency or a percentage is a `format` on\nanother type, never a `type`:\n\n| Wanted | Field |\n|---|---|\n| URL or external link | `{ type: "text", format: "link" }` |\n| Email or phone | `{ type: "text" }` |\n| Markdown | `{ type: "text", format: "markdown" }` |\n| Checkbox | `{ type: "boolean" }` |\n| Money | `{ type: "number", format: "currency", currency: "USD" }` |\n| Percentage | `{ type: "number", format: "percentage" }` |\n| Datetime | `{ type: "date", format: "datetime" }` |\n| Date range | `{ type: "date", format: "date_range" }` |\n\n### Properties\n\nA type\'s properties sit **directly on the field object** \u2014 there is no `config` wrapper. A property\nmay itself hold an object (`formula`, `aggregate_option`, `filter`, `order_by`); its inner keys stay\ninside it. Each type takes only its own:\n\n- **text** \u2014 `format?` (`"text"` | `"link"` | `"markdown"`), `unique?`, `default_value?` (a string).\n- **number** \u2014 `format?` (`"number"` | `"currency"` | `"percentage"`), `currency?` (an ISO 4217\n code), `unit?`, `unit_field?`, `currency_field?`, `default_value?` (a number). On `update_fields`,\n null clears `currency`, `unit`, `unit_field` or `currency_field`.\n - `unit`, beside format `"number"`: a measured code \u2014 g, kg, t, l, m3, cbm, mm, cm, m, km, m2,\n min, h, day \u2014 or a counted noun such as `ki\u1EC7n`. A change between two units of one dimension\n converts every stored figure; any other change relabels.\n - `unit_field`: A single select on the same row, or a lookup of one through a one-link, whose chosen option is that row\'s unit: every option label is a unit as `unit` takes one. In place of `unit`; only beside format "number".\n - `currency_field`: A single select on the same row, or a lookup of one through a one-link, whose chosen option is that row\'s currency: every option label is an ISO 4217 code. In place of `currency`; only beside format "currency".\n- **date** \u2014 `format?` (`"date"` | `"datetime"` | `"date_range"` | `"datetime_range"`), `timezone?`,\n `derive_from?`, `default_value?` (a date string). `derive_from: "created_at"` stamps the row\'s\n creation once, `"updated_at"` re-stamps on every update; the field is then read-only, takes no\n `default_value`, and holds only the `date` and `datetime` formats.\n- **boolean** \u2014 `default_value?` (`true` | `false`).\n- **select** \u2014 `options` `[{ name, color?, mark? }]`, `multi?`, `default_value?`.\n- **select_member** \u2014 `multi?`, `default_value?` (member ids).\n- **select_record_link** \u2014 `table_id`, `display_field_keys?`, `sync_both_ways?`, `cardinality?`,\n `paired_field_name?`, `paired_field_display_field_keys?`. Without `display_field_keys` a link shows\n the target\'s autonumber, else its first text field. `sync_both_ways: true` on an existing one-way\n link makes it two-way \u2014 never add a second link for that. `cardinality: "one"` marks the child\n side of a parent-child pair (the linked record is this one\'s parent); two paired sides cannot both\n be `"one"`. The `paired_*` keys name and label the back-reference the target gets.\n- **files** \u2014 no properties.\n- **autonumber** \u2014 `template` (`"INV-{YEAR}-{N:4}"`; tokens `{N}`, `{N:W}` zero-padded to W,\n `{YEAR}`, `{YEAR:2}`, `{MONTH}`, `{DAY}`), or `prefix` + `padding` (1\u201320) for `PREFIX-0001`.\n- **any type**, at create \u2014 `confirm_before_update?`: a person confirms each later edit.\n\n`default_value` fills the field on a NEW record that states no value; existing records are never\nbackfilled, and null clears it. A select\'s or a member field\'s default is an ARRAY even when the\nfield holds one (`["Unread"]`, never `"Unread"`) \u2014 option names in `add_fields`, where the keys do\nnot exist yet, and option keys (`opt_\u2026`) in `update_fields`.\n\n### Computed fields\n\nRead-only in records. `formula` is one key holding an object; a rollup\'s and a lookup\'s properties\nare separate keys on the field \u2014 `rollup: {\u2026}` and `lookup: {\u2026}` are refused as unrecognized.\n\nformula: { expression, format?, currency?, unit?, unit_field?, currency_field? }\n\n- `{Field Name}` or `{fld_key}` names a field of the same table, stored as its key, so a rename never\n breaks the formula. A reference to no field, or a call to something that is no helper, is refused.\n- A select reads as an ARRAY of option keys: `{Status}[0]` for a single select,\n `includes({Tags}, "opt_\u2026")` for a multi.\n- The operators, the helpers and how an empty cell reads are the `model` reference, section `formula` \u2014 a model\n names fields by alias where a table tool names them by name or key; the language is the same.\n- On `update_fields` a key left out keeps its stored value, and null clears `currency`, `unit`,\n `unit_field` or `currency_field`. `get_table` marks a formula that is null when every field it\n reads is empty.\n\nrollup \u2014 `source_field_key`, `aggregate_option: { field_key?, operation }`, `filter?`\n\n- `source_field_key` is a `select_record_link` of this table; `aggregate_option.field_key` is a\n field of the linked table, required by every operation but `count`, which counts linked records.\n- Operations by the aggregated field\'s type \u2014 none: `count`; any: `empty`, `filled`,\n `percent_empty`, `percent_filled`, `unique`, `percent_unique`; number: `sum`, `avg`, `median`,\n `min`, `max`, `range`; date: `earliest`, `latest`, `date_range`.\n- The cell\'s type comes from the OPERATION: `earliest` and `latest` hold a date, every other\n operation a number (`date_range` counts days). A "most recent linked date" is `latest` \u2014 `min` and\n `max` are numeric and refuse a date. A rollup takes no format, currency or unit of its own: `sum`\n over money carries the aggregated field\'s currency, over a weight its unit.\n- Over a figure read in each row\'s own unit or currency (`unit_field` / `currency_field`), `sum`,\n `avg`, `median`, `min`, `max` and `range` hold only where that select is a lookup, through the\n link paired with `source_field_key`, of a single select on this table \u2014 the total reads in this\n row\'s option of it. A lookup of such a field has no unit.\n- `filter` aggregates only the linked records it matches: a full group over the LINKED table\'s\n fields \u2014 `{ node_type: "group", logic, children: [{ node_type: "condition", type, field_key,\n operator, value }] }`, a select condition using `has_any_of` with an ARRAY value.\n\nlookup \u2014 `source_field_key`, `lookup_field_key`, `order_by?`\n\n- `lookup_field_key` is a field of the linked table. Without `order_by` the cell holds every linked\n record\'s value; with `order_by: { field_key, direction }` (`desc` latest, `asc` earliest) it holds\n the picked field of the single extreme record \u2014 a "latest linked X" that maintains itself.\n Ordered lookups sharing one `order_by` resolve to the SAME record.\n\n### Examples\n\n```\n{ name: "Gi\xE1 b\xE1n", type: "number", format: "currency", currency: "VND" }\n{ name: "Tr\u1ECDng l\u01B0\u1EE3ng", type: "number", unit: "kg" }\n{ name: "S\u1ED1 l\u01B0\u1EE3ng", type: "number", unit_field: "\u0110VT" }\n{ name: "Tr\u1EA1ng th\xE1i", type: "select", options: [{ name: "M\u1EDBi" }, { name: "Xong" }] }\n{ name: "M\xE3 \u0111\u01A1n", type: "autonumber", template: "SR-{YEAR}-{N:4}" }\n{ name: "Kh\xE1ch h\xE0ng", type: "select_record_link", table_id: "tbl_x", sync_both_ways: true }\n{ name: "Total", type: "formula", formula: { expression: "{Price} * {Qty}", format: "currency", currency: "VND" } }\n{ name: "SL \u0111\xE3 giao", type: "rollup", source_field_key: "Giao h\xE0ng", aggregate_option: { operation: "sum", field_key: "S\u1ED1 l\u01B0\u1EE3ng" } }\n{ name: "Customer Name", type: "lookup", source_field_key: "Customer", lookup_field_key: "Name" }\n{ name: "Latest note", type: "lookup", source_field_key: "Calls", lookup_field_key: "Note", order_by: { field_key: "At", direction: "desc" } }\n```\n\n### Changing a field\n\nA field added by `update_table` shows in a view only when `add_to_views` names it, at the view\'s far\nright; `update_view` with `move_field` places it beside the columns it belongs with.\n\nAn `update_fields` entry is `{ field_key, name?, description?, convert_to?, \u2026 }` with the same flat\nproperties as `add_fields`, plus what only an update does:\n\n- A select\'s options change through `add_options` `[{ name, color?, mark? }]`, `update_options`\n `[{ key, name?, color?, mark? }]`, `remove_option_keys` and `reorder_options` (every existing\n option once, in order; options added in the same call follow). An option is named by its `opt_\u2026`\n key (its name also resolves); a rename keeps every record\'s value. `mark` is the option\'s brand\n (`{ kind: "brand", name: "tiktok" }`) or kit glyph (`{ kind: "icon", name: "wrench" }`), drawn in\n place of its colour dot \u2014 every option of a field has one or none does, so marking sets them all\n in one call and `mark: null` on each clears them.\n- A link\'s `sync_both_ways: false` disconnects the pair; a new `table_id` re-points it and clears its\n record data; `cardinality` is `"one"` for the child side of a parent-child pair, `"many"` for peers.\n';
|
|
@@ -40283,8 +40315,8 @@ var migration_default = "# @lotics/cli \u2014 migration notes\n\nWhat to DO when
|
|
|
40283
40315
|
|
|
40284
40316
|
// ../shared/src/doc_pages.ts
|
|
40285
40317
|
var DOC_LINKS = {
|
|
40286
|
-
cli: (
|
|
40287
|
-
tool: (
|
|
40318
|
+
cli: (path16) => `lotics docs ${path16}`,
|
|
40319
|
+
tool: (path16) => `docs path="${path16}"`
|
|
40288
40320
|
};
|
|
40289
40321
|
var SOURCES = [
|
|
40290
40322
|
{ area: "model", file: "src/model_reference.md", marker: "a workspace starts here", readers: ["cli", "tool"] },
|
|
@@ -40486,8 +40518,8 @@ function renderDocIndex(areas, usage) {
|
|
|
40486
40518
|
const lines = areas.map((a, index) => ` ${names[index].padEnd(width)}${titleOf(a.text)}`);
|
|
40487
40519
|
return [...lines, "", ...usage].join("\n");
|
|
40488
40520
|
}
|
|
40489
|
-
function readDocs(areas,
|
|
40490
|
-
const asked = (
|
|
40521
|
+
function readDocs(areas, path16, surface) {
|
|
40522
|
+
const asked = (path16 ?? "").trim();
|
|
40491
40523
|
if (asked === "") return { text: oneNewline(renderDocIndex(areas, surface.usage)) };
|
|
40492
40524
|
let hits = matchDocArea(areas, asked);
|
|
40493
40525
|
let section;
|
|
@@ -40498,11 +40530,11 @@ function readDocs(areas, path15, surface) {
|
|
|
40498
40530
|
section = asked.slice(cut + 1);
|
|
40499
40531
|
}
|
|
40500
40532
|
}
|
|
40501
|
-
if (hits.length === 0) return { error: `No reference area matching "${
|
|
40533
|
+
if (hits.length === 0) return { error: `No reference area matching "${path16}".
|
|
40502
40534
|
${surface.listHint}` };
|
|
40503
40535
|
if (hits.length > 1) {
|
|
40504
40536
|
return {
|
|
40505
|
-
error: `"${
|
|
40537
|
+
error: `"${path16}" matches more than one reference:
|
|
40506
40538
|
${hits.map((a) => ` \u2022 ${surface.link(a.area)}`).join("\n")}`
|
|
40507
40539
|
};
|
|
40508
40540
|
}
|
|
@@ -40683,7 +40715,11 @@ var COMMANDS = [
|
|
|
40683
40715
|
" @lotics/app-sdk, installed, and typed by the app's",
|
|
40684
40716
|
" bindings in .lotics/. An app stated in a model is made",
|
|
40685
40717
|
" by lotics model apply",
|
|
40686
|
-
" lotics app
|
|
40718
|
+
" lotics app pull [app_id] [path] The live version's source, installed and typed, ready to",
|
|
40719
|
+
" deploy on it: this app's project is brought up to date in",
|
|
40720
|
+
" place (refused, naming the files, when it has edits since",
|
|
40721
|
+
" its version); an empty or new [path] receives it whole",
|
|
40722
|
+
" lotics app deploy [-m <msg>] Rewrite .lotics/ from the app's live bindings, then",
|
|
40687
40723
|
" typecheck + build this directory and upload it as a new",
|
|
40688
40724
|
" version of the live app. Its queries, workflows, agents",
|
|
40689
40725
|
" and capabilities carry forward unchanged \u2014 each is set",
|
|
@@ -40749,8 +40785,8 @@ function helpEntries(help) {
|
|
|
40749
40785
|
}
|
|
40750
40786
|
return entries2;
|
|
40751
40787
|
}
|
|
40752
|
-
function commandHelp(
|
|
40753
|
-
const words =
|
|
40788
|
+
function commandHelp(path16) {
|
|
40789
|
+
const words = path16.filter((word) => word !== "");
|
|
40754
40790
|
if (words.length === 0) return null;
|
|
40755
40791
|
const group = COMMANDS.find(
|
|
40756
40792
|
(g) => g.verbs.includes(words[0]) || (g.aliases ?? []).includes(words[0])
|
|
@@ -40963,6 +40999,9 @@ function readAppWorkspaceId(projectDir) {
|
|
|
40963
40999
|
return null;
|
|
40964
41000
|
}
|
|
40965
41001
|
}
|
|
41002
|
+
function readProjectAppId(projectDir) {
|
|
41003
|
+
return stringOrNull(readLotics(projectDir)?.app_id);
|
|
41004
|
+
}
|
|
40966
41005
|
function readAppMeta(projectDir) {
|
|
40967
41006
|
const lotics = readLotics(projectDir);
|
|
40968
41007
|
const appId = stringOrNull(lotics?.app_id);
|
|
@@ -40981,6 +41020,15 @@ function readAppMeta(projectDir) {
|
|
|
40981
41020
|
version_number: typeof versionNumber === "number" ? versionNumber : null
|
|
40982
41021
|
};
|
|
40983
41022
|
}
|
|
41023
|
+
function writePulledManifest(projectDir, manifest) {
|
|
41024
|
+
const pkgPath = path7.join(projectDir, "package.json");
|
|
41025
|
+
if (!fs7.existsSync(pkgPath)) fail(`The pulled source has no package.json at ${pkgPath}.`);
|
|
41026
|
+
const pkg = JSON.parse(fs7.readFileSync(pkgPath, "utf-8"));
|
|
41027
|
+
if (typeof pkg !== "object" || pkg === null) fail(`${pkgPath} is not a JSON object.`);
|
|
41028
|
+
const lotics = { ...manifest, version_number: null };
|
|
41029
|
+
fs7.writeFileSync(pkgPath, `${JSON.stringify({ ...pkg, lotics }, null, 2)}
|
|
41030
|
+
`);
|
|
41031
|
+
}
|
|
40984
41032
|
function writeDeployedVersion(projectDir, version2) {
|
|
40985
41033
|
const pkgPath = path7.join(projectDir, "package.json");
|
|
40986
41034
|
const pkg = JSON.parse(fs7.readFileSync(pkgPath, "utf-8"));
|
|
@@ -41376,6 +41424,10 @@ function flushPendingWarnings() {
|
|
|
41376
41424
|
}
|
|
41377
41425
|
|
|
41378
41426
|
// src/app_toolchain.ts
|
|
41427
|
+
var ARCHIVE_EXCLUDES = ["node_modules", "dist", "*.tsbuildinfo", ".git"];
|
|
41428
|
+
function packSource(projectDir, archivePath) {
|
|
41429
|
+
return runTar(["-czf", archivePath, ...ARCHIVE_EXCLUDES.map((pattern) => `--exclude=${pattern}`), "."], projectDir);
|
|
41430
|
+
}
|
|
41379
41431
|
function runTar(args, cwd) {
|
|
41380
41432
|
return new Promise((resolve, reject) => {
|
|
41381
41433
|
const proc = spawn("tar", args, { cwd, stdio: ["ignore", "ignore", "pipe"] });
|
|
@@ -41561,10 +41613,110 @@ Ready. Next steps:`);
|
|
|
41561
41613
|
return { app_id: app.id, path: targetPath };
|
|
41562
41614
|
}
|
|
41563
41615
|
|
|
41564
|
-
// src/
|
|
41616
|
+
// src/app_pull.ts
|
|
41565
41617
|
import fs11 from "node:fs";
|
|
41566
41618
|
import path11 from "node:path";
|
|
41567
41619
|
import { tmpdir } from "node:os";
|
|
41620
|
+
var NAMED_CHANGES = 20;
|
|
41621
|
+
async function appPull(client, args) {
|
|
41622
|
+
const cwdAppId = readProjectAppId(process.cwd());
|
|
41623
|
+
const appId = args.appId ?? cwdAppId;
|
|
41624
|
+
if (appId === null) fail("This directory is not an app project. Name the app: lotics app pull <app_id> [path]");
|
|
41625
|
+
const app = await client.getApp(appId);
|
|
41626
|
+
const live = app.current_version_id;
|
|
41627
|
+
if (live === null) fail(`App ${app.id} has no version yet, so there is nothing to pull.`);
|
|
41628
|
+
const targetPath = path11.resolve(
|
|
41629
|
+
args.targetPath ?? (args.appId === void 0 || cwdAppId === app.id ? process.cwd() : appDirName(app.name))
|
|
41630
|
+
);
|
|
41631
|
+
const targetAppId = readProjectAppId(targetPath);
|
|
41632
|
+
if (targetAppId !== null && targetAppId !== app.id) {
|
|
41633
|
+
fail(`${targetPath} is the project of ${targetAppId}, not ${app.id}.`);
|
|
41634
|
+
}
|
|
41635
|
+
if (targetAppId === null && fs11.existsSync(targetPath) && fs11.readdirSync(targetPath).length > 0) {
|
|
41636
|
+
fail(`${targetPath} is not empty and is not this app's project. Pull into a new directory.`);
|
|
41637
|
+
}
|
|
41638
|
+
const scratch = fs11.mkdtempSync(path11.join(tmpdir(), "lotics-pull-"));
|
|
41639
|
+
try {
|
|
41640
|
+
if (targetAppId !== null) {
|
|
41641
|
+
const base = readAppMeta(targetPath).current_version_id;
|
|
41642
|
+
if (base === live) {
|
|
41643
|
+
note2(`${targetPath} is already at the live version (${live}).`);
|
|
41644
|
+
return { app_id: app.id, version_id: live, path: targetPath, outcome: "unchanged" };
|
|
41645
|
+
}
|
|
41646
|
+
if (base === null) {
|
|
41647
|
+
fail(`${targetPath} was never deployed, so its edits cannot be told apart from ${live}. Pull into a new directory.`);
|
|
41648
|
+
}
|
|
41649
|
+
const local = await unpack(scratch, "local", (archive2) => packSource(targetPath, archive2));
|
|
41650
|
+
const baseTree = await unpack(
|
|
41651
|
+
scratch,
|
|
41652
|
+
"base",
|
|
41653
|
+
async (archive2) => fs11.writeFileSync(archive2, await client.downloadAppVersionSource(app.id, base))
|
|
41654
|
+
);
|
|
41655
|
+
const changed = changedSourcePaths(baseTree, local);
|
|
41656
|
+
if (changed.length > 0) {
|
|
41657
|
+
const named = changed.slice(0, NAMED_CHANGES).map((file2) => ` ${file2}`);
|
|
41658
|
+
const rest = changed.length > NAMED_CHANGES ? [` \u2026and ${changed.length - NAMED_CHANGES} more`] : [];
|
|
41659
|
+
fail(
|
|
41660
|
+
`${targetPath} has changes since ${base}, the version it was based on:
|
|
41661
|
+
${[...named, ...rest].join("\n")}
|
|
41662
|
+
Version ${live} is live. Pull it into a new directory (lotics app pull ${app.id} <new-directory>), carry these changes over, and deploy from there.`
|
|
41663
|
+
);
|
|
41664
|
+
}
|
|
41665
|
+
for (const file2 of listFiles(local)) fs11.rmSync(path11.join(targetPath, file2));
|
|
41666
|
+
removeEmptyDirs(targetPath, listDirs(local));
|
|
41667
|
+
}
|
|
41668
|
+
const archive = path11.join(scratch, "live.tar.gz");
|
|
41669
|
+
fs11.writeFileSync(archive, await client.downloadAppVersionSource(app.id, live));
|
|
41670
|
+
fs11.mkdirSync(targetPath, { recursive: true });
|
|
41671
|
+
await runTar(["-xzf", archive], targetPath);
|
|
41672
|
+
} finally {
|
|
41673
|
+
fs11.rmSync(scratch, { recursive: true, force: true });
|
|
41674
|
+
}
|
|
41675
|
+
writePulledManifest(targetPath, { app_id: app.id, workspace_id: app.workspace_id, current_version_id: live });
|
|
41676
|
+
await writeAppTypes(client, targetPath, app.id);
|
|
41677
|
+
note2(`Pulled ${app.name} (${live}) into ${targetPath}`);
|
|
41678
|
+
await runNpmInstall(targetPath);
|
|
41679
|
+
return { app_id: app.id, version_id: live, path: targetPath, outcome: "pulled" };
|
|
41680
|
+
}
|
|
41681
|
+
async function unpack(scratch, name, write) {
|
|
41682
|
+
const archive = path11.join(scratch, `${name}.tar.gz`);
|
|
41683
|
+
const dir = path11.join(scratch, name);
|
|
41684
|
+
await write(archive);
|
|
41685
|
+
fs11.mkdirSync(dir);
|
|
41686
|
+
await runTar(["-xzf", archive], dir);
|
|
41687
|
+
return dir;
|
|
41688
|
+
}
|
|
41689
|
+
function changedSourcePaths(baseDir, localDir) {
|
|
41690
|
+
const files = /* @__PURE__ */ new Set([...listFiles(baseDir), ...listFiles(localDir)]);
|
|
41691
|
+
return [...files].filter((file2) => !file2.startsWith(`.lotics${path11.sep}`)).filter((file2) => !sameSource(path11.join(baseDir, file2), path11.join(localDir, file2), file2 === "package.json")).sort();
|
|
41692
|
+
}
|
|
41693
|
+
function sameSource(a, b, isManifest) {
|
|
41694
|
+
if (!fs11.existsSync(a) || !fs11.existsSync(b)) return false;
|
|
41695
|
+
if (!isManifest) return fs11.readFileSync(a).equals(fs11.readFileSync(b));
|
|
41696
|
+
return JSON.stringify(withoutOwnRecord(a)) === JSON.stringify(withoutOwnRecord(b));
|
|
41697
|
+
}
|
|
41698
|
+
function withoutOwnRecord(pkgPath) {
|
|
41699
|
+
const pkg = JSON.parse(fs11.readFileSync(pkgPath, "utf-8"));
|
|
41700
|
+
if (typeof pkg !== "object" || pkg === null) return pkg;
|
|
41701
|
+
return Object.fromEntries(Object.entries(pkg).filter(([key]) => key !== "lotics"));
|
|
41702
|
+
}
|
|
41703
|
+
function listFiles(dir) {
|
|
41704
|
+
return fs11.readdirSync(dir, { recursive: true, withFileTypes: true }).filter((entry) => !entry.isDirectory()).map((entry) => path11.relative(dir, path11.join(entry.parentPath, entry.name)));
|
|
41705
|
+
}
|
|
41706
|
+
function listDirs(dir) {
|
|
41707
|
+
return fs11.readdirSync(dir, { recursive: true, withFileTypes: true }).filter((entry) => entry.isDirectory()).map((entry) => path11.relative(dir, path11.join(entry.parentPath, entry.name)));
|
|
41708
|
+
}
|
|
41709
|
+
function removeEmptyDirs(root, dirs) {
|
|
41710
|
+
for (const dir of [...dirs].sort((a, b) => b.length - a.length)) {
|
|
41711
|
+
const full = path11.join(root, dir);
|
|
41712
|
+
if (fs11.existsSync(full) && fs11.readdirSync(full).length === 0) fs11.rmdirSync(full);
|
|
41713
|
+
}
|
|
41714
|
+
}
|
|
41715
|
+
|
|
41716
|
+
// src/app_deploy.ts
|
|
41717
|
+
import fs12 from "node:fs";
|
|
41718
|
+
import path12 from "node:path";
|
|
41719
|
+
import { tmpdir as tmpdir2 } from "node:os";
|
|
41568
41720
|
var BINDING_KEY_OWNERS = {
|
|
41569
41721
|
queries: "set_app_query",
|
|
41570
41722
|
workflows: "set_app_workflow",
|
|
@@ -41576,19 +41728,18 @@ function declaredBindingKeys(pkg) {
|
|
|
41576
41728
|
if (typeof lotics !== "object" || lotics === null) return [];
|
|
41577
41729
|
return Object.entries(BINDING_KEY_OWNERS).filter(([key]) => key in lotics).map(([key, tool]) => `package.json#lotics.${key}: the app's ${key} are set live with \`lotics run ${tool}\`.`);
|
|
41578
41730
|
}
|
|
41579
|
-
var ARCHIVE_EXCLUDES = ["node_modules", "dist", "*.tsbuildinfo", ".git"];
|
|
41580
41731
|
async function appDeploy(client, args) {
|
|
41581
|
-
const projectDir =
|
|
41732
|
+
const projectDir = path12.resolve(args.projectDir ?? process.cwd());
|
|
41582
41733
|
const meta3 = readAppMeta(projectDir);
|
|
41583
|
-
const pkg = JSON.parse(
|
|
41734
|
+
const pkg = JSON.parse(fs12.readFileSync(path12.join(projectDir, "package.json"), "utf-8"));
|
|
41584
41735
|
const scripts = typeof pkg === "object" && pkg !== null && "scripts" in pkg ? pkg.scripts : void 0;
|
|
41585
41736
|
if (typeof scripts !== "object" || scripts === null || !("build" in scripts)) {
|
|
41586
|
-
fail(`${
|
|
41737
|
+
fail(`${path12.join(projectDir, "package.json")} declares no \`build\` script, so there is no bundle to deploy.`);
|
|
41587
41738
|
}
|
|
41588
41739
|
const declared = declaredBindingKeys(pkg);
|
|
41589
41740
|
if (declared.length > 0) {
|
|
41590
41741
|
fail(
|
|
41591
|
-
`${
|
|
41742
|
+
`${path12.join(projectDir, "package.json")} still declares bindings a deploy does not ship:
|
|
41592
41743
|
${declared.join("\n ")}
|
|
41593
41744
|
Delete these keys from package.json#lotics, then deploy again.`
|
|
41594
41745
|
);
|
|
@@ -41597,26 +41748,26 @@ Delete these keys from package.json#lotics, then deploy again.`
|
|
|
41597
41748
|
await runAppTypecheck(projectDir);
|
|
41598
41749
|
note2("Building...");
|
|
41599
41750
|
await runNpm(["run", "build"], projectDir);
|
|
41600
|
-
const distDir =
|
|
41601
|
-
if (!
|
|
41751
|
+
const distDir = path12.join(projectDir, "dist");
|
|
41752
|
+
if (!fs12.existsSync(distDir)) {
|
|
41602
41753
|
fail(`\`npm run build\` produced no dist/ in ${projectDir} \u2014 Vite writes one by default; check its outDir.`);
|
|
41603
41754
|
}
|
|
41604
41755
|
const stamp = Date.now();
|
|
41605
|
-
const tmpSource =
|
|
41606
|
-
const tmpDist =
|
|
41756
|
+
const tmpSource = path12.join(tmpdir2(), `lotics-source-${stamp}.tar.gz`);
|
|
41757
|
+
const tmpDist = path12.join(tmpdir2(), `lotics-dist-${stamp}.tar.gz`);
|
|
41607
41758
|
try {
|
|
41608
41759
|
note2("Packaging...");
|
|
41609
|
-
await
|
|
41760
|
+
await packSource(projectDir, tmpSource);
|
|
41610
41761
|
await runTar(["-czf", tmpDist, "-C", distDir, "."], projectDir);
|
|
41611
41762
|
note2("Uploading...");
|
|
41612
41763
|
const result = await client.deployAppVersion({
|
|
41613
41764
|
app_id: meta3.app_id,
|
|
41614
|
-
source_archive:
|
|
41615
|
-
dist_archive:
|
|
41765
|
+
source_archive: fs12.readFileSync(tmpSource),
|
|
41766
|
+
dist_archive: fs12.readFileSync(tmpDist),
|
|
41616
41767
|
prev_version_id: meta3.current_version_id,
|
|
41617
41768
|
...args.message === void 0 ? {} : { message: args.message }
|
|
41618
41769
|
}).catch((error52) => {
|
|
41619
|
-
throw staleRefusal(error52, meta3.current_version_id);
|
|
41770
|
+
throw staleRefusal(error52, meta3.current_version_id, meta3.app_id);
|
|
41620
41771
|
});
|
|
41621
41772
|
writeDeployedVersion(projectDir, result);
|
|
41622
41773
|
note2(`Deployed v${result.version_number} (${result.version_id})`);
|
|
@@ -41624,18 +41775,18 @@ Delete these keys from package.json#lotics, then deploy again.`
|
|
|
41624
41775
|
if (result.origin !== void 0) note2(`Address: ${result.origin}`);
|
|
41625
41776
|
return result;
|
|
41626
41777
|
} finally {
|
|
41627
|
-
|
|
41628
|
-
|
|
41778
|
+
fs12.rmSync(tmpSource, { force: true });
|
|
41779
|
+
fs12.rmSync(tmpDist, { force: true });
|
|
41629
41780
|
}
|
|
41630
41781
|
}
|
|
41631
|
-
function staleRefusal(error52, deployedFrom) {
|
|
41782
|
+
function staleRefusal(error52, deployedFrom, appId) {
|
|
41632
41783
|
if (!(error52 instanceof LoticsRequestError) || error52.status !== 409) return error52;
|
|
41633
41784
|
const served = error52.body.current_version_id;
|
|
41634
41785
|
if (typeof served !== "string" || served === deployedFrom) return error52;
|
|
41635
41786
|
const said = typeof error52.body.message === "string" ? error52.body.message : error52.message;
|
|
41636
41787
|
return new CliError(
|
|
41637
41788
|
`${said}
|
|
41638
|
-
Version ${served} went live after this directory's last deploy.
|
|
41789
|
+
Version ${served} went live after this directory's last deploy. Run \`lotics app pull\` here: it brings this directory up to date when it has no edits, and names the files it changed when it has, to carry over into \`lotics app pull ${appId} <new-directory>\`.`
|
|
41639
41790
|
);
|
|
41640
41791
|
}
|
|
41641
41792
|
|
|
@@ -41891,8 +42042,8 @@ function parseArgs(argv2) {
|
|
|
41891
42042
|
}
|
|
41892
42043
|
|
|
41893
42044
|
// src/model_commands.ts
|
|
41894
|
-
import
|
|
41895
|
-
import
|
|
42045
|
+
import fs13 from "node:fs";
|
|
42046
|
+
import path13 from "node:path";
|
|
41896
42047
|
|
|
41897
42048
|
// ../shared/src/app_compile/history_rows.ts
|
|
41898
42049
|
var LANDED = "@today 00:00";
|
|
@@ -41992,8 +42143,8 @@ function count(n, noun) {
|
|
|
41992
42143
|
return `${n} ${noun}${n === 1 ? "" : "s"}`;
|
|
41993
42144
|
}
|
|
41994
42145
|
function besideFile(file2) {
|
|
41995
|
-
const dir =
|
|
41996
|
-
return { documents: { dir, exists: (relative) =>
|
|
42146
|
+
const dir = path13.dirname(path13.resolve(file2));
|
|
42147
|
+
return { documents: { dir, exists: (relative) => fs13.existsSync(path13.resolve(dir, relative)) } };
|
|
41997
42148
|
}
|
|
41998
42149
|
function oneFinding(pathName, message2) {
|
|
41999
42150
|
return [{ path: pathName, message: message2, severity: "error" }];
|
|
@@ -42001,7 +42152,7 @@ function oneFinding(pathName, message2) {
|
|
|
42001
42152
|
function readModelFile(file2) {
|
|
42002
42153
|
let raw;
|
|
42003
42154
|
try {
|
|
42004
|
-
raw = JSON.parse(
|
|
42155
|
+
raw = JSON.parse(fs13.readFileSync(file2, "utf-8"));
|
|
42005
42156
|
} catch (error52) {
|
|
42006
42157
|
return { kind: "error", findings: oneFinding(file2, `cannot read: ${error52 instanceof Error ? error52.message : String(error52)}`) };
|
|
42007
42158
|
}
|
|
@@ -42055,9 +42206,9 @@ async function uploadDocuments(client, read, file2) {
|
|
|
42055
42206
|
const idByPath = new Map(Object.entries(known).filter(([relative]) => paths.includes(relative)));
|
|
42056
42207
|
const fresh = paths.filter((relative) => !idByPath.has(relative));
|
|
42057
42208
|
if (fresh.length > 0) note2(`Uploading ${count(fresh.length, "document")} the rows attach\u2026`);
|
|
42058
|
-
const baseDir =
|
|
42209
|
+
const baseDir = path13.dirname(path13.resolve(file2));
|
|
42059
42210
|
for (const relative of fresh) {
|
|
42060
|
-
const uploaded = await client.uploadFiles([
|
|
42211
|
+
const uploaded = await client.uploadFiles([path13.resolve(baseDir, relative)]);
|
|
42061
42212
|
const refused = uploaded.errors[0];
|
|
42062
42213
|
if (refused !== void 0) fail(`"${relative}" could not be uploaded (${refused.error}), so nothing was applied.`);
|
|
42063
42214
|
const id = uploaded.files[0]?.id;
|
|
@@ -42182,8 +42333,8 @@ async function modelPull(client, output) {
|
|
|
42182
42333
|
process.stdout.write(text);
|
|
42183
42334
|
return;
|
|
42184
42335
|
}
|
|
42185
|
-
|
|
42186
|
-
note2(`Wrote ${
|
|
42336
|
+
fs13.writeFileSync(output, text);
|
|
42337
|
+
note2(`Wrote ${path13.resolve(output)}.`);
|
|
42187
42338
|
}
|
|
42188
42339
|
|
|
42189
42340
|
// src/signin_link.ts
|
|
@@ -42363,7 +42514,7 @@ async function validateOrgWorkspacePin(client, orgId, profile) {
|
|
|
42363
42514
|
}
|
|
42364
42515
|
|
|
42365
42516
|
// src/knowledge.ts
|
|
42366
|
-
import
|
|
42517
|
+
import fs15 from "node:fs";
|
|
42367
42518
|
|
|
42368
42519
|
// ../shared/src/knowledge_tags.ts
|
|
42369
42520
|
function isHiddenDoc(doc) {
|
|
@@ -42371,17 +42522,17 @@ function isHiddenDoc(doc) {
|
|
|
42371
42522
|
}
|
|
42372
42523
|
|
|
42373
42524
|
// src/cli_io.ts
|
|
42374
|
-
import
|
|
42375
|
-
import
|
|
42525
|
+
import fs14 from "node:fs";
|
|
42526
|
+
import path14 from "node:path";
|
|
42376
42527
|
function writeFileAtomic(filePath, bytes) {
|
|
42377
|
-
const dir =
|
|
42378
|
-
const tmp =
|
|
42379
|
-
|
|
42528
|
+
const dir = path14.dirname(path14.resolve(filePath));
|
|
42529
|
+
const tmp = path14.join(dir, `.${path14.basename(filePath)}.${process.pid}.${Date.now()}.tmp`);
|
|
42530
|
+
fs14.writeFileSync(tmp, bytes);
|
|
42380
42531
|
try {
|
|
42381
|
-
|
|
42532
|
+
fs14.renameSync(tmp, filePath);
|
|
42382
42533
|
} catch (error52) {
|
|
42383
42534
|
try {
|
|
42384
|
-
|
|
42535
|
+
fs14.unlinkSync(tmp);
|
|
42385
42536
|
} catch {
|
|
42386
42537
|
}
|
|
42387
42538
|
throw error52;
|
|
@@ -42391,8 +42542,8 @@ function writeFileAtomic(filePath, bytes) {
|
|
|
42391
42542
|
|
|
42392
42543
|
// src/knowledge.ts
|
|
42393
42544
|
function readBodyFile(filePath) {
|
|
42394
|
-
if (!
|
|
42395
|
-
return
|
|
42545
|
+
if (!fs15.existsSync(filePath)) fail(`File not found: ${filePath}`);
|
|
42546
|
+
return fs15.readFileSync(filePath, "utf-8");
|
|
42396
42547
|
}
|
|
42397
42548
|
function resolveBody(flags, required2) {
|
|
42398
42549
|
if (flags.from !== void 0 && flags.content !== void 0) {
|
|
@@ -42749,8 +42900,8 @@ function prompt(question) {
|
|
|
42749
42900
|
});
|
|
42750
42901
|
});
|
|
42751
42902
|
}
|
|
42752
|
-
async function publicPost(apiUrl,
|
|
42753
|
-
const response = await fetch(`${apiUrl}${
|
|
42903
|
+
async function publicPost(apiUrl, path16, body) {
|
|
42904
|
+
const response = await fetch(`${apiUrl}${path16}`, {
|
|
42754
42905
|
method: "POST",
|
|
42755
42906
|
headers: { "Content-Type": "application/json" },
|
|
42756
42907
|
body: JSON.stringify(body)
|
|
@@ -43111,13 +43262,13 @@ async function resolveWorkspace(client, ctx) {
|
|
|
43111
43262
|
function resolveUploadPaths(rawPaths) {
|
|
43112
43263
|
const result = [];
|
|
43113
43264
|
for (const p of rawPaths) {
|
|
43114
|
-
const resolved =
|
|
43115
|
-
const stat =
|
|
43265
|
+
const resolved = path15.resolve(p);
|
|
43266
|
+
const stat = fs16.statSync(resolved);
|
|
43116
43267
|
if (stat.isDirectory()) {
|
|
43117
|
-
const entries2 =
|
|
43268
|
+
const entries2 = fs16.readdirSync(resolved, { withFileTypes: true });
|
|
43118
43269
|
for (const entry of entries2) {
|
|
43119
43270
|
if (entry.isFile()) {
|
|
43120
|
-
result.push(
|
|
43271
|
+
result.push(path15.join(resolved, entry.name));
|
|
43121
43272
|
}
|
|
43122
43273
|
}
|
|
43123
43274
|
} else {
|
|
@@ -43315,11 +43466,11 @@ async function main(argv2) {
|
|
|
43315
43466
|
if (input.startsWith("@")) {
|
|
43316
43467
|
source = "file";
|
|
43317
43468
|
const file2 = input.slice(1);
|
|
43318
|
-
if (!
|
|
43469
|
+
if (!fs16.existsSync(file2)) {
|
|
43319
43470
|
console.error(`No such file: ${file2}`);
|
|
43320
43471
|
process.exit(1);
|
|
43321
43472
|
}
|
|
43322
|
-
input =
|
|
43473
|
+
input = fs16.readFileSync(file2, "utf-8");
|
|
43323
43474
|
} else if (input === "-") {
|
|
43324
43475
|
source = "stdin";
|
|
43325
43476
|
input = await readStdin();
|
|
@@ -43540,6 +43691,7 @@ async function main(argv2) {
|
|
|
43540
43691
|
console.error("Usage:");
|
|
43541
43692
|
console.error(" lotics app create <name> --custom [path] Create a custom-code app and scaffold it here");
|
|
43542
43693
|
console.error(" lotics app deploy [-m <message>] Build this directory and upload it as a new version");
|
|
43694
|
+
console.error(" lotics app pull [app_id] [path] The app's live source: this project brought up to date, or a new directory");
|
|
43543
43695
|
console.error(" Everything else about an app is a tool: lotics tools, then lotics run <tool>.");
|
|
43544
43696
|
process.exit(1);
|
|
43545
43697
|
}
|
|
@@ -43832,6 +43984,15 @@ Available workspaces:`);
|
|
|
43832
43984
|
if (flags.json) emitJson({ workspace_id: ctx.workspaceId, ...created });
|
|
43833
43985
|
return;
|
|
43834
43986
|
}
|
|
43987
|
+
if (subcommand === "pull") {
|
|
43988
|
+
if (flags.json) setMachineOutput(true);
|
|
43989
|
+
const pulled = await appPull(client, {
|
|
43990
|
+
...toolArgs === void 0 ? {} : { appId: toolArgs },
|
|
43991
|
+
...restArgs[0] === void 0 ? {} : { targetPath: restArgs[0] }
|
|
43992
|
+
});
|
|
43993
|
+
if (flags.json) emitJson({ workspace_id: ctx.workspaceId, ...pulled });
|
|
43994
|
+
return;
|
|
43995
|
+
}
|
|
43835
43996
|
if (subcommand === "deploy") {
|
|
43836
43997
|
const message2 = (flags.message ?? toolArgs)?.trim();
|
|
43837
43998
|
if (flags.json) setMachineOutput(true);
|
|
@@ -43927,7 +44088,7 @@ ${JSON.stringify(info.input_schema, null, 2)}`);
|
|
|
43927
44088
|
const toolName = subcommand;
|
|
43928
44089
|
const ingested = await ingestJsonArgs({
|
|
43929
44090
|
rawArg: toolArgs,
|
|
43930
|
-
readFile: (p) =>
|
|
44091
|
+
readFile: (p) => fs16.readFileSync(p, "utf-8"),
|
|
43931
44092
|
readStdin
|
|
43932
44093
|
});
|
|
43933
44094
|
if (ingested.kind === "error") {
|