@lotics/cli 0.221.0 → 0.223.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 +1244 -196
- package/docs/cli_reference.md +2 -2
- package/package.json +1 -1
package/dist/src/cli.js
CHANGED
|
@@ -35282,7 +35282,7 @@ var tableDateFieldSchema = tableFieldBaseSchema.extend({
|
|
|
35282
35282
|
format: dateFormatSchema.optional(),
|
|
35283
35283
|
timezone: zod_default.string().optional().describe("IANA timezone"),
|
|
35284
35284
|
derive_from: zod_default.enum(["created_at", "updated_at"]).optional().describe(
|
|
35285
|
-
"Auto-populate from the row's system timestamp. `created_at` stamps once at insert time; `updated_at` re-stamps on every record edit.
|
|
35285
|
+
"Auto-populate from the row's system timestamp. `created_at` stamps once at insert time; `updated_at` re-stamps on every record edit. A derived field is read-only: a write naming it is refused, as for formula/rollup/lookup. Replaces the per-table after_create workflow pattern."
|
|
35286
35286
|
),
|
|
35287
35287
|
// A derived date is read-only, so derive_from and default_value are mutually
|
|
35288
35288
|
// exclusive — enforced in validateFieldDefaultValue.
|
|
@@ -35595,6 +35595,17 @@ var fieldVisibilitySchema = zod_default.enum(["visible", "hidden"]).describe(
|
|
|
35595
35595
|
"Field visibility state: 'visible' = shown to everyone, 'hidden' = not shown by default but members can toggle"
|
|
35596
35596
|
);
|
|
35597
35597
|
|
|
35598
|
+
// ../shared/src/schemas/database_types.ts
|
|
35599
|
+
function extractRecordLinkIds(value) {
|
|
35600
|
+
if (!value || !Array.isArray(value)) return [];
|
|
35601
|
+
return value.map((item) => typeof item === "string" ? item : item?.id).filter((id) => Boolean(id));
|
|
35602
|
+
}
|
|
35603
|
+
function isPlatformWrittenField(field) {
|
|
35604
|
+
if (COMPUTED_FIELD_TYPES.has(field.type)) return true;
|
|
35605
|
+
if (field.type === "autonumber") return true;
|
|
35606
|
+
return field.type === "date" && field.derive_from !== void 0;
|
|
35607
|
+
}
|
|
35608
|
+
|
|
35598
35609
|
// ../shared/src/binding_keys.ts
|
|
35599
35610
|
function fieldKeyName(entityAlias, fieldAlias) {
|
|
35600
35611
|
return `${entityAlias}.${fieldAlias}`;
|
|
@@ -37878,6 +37889,23 @@ function fieldHoldsSeveral(field) {
|
|
|
37878
37889
|
if (field.type === "select") return field.multi === true;
|
|
37879
37890
|
return field.type === "select_record_link" && field.cardinality !== "one";
|
|
37880
37891
|
}
|
|
37892
|
+
var STATED_FIELD_TYPES = [
|
|
37893
|
+
"text",
|
|
37894
|
+
"number",
|
|
37895
|
+
"date",
|
|
37896
|
+
"boolean",
|
|
37897
|
+
"select",
|
|
37898
|
+
"select_record_link",
|
|
37899
|
+
"files"
|
|
37900
|
+
];
|
|
37901
|
+
function isEditable(field) {
|
|
37902
|
+
if (isPlatformWrittenField(field)) return false;
|
|
37903
|
+
if (field.type === "select_record_link") return field.cardinality === "one";
|
|
37904
|
+
return STATED_FIELD_TYPES.includes(field.type);
|
|
37905
|
+
}
|
|
37906
|
+
function isAsked(field) {
|
|
37907
|
+
return isEditable(field) || field.type === "select_member";
|
|
37908
|
+
}
|
|
37881
37909
|
var contractViewSchema = zod_default.object({
|
|
37882
37910
|
alias: contractAliasSchema.describe("Stable view alias, unique within the entity"),
|
|
37883
37911
|
label: zod_default.string().min(1).describe("Display name of the view"),
|
|
@@ -40416,13 +40444,15 @@ var contractActWhenSchema = zod_default.strictObject({
|
|
|
40416
40444
|
field: contractAliasSchema.describe("The lifecycle field of this entity the state is read off"),
|
|
40417
40445
|
in: zod_default.array(contractAliasSchema).min(1).describe("The option aliases of that lifecycle the act is offered at")
|
|
40418
40446
|
}).describe("Offer this act only while the row stands at one of these stages");
|
|
40447
|
+
var contractActConfirmSchema = zod_default.boolean().describe("Ask before this act runs, with its own label as the commit word \u2014 for a press that cannot be taken back");
|
|
40419
40448
|
var contractTemplateActSchema = zod_default.strictObject({
|
|
40420
40449
|
label: zod_default.string().min(1).describe("The act's own words, as the reader reads them in the menu"),
|
|
40421
40450
|
template: contractAliasSchema.describe(
|
|
40422
40451
|
"A template alias this model declares; a row's act generates that document from the row and opens it"
|
|
40423
40452
|
),
|
|
40424
40453
|
place: contractActPlaceSchema.optional(),
|
|
40425
|
-
when: contractActWhenSchema.optional()
|
|
40454
|
+
when: contractActWhenSchema.optional(),
|
|
40455
|
+
confirm: contractActConfirmSchema.optional()
|
|
40426
40456
|
});
|
|
40427
40457
|
var contractAgentActSchema = zod_default.strictObject({
|
|
40428
40458
|
kind: zod_default.literal("agent"),
|
|
@@ -40430,7 +40460,8 @@ var contractAgentActSchema = zod_default.strictObject({
|
|
|
40430
40460
|
agent: contractAliasSchema.describe("An agent alias the APP declares (package.json#lotics.agents)"),
|
|
40431
40461
|
fills: zod_default.array(contractAliasSchema).min(1).describe("The fields of this entity the run may write \u2014 reviewed by the reader, then written as ONE update"),
|
|
40432
40462
|
place: contractActPlaceSchema.optional(),
|
|
40433
|
-
when: contractActWhenSchema.optional()
|
|
40463
|
+
when: contractActWhenSchema.optional(),
|
|
40464
|
+
confirm: contractActConfirmSchema.optional()
|
|
40434
40465
|
});
|
|
40435
40466
|
var RECORD_INPUT = "record";
|
|
40436
40467
|
var contractWorkflowActSchema = zod_default.strictObject({
|
|
@@ -40442,7 +40473,8 @@ var contractWorkflowActSchema = zod_default.strictObject({
|
|
|
40442
40473
|
contractAliasSchema.describe('A field of this entity, or "record" \u2014 which names the row itself before any field of that alias')
|
|
40443
40474
|
).describe("What the run is handed: the workflow's own input name \u2192 the value on the row it is pressed on"),
|
|
40444
40475
|
place: contractActPlaceSchema.optional(),
|
|
40445
|
-
when: contractActWhenSchema.optional()
|
|
40476
|
+
when: contractActWhenSchema.optional(),
|
|
40477
|
+
confirm: contractActConfirmSchema.optional()
|
|
40446
40478
|
});
|
|
40447
40479
|
var contractActSchema = zod_default.union([contractAgentActSchema, contractWorkflowActSchema, contractTemplateActSchema], {
|
|
40448
40480
|
error: (issue2) => typeof issue2.input === "object" && issue2.input !== null && "screen" in issue2.input ? "a row act opens this app's record or another app" : void 0
|
|
@@ -40505,6 +40537,28 @@ var contractPredicateSchema = zod_default.strictObject({
|
|
|
40505
40537
|
"An `and` group of plain conditions over the entity's OWN fields, each `field_key` a field alias \u2014 the rows this predicate keeps"
|
|
40506
40538
|
)
|
|
40507
40539
|
});
|
|
40540
|
+
var SOCIAL_NETWORKS = ["facebook", "instagram", "threads", "x", "linkedin"];
|
|
40541
|
+
var contractPublishDestinationSchema = zod_default.strictObject({
|
|
40542
|
+
network: zod_default.strictObject({
|
|
40543
|
+
field: contractAliasSchema.describe("A single select on the DESTINATION entity saying what kind of surface a row is"),
|
|
40544
|
+
options: zod_default.record(contractAliasSchema, zod_default.enum(SOCIAL_NETWORKS)).describe("Option alias \u2192 the network the kit previews and publishes it as; an option this map leaves out is posted by hand")
|
|
40545
|
+
}).describe("Which destinations the platform can reach through an API, and as what"),
|
|
40546
|
+
connected: contractAliasSchema.optional().describe("A text on the destination holding the account the platform posts as \u2014 empty means not connected, so by hand for now"),
|
|
40547
|
+
// A POST IS WRITTEN ONCE AND GOES EVERYWHERE. Where the destinations differ by
|
|
40548
|
+
// language the record carries a body each, and the destination's own select
|
|
40549
|
+
// says which of them it receives; one alias is the model with one body.
|
|
40550
|
+
bodies: zod_default.union([
|
|
40551
|
+
contractAliasSchema,
|
|
40552
|
+
zod_default.strictObject({
|
|
40553
|
+
field: contractAliasSchema.describe("A single select on the DESTINATION entity deciding which body it receives"),
|
|
40554
|
+
options: zod_default.record(contractAliasSchema, contractAliasSchema).describe("Option alias \u2192 a text field of THIS entity")
|
|
40555
|
+
})
|
|
40556
|
+
]).describe("The text each destination goes out with \u2014 one field of this entity, or the destination's own select choosing between several"),
|
|
40557
|
+
offered: zod_default.strictObject({
|
|
40558
|
+
field: contractAliasSchema.describe("A single select on the destination"),
|
|
40559
|
+
in: zod_default.array(contractAliasSchema).min(1).describe("Its option aliases the strip offers a row at")
|
|
40560
|
+
}).optional().describe("Which destination rows the strip offers; absent, every row")
|
|
40561
|
+
});
|
|
40508
40562
|
var ITINERARY_RUNS = ["when", "kind", "provider", "stage", "duration", "caption", "reference"];
|
|
40509
40563
|
var STOP_LINE_RUNS = 4;
|
|
40510
40564
|
var contractSectionDrawSchema = zod_default.discriminatedUnion("draw", [
|
|
@@ -40543,8 +40597,35 @@ var contractSectionDrawSchema = zod_default.discriminatedUnion("draw", [
|
|
|
40543
40597
|
line: zod_default.array(zod_default.enum(ITINERARY_RUNS)).min(1).max(STOP_LINE_RUNS).describe(
|
|
40544
40598
|
`The runs of a stop's muted line, in this order \u2014 at most ${STOP_LINE_RUNS}, each one this run already carries; absent, a stop is read by ${ITINERARY_RUNS.slice(0, STOP_LINE_RUNS).join(", ")}`
|
|
40545
40599
|
)
|
|
40600
|
+
}),
|
|
40601
|
+
// ONE PIECE, N DESTINATIONS. The rows are not a register anybody reads: each
|
|
40602
|
+
// is this record standing on one destination, so what a reader wants is the
|
|
40603
|
+
// strip of destinations with the state of each on it — and the one press
|
|
40604
|
+
// that sends the ones that are ready. Every other clause here names a column
|
|
40605
|
+
// the roles cannot reach: the four states the desk draws, the per-destination
|
|
40606
|
+
// override, and what came back from the platform.
|
|
40607
|
+
zod_default.strictObject({
|
|
40608
|
+
draw: zod_default.literal("publish").describe("One row per destination this record stands on \u2014 the strip, the preview, and the press that sends them"),
|
|
40609
|
+
states: zod_default.strictObject({
|
|
40610
|
+
queued: contractAliasSchema.describe("Waiting to go out \u2014 the stage the child's flow OPENS at"),
|
|
40611
|
+
published: contractAliasSchema.describe("Sent through the API"),
|
|
40612
|
+
failed: contractAliasSchema.describe("The platform refused it"),
|
|
40613
|
+
by_hand: contractAliasSchema.describe("Posted by a person, and marked so here")
|
|
40614
|
+
}).describe("Option aliases of the child's own lifecycle \u2014 all four, because each is a different thing the chip says"),
|
|
40615
|
+
text: contractAliasSchema.optional().describe("A plain text on the child \u2014 what this one destination goes out with instead of the body"),
|
|
40616
|
+
permalink: contractAliasSchema.optional().describe("A link-formatted text on the child \u2014 where the post landed"),
|
|
40617
|
+
error: contractAliasSchema.optional().describe("A text on the child holding the platform's own refusal"),
|
|
40618
|
+
link: contractAliasSchema.optional().describe("A link-formatted text of THIS entity \u2014 the address the post carries as its card"),
|
|
40619
|
+
destination: contractPublishDestinationSchema,
|
|
40620
|
+
publish: contractAliasSchema.describe(
|
|
40621
|
+
"The workflow alias the APP binds (package.json#lotics.workflows) \u2014 the generator writes no body, and `lotics app check` refuses an alias nothing bound"
|
|
40622
|
+
)
|
|
40546
40623
|
})
|
|
40547
40624
|
]).describe("How a section of this screen's record is drawn, where its rows are more than a register");
|
|
40625
|
+
function publishBodies(clause) {
|
|
40626
|
+
const bodies = clause.destination.bodies;
|
|
40627
|
+
return typeof bodies === "string" ? [bodies] : [...new Set(Object.values(bodies.options))];
|
|
40628
|
+
}
|
|
40548
40629
|
var contractLensSchema = zod_default.strictObject({
|
|
40549
40630
|
label: zod_default.string().min(1).describe("What the chip is called"),
|
|
40550
40631
|
predicates: zod_default.array(contractPredicateSchema).min(1).describe("The sets it offers, in this order")
|
|
@@ -40567,6 +40648,30 @@ function writesChildren(writes) {
|
|
|
40567
40648
|
function statedWrites(writes) {
|
|
40568
40649
|
return JSON.stringify(writes);
|
|
40569
40650
|
}
|
|
40651
|
+
var DRAFT_INPUTS = 12;
|
|
40652
|
+
function standsOnLink(field, entity, roles) {
|
|
40653
|
+
if (field.type !== "select_record_link") return false;
|
|
40654
|
+
const role = roleOf(roles, entity.alias, field.alias)?.role;
|
|
40655
|
+
return role === "parent" || role === "party";
|
|
40656
|
+
}
|
|
40657
|
+
function draftSkips(field, entity, roles, rules) {
|
|
40658
|
+
if (!isAsked(field) && !standsOnLink(field, entity, roles)) {
|
|
40659
|
+
return "a computed value, a link holding many rows and a minted number are the platform's to write, and no editor states them";
|
|
40660
|
+
}
|
|
40661
|
+
const decl = roleOf(roles, entity.alias, field.alias);
|
|
40662
|
+
if (decl?.role === "lifecycle") return "a lifecycle is WALKED \u2014 the row opens at the flow's first rung and the ladder on its record moves it";
|
|
40663
|
+
if (field.type === "files" && decl?.role !== "mark") return "a pile is gathered onto a row that exists, one paper at a time; only the `mark` is asked at create";
|
|
40664
|
+
const from = rules[entity.alias]?.fields?.[field.alias]?.default_from;
|
|
40665
|
+
if (from !== void 0) return `its value is copied from \`${from}\` when the row is written`;
|
|
40666
|
+
const stamp = entity.fields.find((one) => {
|
|
40667
|
+
const owed = roleOf(roles, entity.alias, one.alias);
|
|
40668
|
+
return owed?.role === "obligation" && owed.satisfied_by === field.alias;
|
|
40669
|
+
});
|
|
40670
|
+
if (stamp !== void 0) {
|
|
40671
|
+
return `it is the stamp that CLOSES "${stamp.label}", and a create is what OPENS the thing owed`;
|
|
40672
|
+
}
|
|
40673
|
+
return void 0;
|
|
40674
|
+
}
|
|
40570
40675
|
var contractScreenSchema = zod_default.object({
|
|
40571
40676
|
alias: contractAliasSchema.describe("Stable screen alias, unique within the app"),
|
|
40572
40677
|
label: zod_default.string().min(1).describe("What the screen is called in the app"),
|
|
@@ -40584,6 +40689,12 @@ var contractScreenSchema = zod_default.object({
|
|
|
40584
40689
|
columns: zod_default.array(contractAliasSchema).min(1).max(CONTEXT_COLUMNS).optional().describe(
|
|
40585
40690
|
`Extra fields drawn after the slots, in this order \u2014 at most ${CONTEXT_COLUMNS}, each holding ONE value; never a files field, and never a field a slot already draws`
|
|
40586
40691
|
),
|
|
40692
|
+
// WHAT THE DRAFT ASKS, where the row is written over days rather than typed
|
|
40693
|
+
// in one sitting. Absent, the draft asks for every field a person states,
|
|
40694
|
+
// which is right for a row somebody has the answers to when they open it.
|
|
40695
|
+
create: zod_default.array(contractAliasSchema).min(1).optional().describe(
|
|
40696
|
+
`The fields a new row is asked for, in this order \u2014 at most ${DRAFT_INPUTS}, each a field of this entity a draft can ask for, and every field the row cannot be written without among them; absent, the draft asks for every field a person states`
|
|
40697
|
+
),
|
|
40587
40698
|
// Absent IS true: a desk that can only be read is the exception, so the file
|
|
40588
40699
|
// says that and says nothing in the ordinary case.
|
|
40589
40700
|
writes: zod_default.union([zod_default.boolean(), zod_default.literal(CHILDREN_WRITES)]).optional().describe(
|
|
@@ -40886,6 +40997,7 @@ function resolveScreen(app, entityByAlias, roles, templates = [], rules = {}) {
|
|
|
40886
40997
|
}
|
|
40887
40998
|
}
|
|
40888
40999
|
const columns = resolveColumns(path24, screen, entity, [...entityByAlias.values()], fieldByAlias, boundBy, roles, shapeLabel, findings);
|
|
41000
|
+
const create = resolveCreate(path24, screen, entity, roles, rules, fieldByAlias, findings);
|
|
40889
41001
|
const acts = resolveActs(path24, screen, entity, roles, entityByAlias, rules, fieldByAlias, templates, findings);
|
|
40890
41002
|
const sectionActs2 = resolveSectionActs(path24, screen, entity, roles, fieldByAlias, templates, findings);
|
|
40891
41003
|
const summary = resolveSummary(path24, screen, entity, [...entityByAlias.values()], roles, slots, findings);
|
|
@@ -40915,6 +41027,7 @@ function resolveScreen(app, entityByAlias, roles, templates = [], rules = {}) {
|
|
|
40915
41027
|
slots,
|
|
40916
41028
|
outcomes,
|
|
40917
41029
|
columns,
|
|
41030
|
+
create,
|
|
40918
41031
|
acts,
|
|
40919
41032
|
sectionActs: sectionActs2,
|
|
40920
41033
|
summary,
|
|
@@ -40982,6 +41095,58 @@ function resolveColumns(path24, screen, entity, entities, fieldByAlias, boundBy,
|
|
|
40982
41095
|
}
|
|
40983
41096
|
return drawn;
|
|
40984
41097
|
}
|
|
41098
|
+
function resolveCreate(path24, screen, entity, roles, rules, fieldByAlias, findings) {
|
|
41099
|
+
const named3 = screen.create;
|
|
41100
|
+
if (named3 === void 0) return [];
|
|
41101
|
+
const at2 = `${path24}.create`;
|
|
41102
|
+
if (named3.length > DRAFT_INPUTS) {
|
|
41103
|
+
findings.push({
|
|
41104
|
+
severity: "error",
|
|
41105
|
+
path: at2,
|
|
41106
|
+
message: `asks for ${named3.length} fields, above the ${DRAFT_INPUTS} a draft is read in one pass \u2014 a create past that is a form in front of a row whose record edits every one of them in place`
|
|
41107
|
+
});
|
|
41108
|
+
return [];
|
|
41109
|
+
}
|
|
41110
|
+
const asked = [];
|
|
41111
|
+
const seen = /* @__PURE__ */ new Set();
|
|
41112
|
+
for (const alias of named3) {
|
|
41113
|
+
const field = fieldByAlias.get(alias);
|
|
41114
|
+
if (field === void 0) {
|
|
41115
|
+
findings.push({ severity: "error", path: at2, message: `names "${alias}", which is not a field of entity "${entity.alias}"` });
|
|
41116
|
+
continue;
|
|
41117
|
+
}
|
|
41118
|
+
if (seen.has(alias)) {
|
|
41119
|
+
findings.push({ severity: "error", path: at2, message: `names "${alias}" twice` });
|
|
41120
|
+
continue;
|
|
41121
|
+
}
|
|
41122
|
+
const skipped = draftSkips(field, entity, roles, rules);
|
|
41123
|
+
if (skipped !== void 0) {
|
|
41124
|
+
findings.push({ severity: "error", path: at2, message: `names "${alias}", which a draft never asks for: ${skipped}` });
|
|
41125
|
+
continue;
|
|
41126
|
+
}
|
|
41127
|
+
seen.add(alias);
|
|
41128
|
+
asked.push(field);
|
|
41129
|
+
}
|
|
41130
|
+
for (const field of draftMustAsk(entity, roles, rules)) {
|
|
41131
|
+
if (seen.has(field.alias)) continue;
|
|
41132
|
+
findings.push({
|
|
41133
|
+
severity: "error",
|
|
41134
|
+
path: at2,
|
|
41135
|
+
message: `leaves out "${field.alias}", which a row cannot be written without \u2014 a draft that cannot save is worse than a long one`
|
|
41136
|
+
});
|
|
41137
|
+
}
|
|
41138
|
+
return asked;
|
|
41139
|
+
}
|
|
41140
|
+
function draftMustAsk(entity, roles, rules) {
|
|
41141
|
+
return entity.fields.filter((field) => {
|
|
41142
|
+
if (draftSkips(field, entity, roles, rules) !== void 0) return false;
|
|
41143
|
+
if (field.required === true) return true;
|
|
41144
|
+
const role = roleOf(roles, entity.alias, field.alias)?.role;
|
|
41145
|
+
if (field.type !== "select_record_link") return false;
|
|
41146
|
+
if (role === "parent") return field.target_entity !== entity.alias;
|
|
41147
|
+
return role === "party" && (rules[field.target_entity]?.natural_key ?? []).length > 0;
|
|
41148
|
+
});
|
|
41149
|
+
}
|
|
40985
41150
|
function columnCountsRows(field) {
|
|
40986
41151
|
return field.type === "rollup" && field.aggregate_option.operation === "count";
|
|
40987
41152
|
}
|
|
@@ -41183,8 +41348,11 @@ function resolveActs(path24, screen, entity, roles, entityByAlias, rules, fieldB
|
|
|
41183
41348
|
import: resolveImport(path24, declared.import, screen, entity, entityByAlias, rules, findings)
|
|
41184
41349
|
};
|
|
41185
41350
|
}
|
|
41351
|
+
function platformWrittenKind(field) {
|
|
41352
|
+
return field.type === "date" && field.derive_from !== void 0 ? `date stamped from the row's ${field.derive_from}` : field.type;
|
|
41353
|
+
}
|
|
41186
41354
|
function importableField(field) {
|
|
41187
|
-
return !
|
|
41355
|
+
return !isPlatformWrittenField(field) && !["select_record_link", "select_member", "files"].includes(field.type);
|
|
41188
41356
|
}
|
|
41189
41357
|
function resolveImport(path24, declared, screen, entity, entityByAlias, rules, findings) {
|
|
41190
41358
|
if (declared === void 0) return null;
|
|
@@ -41239,8 +41407,8 @@ function resolveImport(path24, declared, screen, entity, entityByAlias, rules, f
|
|
|
41239
41407
|
findings.push({ severity: "error", path: at2, message: `fills "${alias}", which is not a field of "${target.alias}"` });
|
|
41240
41408
|
return [];
|
|
41241
41409
|
}
|
|
41242
|
-
if (
|
|
41243
|
-
findings.push({ severity: "error", path: at2, message: `fills "${alias}", a ${field
|
|
41410
|
+
if (isPlatformWrittenField(field)) {
|
|
41411
|
+
findings.push({ severity: "error", path: at2, message: `fills "${alias}", a ${platformWrittenKind(field)} \u2014 the workspace writes that column, so a file cannot` });
|
|
41244
41412
|
return [];
|
|
41245
41413
|
}
|
|
41246
41414
|
if (!importableField(field)) {
|
|
@@ -41295,9 +41463,10 @@ function resolveAct(act, at2, screen, entity, roles, fieldByAlias, templates, fi
|
|
|
41295
41463
|
const place = act.place === void 0 ? {} : { place: act.place };
|
|
41296
41464
|
const gate = resolveActWhen(act.when, at2, entity, roles, fieldByAlias, findings);
|
|
41297
41465
|
const stands = gate === void 0 ? {} : { when: gate };
|
|
41466
|
+
const asks = act.confirm === true ? { confirm: true } : {};
|
|
41298
41467
|
if (!("kind" in act)) {
|
|
41299
41468
|
const template = resolveTemplateAct(act.template, templates, at2, findings);
|
|
41300
|
-
return template === void 0 ? [] : [{ kind: "template", label: act.label, ...place, ...stands, template }];
|
|
41469
|
+
return template === void 0 ? [] : [{ kind: "template", label: act.label, ...place, ...stands, ...asks, template }];
|
|
41301
41470
|
}
|
|
41302
41471
|
if (!writesRecord(screen.writes)) {
|
|
41303
41472
|
const [pressed, why] = act.kind === "agent" ? [`runs "${act.agent}"`, "an agent act fills the record's own fields"] : [`hands the row to "${act.workflow}"`, "a hand-off moves the row"];
|
|
@@ -41310,7 +41479,7 @@ function resolveAct(act, at2, screen, entity, roles, fieldByAlias, templates, fi
|
|
|
41310
41479
|
}
|
|
41311
41480
|
if (act.kind === "workflow") {
|
|
41312
41481
|
const inputs = resolveActInputs(act.inputs, at2, entity, fieldByAlias, findings);
|
|
41313
|
-
return inputs === void 0 ? [] : [{ kind: "workflow", label: act.label, ...place, ...stands, workflow: act.workflow, inputs }];
|
|
41482
|
+
return inputs === void 0 ? [] : [{ kind: "workflow", label: act.label, ...place, ...stands, ...asks, workflow: act.workflow, inputs }];
|
|
41314
41483
|
}
|
|
41315
41484
|
for (const dup of findDuplicates(act.fills)) {
|
|
41316
41485
|
findings.push({ severity: "error", path: at2, message: `fills "${dup}" twice \u2014 one field is written once` });
|
|
@@ -41321,13 +41490,13 @@ function resolveAct(act, at2, screen, entity, roles, fieldByAlias, templates, fi
|
|
|
41321
41490
|
findings.push({ severity: "error", path: at2, message: `fills "${alias}", which is not a field of entity "${entity.alias}"` });
|
|
41322
41491
|
return [];
|
|
41323
41492
|
}
|
|
41324
|
-
if (
|
|
41325
|
-
findings.push({ severity: "error", path: at2, message: `fills "${alias}", a ${field
|
|
41493
|
+
if (isPlatformWrittenField(field)) {
|
|
41494
|
+
findings.push({ severity: "error", path: at2, message: `fills "${alias}", a ${platformWrittenKind(field)} \u2014 the workspace writes that column, so a run cannot propose one` });
|
|
41326
41495
|
return [];
|
|
41327
41496
|
}
|
|
41328
41497
|
return [field];
|
|
41329
41498
|
});
|
|
41330
|
-
return fills.length === 0 ? [] : [{ kind: "agent", label: act.label, ...place, ...stands, agent: act.agent, fills }];
|
|
41499
|
+
return fills.length === 0 ? [] : [{ kind: "agent", label: act.label, ...place, ...stands, ...asks, agent: act.agent, fills }];
|
|
41331
41500
|
}
|
|
41332
41501
|
function resolveActWhen(declared, at2, entity, roles, fieldByAlias, findings) {
|
|
41333
41502
|
if (declared === void 0) return void 0;
|
|
@@ -41959,7 +42128,8 @@ var SECTION_WHY = {
|
|
|
41959
42128
|
journal: "the record's own rows, a day and a markdown body, written by a member \u2014 parties are who it was with",
|
|
41960
42129
|
parent: "rows this record owns",
|
|
41961
42130
|
named: "rows that name this record",
|
|
41962
|
-
link: "reached through a link carrying no role"
|
|
42131
|
+
link: "reached through a link carrying no role",
|
|
42132
|
+
publish: "the screen draws these rows as a publish desk, and a clause naming the reading IS the reading"
|
|
41963
42133
|
};
|
|
41964
42134
|
function recordSections(entity, entities, roles, header, shape = CUSTOM_SHAPE, door = recordDoor(shape, entity, entities, roles), drawn = void 0) {
|
|
41965
42135
|
const band = recordBand(shape, entity, entities, roles, door, header.levels);
|
|
@@ -41992,6 +42162,14 @@ function recordSections(entity, entities, roles, header, shape = CUSTOM_SHAPE, d
|
|
|
41992
42162
|
listed.add(child.alias);
|
|
41993
42163
|
drawnRelations.add(relationKey(child.alias, link.alias));
|
|
41994
42164
|
}
|
|
42165
|
+
const stated3 = drawn?.[child.alias];
|
|
42166
|
+
if (stated3?.draw === "publish") {
|
|
42167
|
+
const byName = CHILD_COLUMN_ROLES.flatMap((role) => fieldsWithRole(child, roles, role)).filter(
|
|
42168
|
+
(field) => field.alias !== link.alias
|
|
42169
|
+
);
|
|
42170
|
+
children.push({ kind: "children", why: SECTION_WHY.publish, child, via, link, fields: byName });
|
|
42171
|
+
continue;
|
|
42172
|
+
}
|
|
41995
42173
|
const setField = fieldsWithRole(child, roles, "expected_set").find(
|
|
41996
42174
|
(field) => field.type === "select" && field.multi !== true
|
|
41997
42175
|
);
|
|
@@ -42061,10 +42239,14 @@ function recordSections(entity, entities, roles, header, shape = CUSTOM_SHAPE, d
|
|
|
42061
42239
|
// ladder's own rows reached from the other side — a fact listing them by
|
|
42062
42240
|
// name would be the history drawn twice, once as a list of ids.
|
|
42063
42241
|
...history === void 0 ? [] : [history.back],
|
|
42064
|
-
// …AND THE FIGURES A SECTION BELOW HEADS ITSELF WITH
|
|
42065
|
-
|
|
42066
|
-
|
|
42067
|
-
|
|
42242
|
+
// …AND THE FIGURES A SECTION BELOW HEADS ITSELF WITH, and the words a
|
|
42243
|
+
// desk below SENDS: the bodies are drawn as prose above the strip, so a
|
|
42244
|
+
// fact for one is the same column edited twice, and a band holding
|
|
42245
|
+
// nothing else is an empty grid with an entry in the rail.
|
|
42246
|
+
...Object.values(drawn ?? {}).flatMap((clause) => {
|
|
42247
|
+
const named3 = clause.draw === "ledger" ? clause.summary.map(summaryFieldOf) : clause.draw === "publish" ? publishBodies(clause) : [];
|
|
42248
|
+
return named3.flatMap((alias) => entity.fields.filter((field) => field.alias === alias));
|
|
42249
|
+
}),
|
|
42068
42250
|
...ownSets,
|
|
42069
42251
|
...files,
|
|
42070
42252
|
...judged === void 0 ? [] : [judged],
|
|
@@ -42526,6 +42708,10 @@ function restingScreen(args) {
|
|
|
42526
42708
|
slots: [],
|
|
42527
42709
|
outcomes: {},
|
|
42528
42710
|
columns: [],
|
|
42711
|
+
// A CHILD'S DRAFT IS THE SECTION'S. The `create` clause is the SCREEN's, and
|
|
42712
|
+
// a screen is one register over one entity — these rows are opened from the
|
|
42713
|
+
// record above them, where the composer asking for them is the section's own.
|
|
42714
|
+
create: [],
|
|
42529
42715
|
acts: { row: [], selection: [], record: [], export: null, import: null },
|
|
42530
42716
|
summary: { columnTotals: [], bandTotals: [], above: null, ageing: null, trend: null },
|
|
42531
42717
|
period: null,
|
|
@@ -42751,9 +42937,113 @@ function checkFactGroups(screen, entities, roles) {
|
|
|
42751
42937
|
)
|
|
42752
42938
|
);
|
|
42753
42939
|
}
|
|
42940
|
+
var SECTION_READING = {
|
|
42941
|
+
expected_set: "a set of the entries it owes",
|
|
42942
|
+
timeline: "a run of stops",
|
|
42943
|
+
thread: "a correspondence",
|
|
42944
|
+
journal: "a log"
|
|
42945
|
+
};
|
|
42754
42946
|
function childRegisters(screen, entities, roles) {
|
|
42755
42947
|
const sections = recordSections(screen.entity, entities, roles, recordHeader(screen, entities, roles), screen.screen.shape, screen.record, screen.screen.sections);
|
|
42756
|
-
return
|
|
42948
|
+
return {
|
|
42949
|
+
registers: new Map(sections.flatMap((section) => section.kind === "children" ? [[section.child.alias, section]] : [])),
|
|
42950
|
+
readings: new Map(
|
|
42951
|
+
sections.flatMap(
|
|
42952
|
+
(section) => "child" in section && section.kind !== "children" ? [[section.child.alias, SECTION_READING[section.kind]]] : []
|
|
42953
|
+
)
|
|
42954
|
+
)
|
|
42955
|
+
};
|
|
42956
|
+
}
|
|
42957
|
+
function checkPublishDraw(at2, alias, clause, section, screen, entities, roles) {
|
|
42958
|
+
const findings = [];
|
|
42959
|
+
const fail3 = (path24, message2) => {
|
|
42960
|
+
findings.push({ severity: "error", path: path24, message: message2 });
|
|
42961
|
+
};
|
|
42962
|
+
const child = section.child;
|
|
42963
|
+
const textOf = (owner, named3) => {
|
|
42964
|
+
const field = owner.fields.find((one) => one.alias === named3);
|
|
42965
|
+
return field !== void 0 && resolvedFieldType(field, owner, entities) === "text" ? field : void 0;
|
|
42966
|
+
};
|
|
42967
|
+
const selectOf = (owner, named3) => {
|
|
42968
|
+
const field = owner.fields.find((one) => one.alias === named3);
|
|
42969
|
+
return field?.type === "select" && field.multi !== true ? field : void 0;
|
|
42970
|
+
};
|
|
42971
|
+
const namedText = (owner, what, named3, path24) => {
|
|
42972
|
+
if (named3 === void 0) return;
|
|
42973
|
+
if (textOf(owner, named3) === void 0) {
|
|
42974
|
+
fail3(path24, `${what} "${named3}", which is not a text field of entity "${owner.alias}"`);
|
|
42975
|
+
}
|
|
42976
|
+
};
|
|
42977
|
+
const party = fieldsWithRole(child, roles, "party").find((field) => field.type === "select_record_link");
|
|
42978
|
+
const destination = party?.type === "select_record_link" ? entities.find((one) => one.alias === party.target_entity) : void 0;
|
|
42979
|
+
if (party === void 0 || destination === void 0) {
|
|
42980
|
+
fail3(at2, `draws "${alias}" as a publish desk, and its rows name no destination \u2014 declare the link to the destination entity as a "party"`);
|
|
42981
|
+
}
|
|
42982
|
+
const lifecycle = fieldsWithRole(child, roles, "lifecycle").find((field) => field.type === "select");
|
|
42983
|
+
if (lifecycle === void 0 || lifecycle.type !== "select") {
|
|
42984
|
+
fail3(at2, `draws "${alias}" as a publish desk, and its rows have no lifecycle \u2014 the chip's state is a stage of the child's own flow`);
|
|
42985
|
+
} else {
|
|
42986
|
+
for (const [state, named3] of Object.entries(clause.states)) {
|
|
42987
|
+
if (!lifecycle.options.some((option) => option.alias === named3)) {
|
|
42988
|
+
fail3(`${at2}.states.${state}`, `names "${named3}", which is not an option of "${lifecycle.label}" on entity "${child.alias}"`);
|
|
42989
|
+
}
|
|
42990
|
+
}
|
|
42991
|
+
const opens = lifecycle.options[0];
|
|
42992
|
+
if (opens !== void 0 && lifecycle.options.some((option) => option.alias === clause.states.queued) && clause.states.queued !== opens.alias) {
|
|
42993
|
+
fail3(
|
|
42994
|
+
`${at2}.states.queued`,
|
|
42995
|
+
`names "${clause.states.queued}", and "${lifecycle.label}" opens at "${opens.alias}" \u2014 a destination is queued by opening a row, so the queued stage is the flow's first`
|
|
42996
|
+
);
|
|
42997
|
+
}
|
|
42998
|
+
}
|
|
42999
|
+
if (destination !== void 0 && fieldsWithRole(destination, roles, "identity").length === 0) {
|
|
43000
|
+
fail3(at2, `draws "${alias}" over "${destination.alias}", which names no row of itself \u2014 a chip is the destination's own name, so it declares an "identity"`);
|
|
43001
|
+
}
|
|
43002
|
+
namedText(child, "overrides with", clause.text, `${at2}.text`);
|
|
43003
|
+
namedText(child, "reads its permalink from", clause.permalink, `${at2}.permalink`);
|
|
43004
|
+
namedText(child, "reads the refusal from", clause.error, `${at2}.error`);
|
|
43005
|
+
namedText(screen.entity, "carries the address", clause.link, `${at2}.link`);
|
|
43006
|
+
if (destination === void 0) return findings;
|
|
43007
|
+
const reach = clause.destination;
|
|
43008
|
+
const kind = selectOf(destination, reach.network.field);
|
|
43009
|
+
if (kind === void 0) {
|
|
43010
|
+
fail3(`${at2}.destination.network.field`, `names "${reach.network.field}", which is not a single select of entity "${destination.alias}"`);
|
|
43011
|
+
} else {
|
|
43012
|
+
for (const option of Object.keys(reach.network.options)) {
|
|
43013
|
+
if (!kind.options.some((one) => one.alias === option)) {
|
|
43014
|
+
fail3(`${at2}.destination.network.options`, `names "${option}", which is not an option of "${kind.label}" on entity "${destination.alias}"`);
|
|
43015
|
+
}
|
|
43016
|
+
}
|
|
43017
|
+
}
|
|
43018
|
+
namedText(destination, "reads the connected account from", reach.connected, `${at2}.destination.connected`);
|
|
43019
|
+
if (typeof reach.bodies === "string") {
|
|
43020
|
+
namedText(screen.entity, "goes out with", reach.bodies, `${at2}.destination.bodies`);
|
|
43021
|
+
} else {
|
|
43022
|
+
const picks = selectOf(destination, reach.bodies.field);
|
|
43023
|
+
if (picks === void 0) {
|
|
43024
|
+
fail3(`${at2}.destination.bodies.field`, `names "${reach.bodies.field}", which is not a single select of entity "${destination.alias}"`);
|
|
43025
|
+
}
|
|
43026
|
+
for (const [option, body] of Object.entries(reach.bodies.options)) {
|
|
43027
|
+
if (picks !== void 0 && !picks.options.some((one) => one.alias === option)) {
|
|
43028
|
+
fail3(`${at2}.destination.bodies.options`, `names "${option}", which is not an option of "${picks.label}" on entity "${destination.alias}"`);
|
|
43029
|
+
}
|
|
43030
|
+
namedText(screen.entity, "goes out with", body, `${at2}.destination.bodies.options`);
|
|
43031
|
+
}
|
|
43032
|
+
}
|
|
43033
|
+
const offered = reach.offered;
|
|
43034
|
+
if (offered !== void 0) {
|
|
43035
|
+
const gate = selectOf(destination, offered.field);
|
|
43036
|
+
if (gate === void 0) {
|
|
43037
|
+
fail3(`${at2}.destination.offered.field`, `names "${offered.field}", which is not a single select of entity "${destination.alias}"`);
|
|
43038
|
+
} else {
|
|
43039
|
+
for (const option of offered.in) {
|
|
43040
|
+
if (!gate.options.some((one) => one.alias === option)) {
|
|
43041
|
+
fail3(`${at2}.destination.offered.in`, `names "${option}", which is not an option of "${gate.label}" on entity "${destination.alias}"`);
|
|
43042
|
+
}
|
|
43043
|
+
}
|
|
43044
|
+
}
|
|
43045
|
+
}
|
|
43046
|
+
return findings;
|
|
42757
43047
|
}
|
|
42758
43048
|
function childRuns(screen, entities, roles) {
|
|
42759
43049
|
const archetype = recordArchetype(screen.screen.shape, screen.entity, entities, roles);
|
|
@@ -42769,19 +43059,30 @@ function childRuns(screen, entities, roles) {
|
|
|
42769
43059
|
function checkSectionDraws(screen, entities, roles) {
|
|
42770
43060
|
const drawn = Object.entries(screen.screen.sections ?? {});
|
|
42771
43061
|
if (drawn.length === 0) return [];
|
|
42772
|
-
const registers = childRegisters(screen, entities, roles);
|
|
43062
|
+
const { registers, readings } = childRegisters(screen, entities, roles);
|
|
42773
43063
|
const runs = childRuns(screen, entities, roles);
|
|
42774
43064
|
const takes = SHAPE_REGISTRY.worksheet.slots.sell.roles;
|
|
43065
|
+
const desks = drawn.flatMap(([alias, clause]) => clause.draw === "publish" ? [alias] : []);
|
|
43066
|
+
if (desks.length > 1) {
|
|
43067
|
+
return [
|
|
43068
|
+
{
|
|
43069
|
+
severity: "error",
|
|
43070
|
+
path: `apps.${screen.app.alias}.screen.sections`,
|
|
43071
|
+
message: `draws ${desks.join(" and ")} as publish desks \u2014 a record is sent from ONE strip of destinations, and a row of the register wears the marks of one`
|
|
43072
|
+
}
|
|
43073
|
+
];
|
|
43074
|
+
}
|
|
42775
43075
|
return drawn.flatMap(([alias, clause]) => {
|
|
42776
43076
|
const at2 = `apps.${screen.app.alias}.screen.sections.${alias}`;
|
|
42777
43077
|
const section = registers.get(alias);
|
|
42778
43078
|
if (section === void 0) {
|
|
43079
|
+
const read2 = readings.get(alias);
|
|
42779
43080
|
const offered = [...registers.keys()];
|
|
42780
43081
|
return [
|
|
42781
43082
|
{
|
|
42782
43083
|
severity: "error",
|
|
42783
43084
|
path: at2,
|
|
42784
|
-
message: offered.length === 0 ? `names "${alias}", and this record draws no register of a child's rows to be drawn another way` : `names "${alias}", which this record draws no register of \u2014 its registers are addressed by ${offered.join(", ")}`
|
|
43085
|
+
message: read2 !== void 0 ? `names "${alias}", whose rows this record already draws as ${read2} rather than as a register \u2014 and "publish" is the one draw that outranks a reading the roles decided` : offered.length === 0 ? `names "${alias}", and this record draws no register of a child's rows to be drawn another way` : `names "${alias}", which this record draws no register of \u2014 its registers are addressed by ${offered.join(", ")}`
|
|
42785
43086
|
}
|
|
42786
43087
|
];
|
|
42787
43088
|
}
|
|
@@ -42821,6 +43122,9 @@ function checkSectionDraws(screen, entities, roles) {
|
|
|
42821
43122
|
] : [];
|
|
42822
43123
|
});
|
|
42823
43124
|
}
|
|
43125
|
+
if (clause.draw === "publish") {
|
|
43126
|
+
return checkPublishDraw(at2, alias, clause, section, screen, entities, roles);
|
|
43127
|
+
}
|
|
42824
43128
|
if (clause.draw === "ledger") {
|
|
42825
43129
|
return clause.summary.flatMap((entry) => {
|
|
42826
43130
|
const field = summaryFieldOf(entry);
|
|
@@ -42887,7 +43191,9 @@ function checkSectionDraws(screen, entities, roles) {
|
|
|
42887
43191
|
function unregisteredChildren(screen, entities, roles, registered) {
|
|
42888
43192
|
const entries2 = recordRecipe(screen.screen.shape, screen.entity, entities, roles, recordHeader(screen, entities, roles), screen.record, screen.screen.sections);
|
|
42889
43193
|
const priced = new Set(
|
|
42890
|
-
Object.entries(screen.screen.sections ?? {}).flatMap(
|
|
43194
|
+
Object.entries(screen.screen.sections ?? {}).flatMap(
|
|
43195
|
+
([alias, clause]) => clause.draw === "worksheet" || clause.draw === "publish" ? [alias] : []
|
|
43196
|
+
)
|
|
42891
43197
|
);
|
|
42892
43198
|
const read2 = new Set(
|
|
42893
43199
|
entries2.flatMap(({ draw, section }) => {
|
|
@@ -44101,7 +44407,7 @@ function resultSideEffects(result) {
|
|
|
44101
44407
|
}
|
|
44102
44408
|
|
|
44103
44409
|
// src/version.ts
|
|
44104
|
-
var VERSION = "0.
|
|
44410
|
+
var VERSION = "0.223.0";
|
|
44105
44411
|
|
|
44106
44412
|
// src/timezone.ts
|
|
44107
44413
|
function machineTimezone() {
|
|
@@ -45792,7 +46098,8 @@ publish each declaration on their own.
|
|
|
45792
46098
|
\`src/workflows/<alias>.ts\` with declared \`inputs\`, so a caller's bad value is a \`field_errors\`
|
|
45793
46099
|
reply rather than a half-written row.
|
|
45794
46100
|
- **Declare what it changes.** \`package.json#lotics.writes\` names the fields the workflows
|
|
45795
|
-
write
|
|
46101
|
+
write and \`lotics.deletes\` the tables whose ROWS they take \u2014 a delete names no column;
|
|
46102
|
+
\`lotics app check\` refuses a body that reaches outside either.
|
|
45796
46103
|
- **No concrete id in source.** \`rec_\`, \`fld_\` and \`opt_\` literals belong in \`.lotics/\`, which
|
|
45797
46104
|
is generated. \`lotics app codegen\` writes the \`F\`/\`OPT\`/\`TBL\` maps to type against.
|
|
45798
46105
|
- **An alias is a promise once the API is published.** Renaming one, or narrowing what it takes,
|
|
@@ -52220,7 +52527,7 @@ function readLeadingComments(stmt) {
|
|
|
52220
52527
|
var TOOL_INPUT_SEMANTICS = {
|
|
52221
52528
|
// ─── Bulk record mutations ─────────────────────────────────────────────
|
|
52222
52529
|
update_records: {
|
|
52223
|
-
table_id: { kind: "table_ref" },
|
|
52530
|
+
table_id: { kind: "table_ref", removes: false },
|
|
52224
52531
|
record_ids: { kind: "record_refs" },
|
|
52225
52532
|
filters: { kind: "filter_tree", table_from: "table_id" },
|
|
52226
52533
|
set: { kind: "record_data", table_from: "table_id", writes: true },
|
|
@@ -52252,7 +52559,7 @@ var TOOL_INPUT_SEMANTICS = {
|
|
|
52252
52559
|
field_edits: { kind: "dynamic" }
|
|
52253
52560
|
},
|
|
52254
52561
|
create_records: {
|
|
52255
|
-
table_id: { kind: "table_ref" },
|
|
52562
|
+
table_id: { kind: "table_ref", removes: false },
|
|
52256
52563
|
records: { kind: "record_data_array", table_from: "table_id", writes: true },
|
|
52257
52564
|
field_keys: { kind: "field_key_refs", table_from: "table_id", writes: true },
|
|
52258
52565
|
// Per-record cells — semantic depends on field_keys[i]; tool resolves.
|
|
@@ -52263,7 +52570,7 @@ var TOOL_INPUT_SEMANTICS = {
|
|
|
52263
52570
|
// `rec_*` identifiers) which is all this kind asserts: normalization passes it
|
|
52264
52571
|
// through raw and nothing resolves it against stored rows.
|
|
52265
52572
|
ids: { kind: "record_refs" },
|
|
52266
|
-
source_table_id: { kind: "table_ref" },
|
|
52573
|
+
source_table_id: { kind: "table_ref", removes: false },
|
|
52267
52574
|
record_ids: { kind: "record_refs" },
|
|
52268
52575
|
filters: { kind: "filter_tree", table_from: "table_id" },
|
|
52269
52576
|
// Cross-table mapping — {source_field_key: target_field_key}. Not
|
|
@@ -52276,28 +52583,34 @@ var TOOL_INPUT_SEMANTICS = {
|
|
|
52276
52583
|
return_fields: { kind: "field_key_refs", table_from: "table_id", writes: false }
|
|
52277
52584
|
},
|
|
52278
52585
|
delete_records: {
|
|
52279
|
-
|
|
52586
|
+
// THE ONE CALL THAT TAKES ROWS. Every column of the row goes with it, so
|
|
52587
|
+
// nothing field-keyed can carry the fact and it rides the table position —
|
|
52588
|
+
// which is also the only table the source NAMES. `cascade: true` reaches the
|
|
52589
|
+
// rows of whatever tables a single-valued link points here from, and those
|
|
52590
|
+
// are the link graph's answer rather than the source's, so a scan can no
|
|
52591
|
+
// more resolve them than it can a field key computed at runtime.
|
|
52592
|
+
table_id: { kind: "table_ref", removes: true },
|
|
52280
52593
|
record_ids: { kind: "record_refs" },
|
|
52281
52594
|
filters: { kind: "filter_tree", table_from: "table_id" },
|
|
52282
52595
|
cascade: { kind: "scalar" }
|
|
52283
52596
|
},
|
|
52284
52597
|
restore_records: {
|
|
52285
|
-
table_id: { kind: "table_ref" },
|
|
52598
|
+
table_id: { kind: "table_ref", removes: false },
|
|
52286
52599
|
record_ids: { kind: "record_refs" },
|
|
52287
52600
|
filters: { kind: "filter_tree", table_from: "table_id" }
|
|
52288
52601
|
},
|
|
52289
52602
|
lock_records: {
|
|
52290
|
-
table_id: { kind: "table_ref" },
|
|
52603
|
+
table_id: { kind: "table_ref", removes: false },
|
|
52291
52604
|
record_ids: { kind: "record_refs" },
|
|
52292
52605
|
filters: { kind: "filter_tree", table_from: "table_id" }
|
|
52293
52606
|
},
|
|
52294
52607
|
unlock_records: {
|
|
52295
|
-
table_id: { kind: "table_ref" },
|
|
52608
|
+
table_id: { kind: "table_ref", removes: false },
|
|
52296
52609
|
record_ids: { kind: "record_refs" },
|
|
52297
52610
|
filters: { kind: "filter_tree", table_from: "table_id" }
|
|
52298
52611
|
},
|
|
52299
52612
|
request_locked_record_change: {
|
|
52300
|
-
table_id: { kind: "table_ref" },
|
|
52613
|
+
table_id: { kind: "table_ref", removes: false },
|
|
52301
52614
|
record_id: { kind: "record_ref" },
|
|
52302
52615
|
// Map of field_key → new value; key validation and per-field value
|
|
52303
52616
|
// resolution depend on the table schema, resolved at execute time.
|
|
@@ -52306,7 +52619,7 @@ var TOOL_INPUT_SEMANTICS = {
|
|
|
52306
52619
|
},
|
|
52307
52620
|
// ─── Query surfaces ────────────────────────────────────────────────────
|
|
52308
52621
|
query_records: {
|
|
52309
|
-
table_id: { kind: "table_ref" },
|
|
52622
|
+
table_id: { kind: "table_ref", removes: false },
|
|
52310
52623
|
view_id: { kind: "scalar" },
|
|
52311
52624
|
field_keys: { kind: "field_key_refs", table_from: "table_id", writes: false },
|
|
52312
52625
|
filters: { kind: "filter_tree", table_from: "table_id" },
|
|
@@ -52347,7 +52660,7 @@ var TOOL_INPUT_SEMANTICS = {
|
|
|
52347
52660
|
limit: { kind: "scalar" }
|
|
52348
52661
|
},
|
|
52349
52662
|
aggregate_records: {
|
|
52350
|
-
table_id: { kind: "table_ref" },
|
|
52663
|
+
table_id: { kind: "table_ref", removes: false },
|
|
52351
52664
|
filters: { kind: "filter_tree", table_from: "table_id" },
|
|
52352
52665
|
// Nested {field_key, operation} shape — current vocabulary has no
|
|
52353
52666
|
// nested-field-key kind, so the tool resolves at execute time.
|
|
@@ -52358,14 +52671,14 @@ var TOOL_INPUT_SEMANTICS = {
|
|
|
52358
52671
|
get_record: {
|
|
52359
52672
|
// Schema only takes `record_id`; table_id is optional/derived.
|
|
52360
52673
|
record_id: { kind: "record_ref" },
|
|
52361
|
-
table_id: { kind: "table_ref" },
|
|
52674
|
+
table_id: { kind: "table_ref", removes: false },
|
|
52362
52675
|
// Keys on the record's own table, which `table_id` names when supplied and
|
|
52363
52676
|
// the record resolves otherwise — the same position it holds on a query.
|
|
52364
52677
|
field_keys: { kind: "field_key_refs", table_from: "table_id", writes: false }
|
|
52365
52678
|
},
|
|
52366
52679
|
// ─── View management ──────────────────────────────────────────────────
|
|
52367
52680
|
create_view: {
|
|
52368
|
-
table_id: { kind: "table_ref" },
|
|
52681
|
+
table_id: { kind: "table_ref", removes: false },
|
|
52369
52682
|
name: { kind: "text" },
|
|
52370
52683
|
frozen_columns: { kind: "scalar" },
|
|
52371
52684
|
// summary keys are field_keys on `table_id`, values are view-summary
|
|
@@ -52402,13 +52715,13 @@ var TOOL_INPUT_SEMANTICS = {
|
|
|
52402
52715
|
},
|
|
52403
52716
|
// ─── Buttons (table-scoped field with action) ─────────────────────────
|
|
52404
52717
|
press_button: {
|
|
52405
|
-
table_id: { kind: "table_ref" },
|
|
52718
|
+
table_id: { kind: "table_ref", removes: false },
|
|
52406
52719
|
record_id: { kind: "record_ref" },
|
|
52407
52720
|
field_key: { kind: "field_key_ref", table_from: "table_id", writes: false }
|
|
52408
52721
|
},
|
|
52409
52722
|
// ─── Table management ─────────────────────────────────────────────────
|
|
52410
52723
|
update_table: {
|
|
52411
|
-
table_id: { kind: "table_ref" },
|
|
52724
|
+
table_id: { kind: "table_ref", removes: false },
|
|
52412
52725
|
name: { kind: "text" },
|
|
52413
52726
|
description: { kind: "text" },
|
|
52414
52727
|
// add_fields carries NEW field definitions (not refs to existing ones).
|
|
@@ -52818,8 +53131,9 @@ function fieldInputs(alias, type) {
|
|
|
52818
53131
|
}
|
|
52819
53132
|
function bodyWrites(source) {
|
|
52820
53133
|
const parsed = parseWorkflowJs(source, { toolNames: void 0 });
|
|
52821
|
-
if (!parsed.ok) return { writes: [], unknownTools: [], parsed: false };
|
|
53134
|
+
if (!parsed.ok) return { writes: [], deletes: [], unknownTools: [], parsed: false };
|
|
52822
53135
|
const writes = [];
|
|
53136
|
+
const deletes = [];
|
|
52823
53137
|
const unknownTools = /* @__PURE__ */ new Set();
|
|
52824
53138
|
walkStepTree(parsed.steps, (step) => {
|
|
52825
53139
|
if (step.type !== "tool_call") return;
|
|
@@ -52830,12 +53144,17 @@ function bodyWrites(source) {
|
|
|
52830
53144
|
}
|
|
52831
53145
|
for (const [kwarg, value] of Object.entries(step.input)) {
|
|
52832
53146
|
const semantic = semantics[kwarg];
|
|
52833
|
-
if (semantic === void 0
|
|
53147
|
+
if (semantic === void 0) continue;
|
|
53148
|
+
if (semantic.kind === "table_ref") {
|
|
53149
|
+
if (semantic.removes) deletes.push(resolveTableFrom(semantics, step.input, kwarg));
|
|
53150
|
+
continue;
|
|
53151
|
+
}
|
|
53152
|
+
if (!("writes" in semantic) || !semantic.writes) continue;
|
|
52834
53153
|
const table = resolveTableFrom(semantics, step.input, semantic.table_from);
|
|
52835
53154
|
for (const field of writtenKeys(semantic.kind, value)) writes.push({ table, field });
|
|
52836
53155
|
}
|
|
52837
53156
|
});
|
|
52838
|
-
return { writes, unknownTools: [...unknownTools], parsed: true };
|
|
53157
|
+
return { writes, deletes, unknownTools: [...unknownTools], parsed: true };
|
|
52839
53158
|
}
|
|
52840
53159
|
function stillWritten(declaration, tables, writers) {
|
|
52841
53160
|
const aliased = aliasTables([...tables]);
|
|
@@ -52846,7 +53165,7 @@ function stillWritten(declaration, tables, writers) {
|
|
|
52846
53165
|
if (write.table === null) anyTable.add(write.field);
|
|
52847
53166
|
else written.add(on(write.table, write.field));
|
|
52848
53167
|
}
|
|
52849
|
-
for (const [key, entries2] of Object.entries(writers.generated)) {
|
|
53168
|
+
for (const [key, entries2] of Object.entries(writers.generated.writes)) {
|
|
52850
53169
|
const table = declaredTable(aliased, key);
|
|
52851
53170
|
if (table === void 0) continue;
|
|
52852
53171
|
for (const entry of entries2) {
|
|
@@ -52856,7 +53175,7 @@ function stillWritten(declaration, tables, writers) {
|
|
|
52856
53175
|
}
|
|
52857
53176
|
const writes = {};
|
|
52858
53177
|
const dropped = [];
|
|
52859
|
-
for (const [key, entries2] of Object.entries(declaration)) {
|
|
53178
|
+
for (const [key, entries2] of Object.entries(declaration.writes)) {
|
|
52860
53179
|
const table = declaredTable(aliased, key);
|
|
52861
53180
|
if (table === void 0) {
|
|
52862
53181
|
writes[key] = entries2;
|
|
@@ -52871,7 +53190,26 @@ function stillWritten(declaration, tables, writers) {
|
|
|
52871
53190
|
});
|
|
52872
53191
|
if (kept.length > 0) writes[key] = kept;
|
|
52873
53192
|
}
|
|
52874
|
-
|
|
53193
|
+
const removed = /* @__PURE__ */ new Set();
|
|
53194
|
+
let removesSomething = false;
|
|
53195
|
+
for (const table of [...writers.removed, ...removedTableIds(aliased, writers.generated.deletes)]) {
|
|
53196
|
+
if (table === null) removesSomething = true;
|
|
53197
|
+
else removed.add(table);
|
|
53198
|
+
}
|
|
53199
|
+
const deletes = [];
|
|
53200
|
+
const undeleted = [];
|
|
53201
|
+
for (const key of declaration.deletes) {
|
|
53202
|
+
const table = declaredTable(aliased, key);
|
|
53203
|
+
if (table === void 0 || removesSomething || removed.has(table.table.id)) deletes.push(key);
|
|
53204
|
+
else undeleted.push(key);
|
|
53205
|
+
}
|
|
53206
|
+
return { writes, deletes, dropped: dropped.sort(), undeleted: undeleted.sort() };
|
|
53207
|
+
}
|
|
53208
|
+
function removedTableIds(aliased, keys2) {
|
|
53209
|
+
return keys2.flatMap((key) => {
|
|
53210
|
+
const table = declaredTable(aliased, key);
|
|
53211
|
+
return table === void 0 ? [] : [table.table.id];
|
|
53212
|
+
});
|
|
52875
53213
|
}
|
|
52876
53214
|
function writtenKeys(kind, value) {
|
|
52877
53215
|
if (kind === "record_data") return isToolInputExpression(value) ? [] : Object.keys(value);
|
|
@@ -52898,9 +53236,13 @@ function literalString(value) {
|
|
|
52898
53236
|
function writtenTableIds(bodies) {
|
|
52899
53237
|
return [
|
|
52900
53238
|
...new Set(
|
|
52901
|
-
bodies.flatMap(
|
|
52902
|
-
|
|
52903
|
-
|
|
53239
|
+
bodies.flatMap((body) => {
|
|
53240
|
+
const scanned = bodyWrites(body.source);
|
|
53241
|
+
return [
|
|
53242
|
+
...scanned.writes.flatMap((write) => write.table === null ? [] : [write.table]),
|
|
53243
|
+
...scanned.deletes.flatMap((table) => table === null ? [] : [table])
|
|
53244
|
+
];
|
|
53245
|
+
})
|
|
52904
53246
|
)
|
|
52905
53247
|
];
|
|
52906
53248
|
}
|
|
@@ -52909,7 +53251,7 @@ function writeFindings(declaration, bodies, tables) {
|
|
|
52909
53251
|
const byTable = /* @__PURE__ */ new Map();
|
|
52910
53252
|
const findings = [];
|
|
52911
53253
|
const unresolved = [];
|
|
52912
|
-
for (const [key, entries2] of Object.entries(declaration)) {
|
|
53254
|
+
for (const [key, entries2] of Object.entries(declaration.writes)) {
|
|
52913
53255
|
const table = declaredTable(aliased, key);
|
|
52914
53256
|
if (table === void 0) {
|
|
52915
53257
|
unresolved.push(key);
|
|
@@ -52930,6 +53272,12 @@ function writeFindings(declaration, bodies, tables) {
|
|
|
52930
53272
|
}
|
|
52931
53273
|
byTable.set(table.table.id, fields);
|
|
52932
53274
|
}
|
|
53275
|
+
const removable = /* @__PURE__ */ new Set();
|
|
53276
|
+
for (const key of declaration.deletes) {
|
|
53277
|
+
const table = declaredTable(aliased, key);
|
|
53278
|
+
if (table === void 0) unresolved.push(key);
|
|
53279
|
+
else removable.add(table.table.id);
|
|
53280
|
+
}
|
|
52933
53281
|
const anyDeclared = new Set([...byTable.values()].flatMap((fields) => [...fields]));
|
|
52934
53282
|
const labelById = new Map(aliased.map((table) => [table.table.id, table.table.name]));
|
|
52935
53283
|
const unknownTools = /* @__PURE__ */ new Set();
|
|
@@ -52937,6 +53285,17 @@ function writeFindings(declaration, bodies, tables) {
|
|
|
52937
53285
|
const named3 = /* @__PURE__ */ new Set();
|
|
52938
53286
|
const scanned = bodyWrites(body.source);
|
|
52939
53287
|
for (const tool of scanned.unknownTools) unknownTools.add(tool);
|
|
53288
|
+
const taken = /* @__PURE__ */ new Set();
|
|
53289
|
+
for (const removed of scanned.deletes) {
|
|
53290
|
+
if (removed === null ? removable.size > 0 : removable.has(removed)) continue;
|
|
53291
|
+
if (taken.has(removed)) continue;
|
|
53292
|
+
taken.add(removed);
|
|
53293
|
+
const table = removed === null ? null : labelById.get(removed) ?? removed;
|
|
53294
|
+
findings.push({
|
|
53295
|
+
where: body.alias,
|
|
53296
|
+
message: `${body.alias} deletes rows${table === null ? "" : ` of ${table}`}, which package.json#lotics.deletes does not declare.`
|
|
53297
|
+
});
|
|
53298
|
+
}
|
|
52940
53299
|
for (const write of scanned.writes) {
|
|
52941
53300
|
const declared = write.table === null ? anyDeclared : byTable.get(write.table) ?? /* @__PURE__ */ new Set();
|
|
52942
53301
|
if (declared.has(write.field)) continue;
|
|
@@ -53107,6 +53466,18 @@ function createAlias(entity) {
|
|
|
53107
53466
|
function importAlias(entity) {
|
|
53108
53467
|
return `import_${entity}`;
|
|
53109
53468
|
}
|
|
53469
|
+
function removeAlias(entity) {
|
|
53470
|
+
return `remove_${entity}`;
|
|
53471
|
+
}
|
|
53472
|
+
function destinationsQueryAlias(entity) {
|
|
53473
|
+
return `${entity}_destinations`;
|
|
53474
|
+
}
|
|
53475
|
+
function placedQueryAlias(child) {
|
|
53476
|
+
return `${child}_in_view`;
|
|
53477
|
+
}
|
|
53478
|
+
function destinationColumnAlias(party, column) {
|
|
53479
|
+
return `${party}_${column}`;
|
|
53480
|
+
}
|
|
53110
53481
|
|
|
53111
53482
|
// src/plan_bind.ts
|
|
53112
53483
|
var CARDINAL_TYPES = /* @__PURE__ */ new Set(["select", "select_member"]);
|
|
@@ -53140,7 +53511,10 @@ var WORDS = {
|
|
|
53140
53511
|
offered: (target) => `Not one of the ${target.toLowerCase()} this may point at.`,
|
|
53141
53512
|
mint: (target, label) => `Nothing in ${target} matches: give a ${label.toLowerCase()} to open a new one.`,
|
|
53142
53513
|
opened: (entity) => `Opened one row of ${entity}.`,
|
|
53143
|
-
updated: (entity) => `Changed one row of ${entity}
|
|
53514
|
+
updated: (entity) => `Changed one row of ${entity}.`,
|
|
53515
|
+
withdrawn: (entity) => `Took one row of ${entity} back.`,
|
|
53516
|
+
sent: "This one has already gone out, so it cannot be taken back.",
|
|
53517
|
+
placed: (destination) => `This one is already on that ${destination.toLowerCase()}.`
|
|
53144
53518
|
},
|
|
53145
53519
|
verbs: {
|
|
53146
53520
|
open: (entity) => `New ${entity}`,
|
|
@@ -53154,7 +53528,9 @@ var WORDS = {
|
|
|
53154
53528
|
record: (entity, screen) => `One row of ${entity} by id \u2014 the record page the "${screen}" screen opens.`,
|
|
53155
53529
|
picker: (table, column) => `The ${table} a "${column}" may point at, narrowed by what the reader typed.`,
|
|
53156
53530
|
children: (child, parent, link) => `The ${child} that name one ${parent} through "${link}".`,
|
|
53157
|
-
history: (child, parent) => `The ${child} of one row of ${parent}, latest first
|
|
53531
|
+
history: (child, parent) => `The ${child} of one row of ${parent}, latest first.`,
|
|
53532
|
+
destinations: (destination) => `Every row of ${destination} a post can be sent to, by name.`,
|
|
53533
|
+
placed: (child, parent) => `The ${child} of the ${parent} rows named \u2014 where each of them has already been sent.`
|
|
53158
53534
|
}
|
|
53159
53535
|
},
|
|
53160
53536
|
diacritics: {
|
|
@@ -53168,7 +53544,10 @@ var WORDS = {
|
|
|
53168
53544
|
offered: (target) => `D\xF2ng ${target.toLowerCase()} n\xE0y kh\xF4ng n\u1EB1m trong danh s\xE1ch \u0111\u01B0\u1EE3c ch\u1ECDn.`,
|
|
53169
53545
|
mint: (target, label) => `Kh\xF4ng c\xF3 ${target.toLowerCase()} n\xE0o kh\u1EDBp: nh\u1EADp ${label.toLowerCase()} \u0111\u1EC3 m\u1EDF m\u1EDBi.`,
|
|
53170
53546
|
opened: (entity) => `\u0110\xE3 m\u1EDF m\u1ED9t d\xF2ng ${entity}.`,
|
|
53171
|
-
updated: (entity) => `\u0110\xE3 s\u1EEDa m\u1ED9t d\xF2ng ${entity}
|
|
53547
|
+
updated: (entity) => `\u0110\xE3 s\u1EEDa m\u1ED9t d\xF2ng ${entity}.`,
|
|
53548
|
+
withdrawn: (entity) => `\u0110\xE3 r\xFAt l\u1EA1i m\u1ED9t d\xF2ng ${entity}.`,
|
|
53549
|
+
sent: "M\u1EE5c n\xE0y \u0111\xE3 g\u1EEDi \u0111i r\u1ED3i n\xEAn kh\xF4ng r\xFAt l\u1EA1i \u0111\u01B0\u1EE3c.",
|
|
53550
|
+
placed: (destination) => `M\u1EE5c n\xE0y \u0111\xE3 n\u1EB1m \u1EDF ${destination.toLowerCase()} \u0111\xF3 r\u1ED3i.`
|
|
53172
53551
|
},
|
|
53173
53552
|
verbs: {
|
|
53174
53553
|
open: (entity) => `${entity} m\u1EDBi`,
|
|
@@ -53182,7 +53561,9 @@ var WORDS = {
|
|
|
53182
53561
|
record: (entity, screen) => `M\u1ED9t d\xF2ng ${entity} theo id \u2014 trang b\u1EA3n ghi m\xE0 m\xE0n h\xECnh "${screen}" m\u1EDF.`,
|
|
53183
53562
|
picker: (table, column) => `C\xE1c ${table} m\xE0 "${column}" c\xF3 th\u1EC3 tr\u1ECF t\u1EDBi, thu h\u1EB9p theo ch\u1EEF ng\u01B0\u1EDDi xem g\xF5.`,
|
|
53184
53563
|
children: (child, parent, link) => `C\xE1c ${child} g\u1EAFn v\u1EDBi m\u1ED9t ${parent} qua "${link}".`,
|
|
53185
|
-
history: (child, parent) => `${child} c\u1EE7a m\u1ED9t d\xF2ng ${parent}, m\u1EDBi nh\u1EA5t tr\u01B0\u1EDBc
|
|
53564
|
+
history: (child, parent) => `${child} c\u1EE7a m\u1ED9t d\xF2ng ${parent}, m\u1EDBi nh\u1EA5t tr\u01B0\u1EDBc.`,
|
|
53565
|
+
destinations: (destination) => `M\u1ECDi d\xF2ng ${destination} m\xE0 m\u1ED9t b\xE0i c\xF3 th\u1EC3 g\u1EEDi t\u1EDBi, theo t\xEAn.`,
|
|
53566
|
+
placed: (child, parent) => `C\xE1c ${child} c\u1EE7a nh\u1EEFng d\xF2ng ${parent} \u0111\u01B0\u1EE3c n\xEAu \u2014 n\u01A1i m\u1ED7i d\xF2ng \u0111\xE3 \u0111\u01B0\u1EE3c g\u1EEDi t\u1EDBi.`
|
|
53186
53567
|
}
|
|
53187
53568
|
}
|
|
53188
53569
|
};
|
|
@@ -53247,6 +53628,135 @@ function tableIndex(aliased) {
|
|
|
53247
53628
|
function fieldIndex(table) {
|
|
53248
53629
|
return liveIndex(table.fields, { id: (candidate) => candidate.field.id, label: (candidate) => candidate.field.name });
|
|
53249
53630
|
}
|
|
53631
|
+
function publishDraw(screen, child) {
|
|
53632
|
+
const clause = screen.screen.sections?.[child];
|
|
53633
|
+
return clause?.draw === "publish" ? clause : void 0;
|
|
53634
|
+
}
|
|
53635
|
+
function publishReads(child, clause) {
|
|
53636
|
+
const named3 = [clause.text, clause.permalink, clause.error].filter((alias) => alias !== void 0);
|
|
53637
|
+
return child.fields.filter((field) => named3.includes(field.alias));
|
|
53638
|
+
}
|
|
53639
|
+
function bindPublish(args) {
|
|
53640
|
+
const { at: at2, clause, screen, child, childFields, ownFields, entities, roles, aliased, live, binding, scopeOf, missing } = args;
|
|
53641
|
+
const roleField = (owner, role) => owner.fields.find((field) => roleOf(roles, owner.alias, field.alias)?.role === role);
|
|
53642
|
+
const party = child.fields.find(
|
|
53643
|
+
(field) => field.type === "select_record_link" && roleOf(roles, child.alias, field.alias)?.role === "party"
|
|
53644
|
+
);
|
|
53645
|
+
const stageField = roleField(child, "lifecycle");
|
|
53646
|
+
const target = party?.type === "select_record_link" ? entities.find((one) => one.alias === party.target_entity) : void 0;
|
|
53647
|
+
const partyBound = party === void 0 ? void 0 : childFields.get(party.alias);
|
|
53648
|
+
const stage = stageField === void 0 ? void 0 : childFields.get(stageField.alias);
|
|
53649
|
+
if (party === void 0 || target === void 0 || partyBound === void 0 || stage === void 0) {
|
|
53650
|
+
missing.push(`${at2}: "${child.label}" is drawn as a publish desk and states no destination \u2014 declare its link to the destination entity as a "party"`);
|
|
53651
|
+
return void 0;
|
|
53652
|
+
}
|
|
53653
|
+
const states = Object.fromEntries(
|
|
53654
|
+
Object.entries(clause.states).flatMap(([state, alias]) => {
|
|
53655
|
+
const option = stage.options?.get(alias);
|
|
53656
|
+
return option === void 0 ? [] : [[state, option.id]];
|
|
53657
|
+
})
|
|
53658
|
+
);
|
|
53659
|
+
if (states.queued === void 0 || states.published === void 0 || states.failed === void 0 || states.by_hand === void 0) {
|
|
53660
|
+
missing.push(`${at2}: "${stage.label}" carries no option for ${Object.keys(clause.states).filter((state) => states[state] === void 0).join(", ")}`);
|
|
53661
|
+
return void 0;
|
|
53662
|
+
}
|
|
53663
|
+
const identity = roleField(target, "identity");
|
|
53664
|
+
if (identity === void 0) {
|
|
53665
|
+
missing.push(`${at2}: "${target.label}" names no row of itself \u2014 a destination's chip is its own name, so it declares an "identity"`);
|
|
53666
|
+
return void 0;
|
|
53667
|
+
}
|
|
53668
|
+
const contacts = target.fields.filter((field) => roleOf(roles, target.alias, field.alias)?.role === "contact");
|
|
53669
|
+
const contact = contacts.find((field) => field.type === "text" && field.format === "link") ?? contacts[0];
|
|
53670
|
+
const mark = roleField(target, "mark");
|
|
53671
|
+
const group = roleField(target, "category");
|
|
53672
|
+
const named3 = (alias) => alias === void 0 ? void 0 : target.fields.find((field) => field.alias === alias);
|
|
53673
|
+
const language = typeof clause.destination.bodies === "string" ? void 0 : named3(clause.destination.bodies.field);
|
|
53674
|
+
const scoped = new Set((target.read_scope?.any ?? []).flatMap((one) => "field" in one ? [one.field] : []));
|
|
53675
|
+
const wanted2 = [
|
|
53676
|
+
identity,
|
|
53677
|
+
mark,
|
|
53678
|
+
contact,
|
|
53679
|
+
group,
|
|
53680
|
+
named3(clause.destination.network.field),
|
|
53681
|
+
named3(clause.destination.connected),
|
|
53682
|
+
named3(clause.destination.offered?.field),
|
|
53683
|
+
language,
|
|
53684
|
+
...target.fields.filter((field) => scoped.has(field.alias))
|
|
53685
|
+
].filter((field) => field !== void 0);
|
|
53686
|
+
const found = bindTable(`${at2}.${target.alias}`, target, entities, roles, wanted2, aliased, live, missing, binding);
|
|
53687
|
+
if (found === void 0) return void 0;
|
|
53688
|
+
const network = found.fields.get(clause.destination.network.field);
|
|
53689
|
+
const identityBound = found.fields.get(identity.alias);
|
|
53690
|
+
if (network === void 0 || identityBound === void 0) return void 0;
|
|
53691
|
+
const bindOptions = (field, declared) => Object.fromEntries(
|
|
53692
|
+
Object.entries(declared).flatMap(([alias, value]) => {
|
|
53693
|
+
const option = field.options?.get(alias);
|
|
53694
|
+
return option === void 0 ? [] : [[option.id, value]];
|
|
53695
|
+
})
|
|
53696
|
+
);
|
|
53697
|
+
const offeredField = clause.destination.offered === void 0 ? void 0 : found.fields.get(clause.destination.offered.field);
|
|
53698
|
+
const languageField = language === void 0 ? void 0 : found.fields.get(language.alias);
|
|
53699
|
+
const bodyAliases = publishBodies(clause);
|
|
53700
|
+
const bodies = bodyAliases.flatMap((alias) => {
|
|
53701
|
+
const bound = ownFields.get(alias);
|
|
53702
|
+
return bound === void 0 ? [] : [bound];
|
|
53703
|
+
});
|
|
53704
|
+
if (bodies.length === 0) {
|
|
53705
|
+
missing.push(`${at2}: none of the bodies "${bodyAliases.join(", ")}" is a column of "${screen.entity.label}"`);
|
|
53706
|
+
return void 0;
|
|
53707
|
+
}
|
|
53708
|
+
const media = roleField(screen.entity, "mark");
|
|
53709
|
+
const mediaBound = media === void 0 || media.type !== "files" ? void 0 : ownFields.get(media.alias);
|
|
53710
|
+
const link = clause.link === void 0 ? void 0 : ownFields.get(clause.link);
|
|
53711
|
+
const optional2 = (alias) => alias === void 0 ? void 0 : childFields.get(alias);
|
|
53712
|
+
const when = roleField(child, "when");
|
|
53713
|
+
const whenBound = when === void 0 ? void 0 : childFields.get(when.alias);
|
|
53714
|
+
const textBound = optional2(clause.text);
|
|
53715
|
+
const permalinkBound = optional2(clause.permalink);
|
|
53716
|
+
const errorBound = optional2(clause.error);
|
|
53717
|
+
const markBound = mark === void 0 ? void 0 : found.fields.get(mark.alias);
|
|
53718
|
+
const contactBound = contact === void 0 ? void 0 : found.fields.get(contact.alias);
|
|
53719
|
+
const groupBound = group === void 0 ? void 0 : found.fields.get(group.alias);
|
|
53720
|
+
const connectedBound = clause.destination.connected === void 0 ? void 0 : found.fields.get(clause.destination.connected);
|
|
53721
|
+
const rule = scopeOf(target, found);
|
|
53722
|
+
return {
|
|
53723
|
+
party: partyBound,
|
|
53724
|
+
stage,
|
|
53725
|
+
states: { queued: states.queued, published: states.published, failed: states.failed, by_hand: states.by_hand },
|
|
53726
|
+
...whenBound === void 0 ? {} : { when: whenBound },
|
|
53727
|
+
...textBound === void 0 ? {} : { text: textBound },
|
|
53728
|
+
...permalinkBound === void 0 ? {} : { permalink: permalinkBound },
|
|
53729
|
+
...errorBound === void 0 ? {} : { error: errorBound },
|
|
53730
|
+
destination: {
|
|
53731
|
+
entity: target,
|
|
53732
|
+
table: found.table,
|
|
53733
|
+
tableAlias: found.tableAlias,
|
|
53734
|
+
alias: destinationsQueryAlias(target.alias),
|
|
53735
|
+
fields: found.fields,
|
|
53736
|
+
identity: identityBound,
|
|
53737
|
+
...markBound === void 0 ? {} : { mark: markBound },
|
|
53738
|
+
...contactBound === void 0 ? {} : { contact: contactBound },
|
|
53739
|
+
...groupBound === void 0 ? {} : { group: groupBound },
|
|
53740
|
+
network: { field: network, options: bindOptions(network, clause.destination.network.options) },
|
|
53741
|
+
...connectedBound === void 0 ? {} : { connected: connectedBound },
|
|
53742
|
+
...clause.destination.offered === void 0 || offeredField === void 0 ? {} : {
|
|
53743
|
+
offered: {
|
|
53744
|
+
field: offeredField,
|
|
53745
|
+
in: clause.destination.offered.in.flatMap((alias) => {
|
|
53746
|
+
const option = offeredField.options?.get(alias);
|
|
53747
|
+
return option === void 0 ? [] : [option.id];
|
|
53748
|
+
})
|
|
53749
|
+
}
|
|
53750
|
+
},
|
|
53751
|
+
...typeof clause.destination.bodies === "string" || languageField === void 0 ? {} : { language: { field: languageField, options: bindOptions(languageField, clause.destination.bodies.options) } },
|
|
53752
|
+
...rule === void 0 ? {} : { scope: rule }
|
|
53753
|
+
},
|
|
53754
|
+
bodies,
|
|
53755
|
+
...mediaBound === void 0 ? {} : { media: mediaBound },
|
|
53756
|
+
...link === void 0 ? {} : { link },
|
|
53757
|
+
publish: clause.publish
|
|
53758
|
+
};
|
|
53759
|
+
}
|
|
53250
53760
|
function bindTable(at2, entity, entities, roles, wanted2, aliased, live, missing, binding) {
|
|
53251
53761
|
const bound = binding?.entities[entity.alias];
|
|
53252
53762
|
const named3 = bindAlias(bound?.table, entity.label, tableIndex(aliased));
|
|
@@ -53640,7 +54150,8 @@ function bindScreen(screen, live, roles, entities, rules, groups = /* @__PURE__
|
|
|
53640
54150
|
);
|
|
53641
54151
|
return;
|
|
53642
54152
|
}
|
|
53643
|
-
const
|
|
54153
|
+
const desk = publishDraw(screen2, child.alias);
|
|
54154
|
+
const itinerary = draw === "open" && desk === void 0 ? recordItinerary(archetype, step, child, roles, screen2.screen.sections) : void 0;
|
|
53644
54155
|
const route = routeOf(child, roles);
|
|
53645
54156
|
const goes = route !== void 0 && drawn.some((field) => field.alias === route.from.alias);
|
|
53646
54157
|
const operandAliases = /* @__PURE__ */ new Set([
|
|
@@ -53709,6 +54220,7 @@ function bindScreen(screen, live, roles, entities, rules, groups = /* @__PURE__
|
|
|
53709
54220
|
const boundField = found.fields.get(field.alias);
|
|
53710
54221
|
if (boundField !== void 0 && !fields.has(field.alias)) operands.set(field.alias, boundField);
|
|
53711
54222
|
}
|
|
54223
|
+
const publish = desk === void 0 ? void 0 : bindPublish({ at: at2, clause: desk, screen: screen2, child, childFields: found.fields, ownFields: own.fields, entities, roles, aliased, live, binding, scopeOf, missing });
|
|
53712
54224
|
const scope2 = scopeOf(child, found);
|
|
53713
54225
|
const logged = entry2.section.kind === "journal" ? entry2.section.when : void 0;
|
|
53714
54226
|
const when = logged ?? childWhen(child, roles);
|
|
@@ -53744,7 +54256,8 @@ function bindScreen(screen, live, roles, entities, rules, groups = /* @__PURE__
|
|
|
53744
54256
|
...itinerary === void 0 ? {} : { itinerary },
|
|
53745
54257
|
...scope2 === void 0 ? {} : { scope: scope2 },
|
|
53746
54258
|
...order === void 0 ? {} : { order },
|
|
53747
|
-
...surface === void 0 ? {} : { surface }
|
|
54259
|
+
...surface === void 0 ? {} : { surface },
|
|
54260
|
+
...publish === void 0 ? {} : { publish }
|
|
53748
54261
|
});
|
|
53749
54262
|
};
|
|
53750
54263
|
for (const entry2 of sections) {
|
|
@@ -53777,6 +54290,11 @@ function bindScreen(screen, live, roles, entities, rules, groups = /* @__PURE__
|
|
|
53777
54290
|
}
|
|
53778
54291
|
if (section.kind === "children") {
|
|
53779
54292
|
const drawn = drawnColumns2(section.child, section.fields, roles);
|
|
54293
|
+
const desk = publishDraw(screen2, section.child.alias);
|
|
54294
|
+
if (desk !== void 0) {
|
|
54295
|
+
bindChild(entry2, drawn, void 0, void 0, void 0, publishReads(section.child, desk));
|
|
54296
|
+
continue;
|
|
54297
|
+
}
|
|
53780
54298
|
if (drawn.length > 0) {
|
|
53781
54299
|
bindChild(entry2, drawn);
|
|
53782
54300
|
continue;
|
|
@@ -53868,23 +54386,6 @@ function bindScreen(screen, live, roles, entities, rules, groups = /* @__PURE__
|
|
|
53868
54386
|
};
|
|
53869
54387
|
return { bound: bindOne(screen, screen.app.alias, true), missing };
|
|
53870
54388
|
}
|
|
53871
|
-
var STATED_FIELD_TYPES = [
|
|
53872
|
-
"text",
|
|
53873
|
-
"number",
|
|
53874
|
-
"date",
|
|
53875
|
-
"boolean",
|
|
53876
|
-
"select",
|
|
53877
|
-
"select_record_link",
|
|
53878
|
-
"files"
|
|
53879
|
-
];
|
|
53880
|
-
function isEditable(field) {
|
|
53881
|
-
if (isComputedFieldType(field.type)) return false;
|
|
53882
|
-
if (field.type === "select_record_link") return field.cardinality === "one";
|
|
53883
|
-
return STATED_FIELD_TYPES.includes(field.type);
|
|
53884
|
-
}
|
|
53885
|
-
function isAsked(field) {
|
|
53886
|
-
return isEditable(field) || field.type === "select_member";
|
|
53887
|
-
}
|
|
53888
54389
|
function stated(entry, field) {
|
|
53889
54390
|
return field.type === "select_record_link" ? entry.links.has(field.alias) : isEditable(field);
|
|
53890
54391
|
}
|
|
@@ -53892,8 +54393,8 @@ function factLinkTargets(screen, sections, entities, roles, owns) {
|
|
|
53892
54393
|
if (!writesRecord(screen.screen.writes) || !owns) return [];
|
|
53893
54394
|
return sections.flatMap((section) => section.kind === "facts" ? section.fields : []).flatMap((field) => linkTarget2(field, entities, roles));
|
|
53894
54395
|
}
|
|
53895
|
-
function linkTarget2(field, entities, roles) {
|
|
53896
|
-
if (field.type !== "select_record_link" || !isEditable(field)) return [];
|
|
54396
|
+
function linkTarget2(field, entities, roles, stands = false) {
|
|
54397
|
+
if (field.type !== "select_record_link" || !(isEditable(field) || stands)) return [];
|
|
53897
54398
|
const target = entities.find((candidate) => candidate.alias === field.target_entity);
|
|
53898
54399
|
if (target === void 0) return [];
|
|
53899
54400
|
const named3 = field.display_field_aliases?.[0];
|
|
@@ -53902,7 +54403,9 @@ function linkTarget2(field, entities, roles) {
|
|
|
53902
54403
|
}
|
|
53903
54404
|
function createLinkTargets(screen, entities, roles, owns, under) {
|
|
53904
54405
|
if (!writesRecord(screen.screen.writes) || !owns) return [];
|
|
53905
|
-
return screen.entity.fields.flatMap(
|
|
54406
|
+
return screen.entity.fields.flatMap(
|
|
54407
|
+
(field) => field.alias === under?.alias ? [] : linkTarget2(field, entities, roles, standsOnLink(field, screen.entity, roles))
|
|
54408
|
+
);
|
|
53906
54409
|
}
|
|
53907
54410
|
function mintedWith(target, display, keys2) {
|
|
53908
54411
|
const keyed = new Set(keys2.map((field) => field.alias));
|
|
@@ -53953,6 +54456,16 @@ function appSurfaces(entry) {
|
|
|
53953
54456
|
for (const surface of reached) if (!surfaces.has(surface.screen.entity.alias)) surfaces.set(surface.screen.entity.alias, surface);
|
|
53954
54457
|
return [...surfaces.values()];
|
|
53955
54458
|
}
|
|
54459
|
+
function publishDesks(entry) {
|
|
54460
|
+
const drawn = (child) => child.publish !== void 0;
|
|
54461
|
+
const desks = /* @__PURE__ */ new Map();
|
|
54462
|
+
for (const surface of appSurfaces(entry)) {
|
|
54463
|
+
for (const child of surface.children.values()) {
|
|
54464
|
+
if (drawn(child) && !desks.has(child.entity.alias)) desks.set(child.entity.alias, child);
|
|
54465
|
+
}
|
|
54466
|
+
}
|
|
54467
|
+
return [...desks.values()];
|
|
54468
|
+
}
|
|
53956
54469
|
function appEditors(entry) {
|
|
53957
54470
|
const editors = /* @__PURE__ */ new Map([[entry.screen.entity.alias, entry]]);
|
|
53958
54471
|
const kept = [
|
|
@@ -53973,7 +54486,11 @@ function planWrites(entry) {
|
|
|
53973
54486
|
function savedFields(entry) {
|
|
53974
54487
|
if (!writesRecord(entry.screen.screen.writes)) return [];
|
|
53975
54488
|
const banded = bandStated(entry).filter((field) => stated(entry, field));
|
|
53976
|
-
|
|
54489
|
+
const sent = new Set(
|
|
54490
|
+
Object.values(entry.screen.screen.sections ?? {}).flatMap((clause) => clause.draw === "publish" ? publishBodies(clause) : [])
|
|
54491
|
+
);
|
|
54492
|
+
const bodies = entry.screen.entity.fields.filter((field) => sent.has(field.alias) && stated(entry, field));
|
|
54493
|
+
return banded.concat(bodies).concat(entry.sections.flatMap(({ section }) => {
|
|
53977
54494
|
if (section.kind === "facts") return section.fields.filter((field) => stated(entry, field));
|
|
53978
54495
|
if (section.kind === "progress") return [section.field];
|
|
53979
54496
|
if (section.kind === "prose") return stated(entry, section.field) ? [section.field] : [];
|
|
@@ -54046,6 +54563,16 @@ function projection(fields) {
|
|
|
54046
54563
|
function unique2(fields) {
|
|
54047
54564
|
return [...new Map(fields.map((field) => [field.alias, field])).values()];
|
|
54048
54565
|
}
|
|
54566
|
+
function destinationReadings(desk) {
|
|
54567
|
+
const across = (column, type) => column === void 0 ? [] : [
|
|
54568
|
+
{
|
|
54569
|
+
output: destinationColumnAlias(desk.party.alias, column.alias),
|
|
54570
|
+
type,
|
|
54571
|
+
source: { link: { source: desk.party.id, field: column.id } }
|
|
54572
|
+
}
|
|
54573
|
+
];
|
|
54574
|
+
return [...across(desk.destination.mark, "files"), ...across(desk.destination.network.field, "select")];
|
|
54575
|
+
}
|
|
54049
54576
|
function planQueries(entry) {
|
|
54050
54577
|
const queries = {};
|
|
54051
54578
|
const inbound = registerFilters(entry);
|
|
@@ -54120,6 +54647,32 @@ function planQueries(entry) {
|
|
|
54120
54647
|
description: entry.words.queries.picker(subject.table.name, subject.display.label)
|
|
54121
54648
|
};
|
|
54122
54649
|
}
|
|
54650
|
+
const desk = [...entry.children.values()].find((child) => child.publish !== void 0);
|
|
54651
|
+
if (desk?.publish !== void 0) {
|
|
54652
|
+
const page = recordsParam(entry.screen.entity.alias);
|
|
54653
|
+
queries[placedQueryAlias(desk.entity.alias)] = {
|
|
54654
|
+
ast: {
|
|
54655
|
+
kind: "project",
|
|
54656
|
+
from: {
|
|
54657
|
+
kind: "from_table",
|
|
54658
|
+
table_id: desk.table.id,
|
|
54659
|
+
filter: withScope(
|
|
54660
|
+
{
|
|
54661
|
+
node_type: "condition",
|
|
54662
|
+
type: "select_record_link",
|
|
54663
|
+
field_key: desk.link.id,
|
|
54664
|
+
operator: "has_any_of",
|
|
54665
|
+
value: `{{params.${page}}}`
|
|
54666
|
+
},
|
|
54667
|
+
desk.scope
|
|
54668
|
+
)
|
|
54669
|
+
},
|
|
54670
|
+
columns: [...projection(unique2([...desk.fields.values(), ...desk.operands.values()])), ...destinationReadings(desk.publish)]
|
|
54671
|
+
},
|
|
54672
|
+
params: { [page]: { type: "record_link", table_id: entry.table.id, multi: true, required: true } },
|
|
54673
|
+
description: entry.words.queries.placed(desk.entity.label, entry.screen.entity.label)
|
|
54674
|
+
};
|
|
54675
|
+
}
|
|
54123
54676
|
recordRead(queries, entry, picked, subject);
|
|
54124
54677
|
surfaceReads(queries, entry);
|
|
54125
54678
|
for (const party of entry.parties.values()) {
|
|
@@ -54219,6 +54772,33 @@ function surfaceReads(queries, entry) {
|
|
|
54219
54772
|
params: { [child.param]: { type: "record_link", table_id: entry.table.id } },
|
|
54220
54773
|
description: child.section === "progress" ? entry.words.queries.history(child.entity.label, entry.screen.entity.label) : entry.words.queries.children(child.entity.label, entry.screen.entity.label, child.link.label)
|
|
54221
54774
|
};
|
|
54775
|
+
const desk = child.publish;
|
|
54776
|
+
if (desk !== void 0 && queries[desk.destination.alias] === void 0) {
|
|
54777
|
+
const offered = desk.destination.offered;
|
|
54778
|
+
const filter2 = withScope(
|
|
54779
|
+
offered === void 0 ? void 0 : {
|
|
54780
|
+
node_type: "condition",
|
|
54781
|
+
type: "select",
|
|
54782
|
+
field_key: offered.field.id,
|
|
54783
|
+
operator: "has_any_of",
|
|
54784
|
+
value: [...offered.in]
|
|
54785
|
+
},
|
|
54786
|
+
desk.destination.scope
|
|
54787
|
+
);
|
|
54788
|
+
queries[desk.destination.alias] = {
|
|
54789
|
+
ast: {
|
|
54790
|
+
kind: "project",
|
|
54791
|
+
from: {
|
|
54792
|
+
kind: "from_table",
|
|
54793
|
+
table_id: desk.destination.table.id,
|
|
54794
|
+
sort: [{ field_key: desk.destination.identity.id, order: "asc" }],
|
|
54795
|
+
...filter2 === void 0 ? {} : { filter: filter2 }
|
|
54796
|
+
},
|
|
54797
|
+
columns: projection([...desk.destination.fields.values()])
|
|
54798
|
+
},
|
|
54799
|
+
description: entry.words.queries.destinations(desk.destination.entity.label)
|
|
54800
|
+
};
|
|
54801
|
+
}
|
|
54222
54802
|
if (child.surface !== void 0) surfaceReads(queries, child.surface);
|
|
54223
54803
|
}
|
|
54224
54804
|
}
|
|
@@ -54291,6 +54871,14 @@ var specFieldSchema = zod_default.object({
|
|
|
54291
54871
|
format: textFormatSchema.optional(),
|
|
54292
54872
|
/** A select or a link that holds SEVERAL — read as a list, never re-pointed by a picker. */
|
|
54293
54873
|
multi: zod_default.literal(true).optional(),
|
|
54874
|
+
/**
|
|
54875
|
+
* A DATE THE WORKSPACE STAMPS from the row's own time — the model's own key,
|
|
54876
|
+
* carried so `isPlatformWrittenField` answers over a spec field exactly as
|
|
54877
|
+
* the server's write path answers over the table's: the type alone reads as
|
|
54878
|
+
* a date somebody states, and an editor offered on it commits a save the
|
|
54879
|
+
* server refuses.
|
|
54880
|
+
*/
|
|
54881
|
+
derive_from: zod_default.enum(["created_at", "updated_at"]).optional(),
|
|
54294
54882
|
/**
|
|
54295
54883
|
* THE RECORD THIS COLUMN'S NAME IS A DOOR ONTO — a key of `records`, where
|
|
54296
54884
|
* this app opens the record of the entity the link points at.
|
|
@@ -54361,6 +54949,7 @@ var specActWhenSchema = zod_default.strictObject({
|
|
|
54361
54949
|
field: specColumnAliasSchema,
|
|
54362
54950
|
in: zod_default.array(specOptionIdSchema)
|
|
54363
54951
|
}).describe("The act stands down unless this column holds one of these options");
|
|
54952
|
+
var specActConfirmSchema = zod_default.literal(true);
|
|
54364
54953
|
var specTemplateActSchema = zod_default.object({
|
|
54365
54954
|
kind: zod_default.literal("template"),
|
|
54366
54955
|
label: zod_default.string().min(1),
|
|
@@ -54371,6 +54960,7 @@ var specTemplateActSchema = zod_default.object({
|
|
|
54371
54960
|
input: zod_default.string().min(1),
|
|
54372
54961
|
place: specActPlaceSchema.optional(),
|
|
54373
54962
|
when: specActWhenSchema.optional(),
|
|
54963
|
+
confirm: specActConfirmSchema.optional(),
|
|
54374
54964
|
/**
|
|
54375
54965
|
* WHAT THE PRESS OPENS INSTEAD OF RUNNING — one of the app's own components,
|
|
54376
54966
|
* drawn in a panel the runtime owns, handed the act and the rows it was
|
|
@@ -54398,7 +54988,8 @@ var specAgentActSchema = zod_default.object({
|
|
|
54398
54988
|
/** The columns of this surface the run may write, in the order the act named them. */
|
|
54399
54989
|
fills: zod_default.array(specColumnAliasSchema).min(1),
|
|
54400
54990
|
place: specActPlaceSchema.optional(),
|
|
54401
|
-
when: specActWhenSchema.optional()
|
|
54991
|
+
when: specActWhenSchema.optional(),
|
|
54992
|
+
confirm: specActConfirmSchema.optional()
|
|
54402
54993
|
}).strict();
|
|
54403
54994
|
var RECORD_ID_COLUMN = "__source_record_id";
|
|
54404
54995
|
var specActInputSchema = zod_default.object({
|
|
@@ -54415,7 +55006,8 @@ var specWorkflowActSchema = zod_default.object({
|
|
|
54415
55006
|
/** What the press hands the run, in the order the act named them. */
|
|
54416
55007
|
inputs: zod_default.array(specActInputSchema).min(1),
|
|
54417
55008
|
place: specActPlaceSchema.optional(),
|
|
54418
|
-
when: specActWhenSchema.optional()
|
|
55009
|
+
when: specActWhenSchema.optional(),
|
|
55010
|
+
confirm: specActConfirmSchema.optional()
|
|
54419
55011
|
}).strict();
|
|
54420
55012
|
var specActSchema = zod_default.discriminatedUnion("kind", [specTemplateActSchema, specAgentActSchema, specWorkflowActSchema]);
|
|
54421
55013
|
var specColumnsExportSchema = zod_default.object({
|
|
@@ -54589,6 +55181,30 @@ var specObligationSchema = zod_default.object({
|
|
|
54589
55181
|
*/
|
|
54590
55182
|
satisfiedBy: specColumnAliasSchema.optional()
|
|
54591
55183
|
}).strict();
|
|
55184
|
+
var specPublishStatesSchema = zod_default.strictObject({
|
|
55185
|
+
queued: specOptionIdSchema,
|
|
55186
|
+
published: specOptionIdSchema,
|
|
55187
|
+
failed: specOptionIdSchema,
|
|
55188
|
+
by_hand: specOptionIdSchema
|
|
55189
|
+
}).describe("The child's stages the desk reads, bound");
|
|
55190
|
+
var specDestinationsSchema = zod_default.object({
|
|
55191
|
+
/** The read, and the param it takes the page's rows by. */
|
|
55192
|
+
query: runtimeAliasSchema,
|
|
55193
|
+
param: zod_default.string().min(1),
|
|
55194
|
+
/** The child entity whose rows these are. */
|
|
55195
|
+
child: contractAliasSchema,
|
|
55196
|
+
/** The column on them that names the register's row — what groups the answer. */
|
|
55197
|
+
link: specColumnAliasSchema,
|
|
55198
|
+
/** The column that names the destination — what each mark is called. */
|
|
55199
|
+
party: specColumnAliasSchema,
|
|
55200
|
+
/** Where each stands, and the four stages its dot reads it as. */
|
|
55201
|
+
stage: specColumnAliasSchema,
|
|
55202
|
+
states: specPublishStatesSchema,
|
|
55203
|
+
/** The destination's face, where the child's own row carries a reading of it. */
|
|
55204
|
+
mark: specColumnAliasSchema.optional(),
|
|
55205
|
+
/** Its network, where the child's own row carries a reading of that. */
|
|
55206
|
+
network: zod_default.strictObject({ field: specColumnAliasSchema, options: zod_default.record(specOptionIdSchema, zod_default.enum(SOCIAL_NETWORKS)) }).optional()
|
|
55207
|
+
}).strict();
|
|
54592
55208
|
var specScreenSchema = zod_default.object({
|
|
54593
55209
|
alias: contractAliasSchema,
|
|
54594
55210
|
label: zod_default.string().min(1),
|
|
@@ -54639,6 +55255,8 @@ var specScreenSchema = zod_default.object({
|
|
|
54639
55255
|
period: specColumnAliasSchema.optional(),
|
|
54640
55256
|
/** Where the register is read a day at a time — the day, and where in it a row sits. */
|
|
54641
55257
|
runs: zod_default.object({ day: specColumnAliasSchema, slot: specColumnAliasSchema }).strict().optional(),
|
|
55258
|
+
/** Where the rows in view have already gone, where the record they open carries a publish desk. */
|
|
55259
|
+
destinations: specDestinationsSchema.optional(),
|
|
54642
55260
|
/** The key a row is filed under, under the name a person calls it by. */
|
|
54643
55261
|
caption: specColumnAliasSchema.optional(),
|
|
54644
55262
|
/** What a clerk types to find a row — joined, in this order. */
|
|
@@ -54833,7 +55451,25 @@ var specChildSchema = zod_default.object({
|
|
|
54833
55451
|
* DRAWN ON: a section that marks one of its own rows is writing the child,
|
|
54834
55452
|
* and the surface's own save would have sent the field to the parent.
|
|
54835
55453
|
*/
|
|
54836
|
-
write: zod_default.object({ alias: runtimeAliasSchema, param: zod_default.string().min(1) }).strict().optional()
|
|
55454
|
+
write: zod_default.object({ alias: runtimeAliasSchema, param: zod_default.string().min(1) }).strict().optional(),
|
|
55455
|
+
/**
|
|
55456
|
+
* THE WORKFLOW ONE OF THESE ROWS IS OPENED THROUGH, where the section adds a
|
|
55457
|
+
* row without a panel to collect it — the param names the record it hangs
|
|
55458
|
+
* under, and everything else about the draft the section already knows.
|
|
55459
|
+
*
|
|
55460
|
+
* A section that ASKS carries its create in `creates` instead, mounted on
|
|
55461
|
+
* this section's key: the panel is the statement there, and two of them
|
|
55462
|
+
* would be two ways to open one row.
|
|
55463
|
+
*/
|
|
55464
|
+
create: zod_default.object({ alias: runtimeAliasSchema, param: zod_default.string().min(1) }).strict().optional(),
|
|
55465
|
+
/**
|
|
55466
|
+
* THE WORKFLOW ONE OF THESE ROWS IS WITHDRAWN THROUGH, where the section can
|
|
55467
|
+
* take one back — the param names the row.
|
|
55468
|
+
*
|
|
55469
|
+
* The BODY refuses the row it may not take: a delete offered on a surface
|
|
55470
|
+
* and refused nowhere else is a press that removes what already happened.
|
|
55471
|
+
*/
|
|
55472
|
+
remove: zod_default.object({ alias: runtimeAliasSchema, param: zod_default.string().min(1) }).strict().optional()
|
|
54837
55473
|
}).strict();
|
|
54838
55474
|
var specPricedChildSchema = specChildSchema.extend({ worksheet: specWorksheetSchema });
|
|
54839
55475
|
var specProgressHistorySchema = zod_default.strictObject({
|
|
@@ -54861,6 +55497,28 @@ var specProgressHistorySchema = zod_default.strictObject({
|
|
|
54861
55497
|
}).describe("The dated stage changes behind this ladder");
|
|
54862
55498
|
var specProgressPhaseSchema = zod_default.strictObject({ label: zod_default.string().min(1), stages: zod_default.array(specOptionIdSchema).min(1) }).describe("One part of the flow and the stages under it");
|
|
54863
55499
|
var sectionActs = { acts: zod_default.array(specActSchema).min(1).optional() };
|
|
55500
|
+
var specPublishDestinationSchema = zod_default.object({
|
|
55501
|
+
entity: contractAliasSchema,
|
|
55502
|
+
table: specTableIdSchema,
|
|
55503
|
+
query: runtimeAliasSchema,
|
|
55504
|
+
fields: specFieldsSchema,
|
|
55505
|
+
/** What a chip is called. */
|
|
55506
|
+
identity: specColumnAliasSchema,
|
|
55507
|
+
/** Its face, where the destination keeps one. */
|
|
55508
|
+
mark: specColumnAliasSchema.optional(),
|
|
55509
|
+
/** Where a post put there by hand is pasted. */
|
|
55510
|
+
contact: specColumnAliasSchema.optional(),
|
|
55511
|
+
/** What the strip folds the chips under — the destination's own kind. */
|
|
55512
|
+
group: specColumnAliasSchema.optional(),
|
|
55513
|
+
/** Which destinations the platform can reach, and as which network's feed. */
|
|
55514
|
+
network: zod_default.strictObject({ field: specColumnAliasSchema, options: zod_default.record(specOptionIdSchema, zod_default.enum(SOCIAL_NETWORKS)) }).describe("An option this map leaves out is posted by hand"),
|
|
55515
|
+
/** The account the platform posts as — empty means not connected. */
|
|
55516
|
+
connected: specColumnAliasSchema.optional(),
|
|
55517
|
+
/** Which rows the strip offers at all. */
|
|
55518
|
+
offered: zod_default.strictObject({ field: specColumnAliasSchema, in: zod_default.array(specOptionIdSchema).min(1) }).optional(),
|
|
55519
|
+
/** Which of THIS record's bodies each destination receives. */
|
|
55520
|
+
language: zod_default.strictObject({ field: specColumnAliasSchema, options: zod_default.record(specOptionIdSchema, specColumnAliasSchema) }).optional()
|
|
55521
|
+
}).strict();
|
|
54864
55522
|
var specSectionSchema = zod_default.discriminatedUnion("kind", [
|
|
54865
55523
|
zod_default.object({
|
|
54866
55524
|
kind: zod_default.literal("facts"),
|
|
@@ -54966,6 +55624,50 @@ var specSectionSchema = zod_default.discriminatedUnion("kind", [
|
|
|
54966
55624
|
...sectionActs
|
|
54967
55625
|
}).strict()
|
|
54968
55626
|
]),
|
|
55627
|
+
/**
|
|
55628
|
+
* WHERE THIS RECORD GOES — one child row per destination it stands on, drawn
|
|
55629
|
+
* as the strip of destinations rather than as a register of those rows.
|
|
55630
|
+
*
|
|
55631
|
+
* A register of them would be the same record listed N times with a stage
|
|
55632
|
+
* beside it. What a reader wants is the opposite reading: every destination
|
|
55633
|
+
* they COULD go to, each wearing what has happened there — so the section is
|
|
55634
|
+
* over the destination table, and the child's rows are the state on it.
|
|
55635
|
+
*/
|
|
55636
|
+
zod_default.object({
|
|
55637
|
+
kind: zod_default.literal("publish"),
|
|
55638
|
+
key: zod_default.string().min(1),
|
|
55639
|
+
heading: zod_default.string().min(1),
|
|
55640
|
+
child: specChildSchema,
|
|
55641
|
+
/** The column of those rows that names the destination — what ties one to its chip. */
|
|
55642
|
+
party: specColumnAliasSchema,
|
|
55643
|
+
/** Where each stands, and the four stages the chip reads it as. */
|
|
55644
|
+
stage: specColumnAliasSchema,
|
|
55645
|
+
states: specPublishStatesSchema,
|
|
55646
|
+
/** When it went out. */
|
|
55647
|
+
when: specColumnAliasSchema.optional(),
|
|
55648
|
+
/** What this one destination goes out with instead of the body. */
|
|
55649
|
+
text: specColumnAliasSchema.optional(),
|
|
55650
|
+
/** Where it landed, and what the platform said when it would not take it. */
|
|
55651
|
+
permalink: specColumnAliasSchema.optional(),
|
|
55652
|
+
error: specColumnAliasSchema.optional(),
|
|
55653
|
+
destination: specPublishDestinationSchema,
|
|
55654
|
+
/** THIS record's own text columns, in the clause's order — what goes out, and the prose the record draws above the desk. */
|
|
55655
|
+
bodies: zod_default.array(specColumnAliasSchema).min(1),
|
|
55656
|
+
/** The pile the post carries. */
|
|
55657
|
+
media: specColumnAliasSchema.optional(),
|
|
55658
|
+
/** The address it carries as its card. */
|
|
55659
|
+
link: specColumnAliasSchema.optional(),
|
|
55660
|
+
/**
|
|
55661
|
+
* THE ONE PRESS THAT SENDS THEM — the alias the APP binds, the input that
|
|
55662
|
+
* names this record, and the input that names the destinations being sent.
|
|
55663
|
+
*
|
|
55664
|
+
* The body is the author's: what each platform accepts is the business's
|
|
55665
|
+
* own arrangement with it, so the generator writes none and `lotics app
|
|
55666
|
+
* check` refuses an alias nothing bound.
|
|
55667
|
+
*/
|
|
55668
|
+
publish: zod_default.strictObject({ workflow: runtimeAliasSchema, param: zod_default.string().min(1), rows: zod_default.string().min(1) }),
|
|
55669
|
+
...sectionActs
|
|
55670
|
+
}).strict(),
|
|
54969
55671
|
/**
|
|
54970
55672
|
* WHERE THE RECORD IS AGAINST WHERE IT SHOULD BE — a child whose rows are the
|
|
54971
55673
|
* ordered stops, each carrying the day it was promised for and the day it
|
|
@@ -55059,6 +55761,7 @@ function sectionChild2(section) {
|
|
|
55059
55761
|
switch (section.kind) {
|
|
55060
55762
|
case "children":
|
|
55061
55763
|
case "desk":
|
|
55764
|
+
case "publish":
|
|
55062
55765
|
case "timeline":
|
|
55063
55766
|
case "thread":
|
|
55064
55767
|
case "journal":
|
|
@@ -55204,11 +55907,18 @@ function referencedComponents(spec) {
|
|
|
55204
55907
|
function specQueryAliases(spec) {
|
|
55205
55908
|
const children = (record2) => record2.sections.flatMap((section) => {
|
|
55206
55909
|
const child = sectionChild2(section);
|
|
55207
|
-
return
|
|
55910
|
+
return [
|
|
55911
|
+
...child === void 0 ? [] : [child.query],
|
|
55912
|
+
// A PUBLISH DESK READS A SECOND TABLE: the destinations it OFFERS, which
|
|
55913
|
+
// are not the rows the child answers with and have no other reader.
|
|
55914
|
+
...section.kind === "publish" ? [section.destination.query] : []
|
|
55915
|
+
];
|
|
55208
55916
|
});
|
|
55209
55917
|
return [
|
|
55210
55918
|
.../* @__PURE__ */ new Set([
|
|
55211
55919
|
spec.screen.query,
|
|
55920
|
+
// …AND SO DOES THE REGISTER, for the marks each row wears.
|
|
55921
|
+
...spec.screen.destinations === void 0 ? [] : [spec.screen.destinations.query],
|
|
55212
55922
|
...spec.scope === void 0 ? [] : [spec.scope.query],
|
|
55213
55923
|
...Object.values(spec.records).flatMap((record2) => [
|
|
55214
55924
|
record2.options,
|
|
@@ -55302,7 +56012,16 @@ function specWorkflowAliases(spec) {
|
|
|
55302
56012
|
// marks its answer through.
|
|
55303
56013
|
...record2.sections.flatMap((section) => {
|
|
55304
56014
|
const child = sectionChild2(section);
|
|
55305
|
-
return
|
|
56015
|
+
return [
|
|
56016
|
+
...child?.write === void 0 ? [] : [child.write.alias],
|
|
56017
|
+
// …AND THE TWO A SECTION THAT OPENS AND WITHDRAWS ITS OWN ROWS calls
|
|
56018
|
+
// with no panel in between — a publish desk's chip, pressed on and
|
|
56019
|
+
// pressed off.
|
|
56020
|
+
...child?.create === void 0 ? [] : [child.create.alias],
|
|
56021
|
+
...child?.remove === void 0 ? [] : [child.remove.alias],
|
|
56022
|
+
// AND THE ONE THE DESK'S OWN BUTTON RUNS, which is the author's body.
|
|
56023
|
+
...section.kind === "publish" ? [section.publish.workflow] : []
|
|
56024
|
+
];
|
|
55306
56025
|
})
|
|
55307
56026
|
]),
|
|
55308
56027
|
...(spec.creates ?? []).map((create) => create.workflow)
|
|
@@ -55336,24 +56055,6 @@ function captionField(entry, roles) {
|
|
|
55336
56055
|
}
|
|
55337
56056
|
|
|
55338
56057
|
// src/plan_creates.ts
|
|
55339
|
-
function filledLater(entry, roles) {
|
|
55340
|
-
const entity = entry.screen.entity;
|
|
55341
|
-
return new Set(
|
|
55342
|
-
entity.fields.flatMap((field) => {
|
|
55343
|
-
const decl = roleOf(roles, entity.alias, field.alias);
|
|
55344
|
-
return decl?.role === "obligation" && decl.satisfied_by !== void 0 ? [decl.satisfied_by] : [];
|
|
55345
|
-
})
|
|
55346
|
-
);
|
|
55347
|
-
}
|
|
55348
|
-
function filledByBody(entry, roles, mounted) {
|
|
55349
|
-
const filled = /* @__PURE__ */ new Set();
|
|
55350
|
-
for (const field of entry.screen.entity.fields) {
|
|
55351
|
-
const role = roleOf(roles, entry.screen.entity.alias, field.alias)?.role;
|
|
55352
|
-
if (role === "lifecycle" || role === "parent" && mounted) filled.add(field.alias);
|
|
55353
|
-
if (writeRuleOf(entry.rules, entry.screen.entity.alias, field.alias)?.default_from !== void 0) filled.add(field.alias);
|
|
55354
|
-
}
|
|
55355
|
-
return filled;
|
|
55356
|
-
}
|
|
55357
56058
|
function planCreates(entry, roles) {
|
|
55358
56059
|
if (!writesChildren(entry.screen.screen.writes)) return [];
|
|
55359
56060
|
const plan = planWrites(entry) === void 0 ? void 0 : planCreate(entry, roles);
|
|
@@ -55392,7 +56093,7 @@ function childCreates(entry, roles) {
|
|
|
55392
56093
|
];
|
|
55393
56094
|
}
|
|
55394
56095
|
if (child.surface === void 0 || planWrites(child.surface) === void 0) return [];
|
|
55395
|
-
const plan = planCreate(child.surface, roles, entry);
|
|
56096
|
+
const plan = planCreate(child.surface, roles, entry, child.publish);
|
|
55396
56097
|
return plan === void 0 ? [] : [plan];
|
|
55397
56098
|
});
|
|
55398
56099
|
}
|
|
@@ -55404,15 +56105,13 @@ function composedEntry(section) {
|
|
|
55404
56105
|
...section.fields.filter((field) => field.type === "select" && field.multi !== true && !asked.has(field.alias) && isEditable(field))
|
|
55405
56106
|
];
|
|
55406
56107
|
}
|
|
55407
|
-
function planCreate(entry, roles, on = entry) {
|
|
56108
|
+
function planCreate(entry, roles, on = entry, desk) {
|
|
55408
56109
|
const entity = entry.screen.entity;
|
|
55409
|
-
const later = filledLater(entry, roles);
|
|
55410
56110
|
const roleOfField = (field) => roleOf(roles, entity.alias, field.alias)?.role;
|
|
55411
56111
|
const parentField = parentLink(entity, roles);
|
|
55412
56112
|
const mountedOn = parentField !== void 0 && on.children.has(childKey(entity.alias, parentField.alias)) ? on : void 0;
|
|
55413
56113
|
const parentBound = parentField === void 0 ? void 0 : entry.fields.get(parentField.alias);
|
|
55414
56114
|
const parent = parentBound === void 0 || mountedOn === void 0 ? void 0 : { bound: parentBound, screen: mountedOn, param: recordParam(mountedOn.screen.entity.alias) };
|
|
55415
|
-
const filled = filledByBody(entry, roles, parent !== void 0);
|
|
55416
56115
|
const lifecycleField = entity.fields.find((field) => roleOfField(field) === "lifecycle");
|
|
55417
56116
|
const lifecycleBound = lifecycleField === void 0 ? void 0 : entry.fields.get(lifecycleField.alias);
|
|
55418
56117
|
const firstOption = lifecycleBound?.options === void 0 ? void 0 : [...lifecycleBound.options.values()][0];
|
|
@@ -55421,13 +56120,13 @@ function planCreate(entry, roles, on = entry) {
|
|
|
55421
56120
|
const reads = /* @__PURE__ */ new Map();
|
|
55422
56121
|
const unique3 = [];
|
|
55423
56122
|
let party;
|
|
56123
|
+
let queued;
|
|
55424
56124
|
for (const field of entity.fields) {
|
|
55425
56125
|
const fieldBound = entry.fields.get(field.alias);
|
|
55426
56126
|
if (fieldBound === void 0) continue;
|
|
55427
56127
|
const rule = writeRuleOf(entry.rules, entity.alias, field.alias);
|
|
55428
|
-
|
|
55429
|
-
if (
|
|
55430
|
-
if (filled.has(field.alias)) {
|
|
56128
|
+
const skipped = draftSkips(field, entity, roles, entry.rules);
|
|
56129
|
+
if (skipped !== void 0) {
|
|
55431
56130
|
if (rule?.default_from === void 0) continue;
|
|
55432
56131
|
const [linkAlias, sourceAlias] = rule.default_from.split(".");
|
|
55433
56132
|
const link2 = entry.createLinks.get(linkAlias);
|
|
@@ -55438,6 +56137,8 @@ function planCreate(entry, roles, on = entry) {
|
|
|
55438
56137
|
reads.set(linkAlias, read2);
|
|
55439
56138
|
continue;
|
|
55440
56139
|
}
|
|
56140
|
+
if (parent !== void 0 && field.alias === parentField?.alias) continue;
|
|
56141
|
+
if (field.type === "text" && field.unique === true) unique3.push(fieldBound);
|
|
55441
56142
|
if (field.type !== "select_record_link") {
|
|
55442
56143
|
inputs.push({
|
|
55443
56144
|
name: fieldBound.alias,
|
|
@@ -55455,7 +56156,7 @@ function planCreate(entry, roles, on = entry) {
|
|
|
55455
56156
|
if ((link.where !== void 0 || link.scope !== void 0) && !reads.has(field.alias)) {
|
|
55456
56157
|
reads.set(field.alias, { input: fieldBound.alias, local: `${camel(field.alias)}Row`, link, copies: [], where: link.where ?? [] });
|
|
55457
56158
|
}
|
|
55458
|
-
if (party === void 0 && roleOfField(field) === "party" && link.keys.length > 0) {
|
|
56159
|
+
if (desk === void 0 && party === void 0 && roleOfField(field) === "party" && link.keys.length > 0) {
|
|
55459
56160
|
party = { alias: field.alias, link, bound: fieldBound, local: `${camel(field.alias)}Id` };
|
|
55460
56161
|
for (const key of link.keys) {
|
|
55461
56162
|
inputs.push({
|
|
@@ -55481,7 +56182,7 @@ function planCreate(entry, roles, on = entry) {
|
|
|
55481
56182
|
}
|
|
55482
56183
|
continue;
|
|
55483
56184
|
}
|
|
55484
|
-
|
|
56185
|
+
const chosen = {
|
|
55485
56186
|
name: fieldBound.alias,
|
|
55486
56187
|
field,
|
|
55487
56188
|
bound: fieldBound,
|
|
@@ -55490,18 +56191,26 @@ function planCreate(entry, roles, on = entry) {
|
|
|
55490
56191
|
// mounts this act writes the parent on every row it opens, so the door
|
|
55491
56192
|
// that has to ask cannot let one through without it — the register the
|
|
55492
56193
|
// record's own section draws would never list it.
|
|
55493
|
-
|
|
56194
|
+
//
|
|
56195
|
+
// …AND A CHIP STANDS ON A DESTINATION. The press IS the destination, so a
|
|
56196
|
+
// desk's create that could open a row without one would queue a placement
|
|
56197
|
+
// on nothing, which no strip can ever draw again.
|
|
56198
|
+
required: field.required === true || roleOfField(field) === "parent" || desk !== void 0 && roleOfField(field) === "party",
|
|
55494
56199
|
into: "row",
|
|
55495
56200
|
link,
|
|
55496
56201
|
...rule === void 0 ? {} : { rule }
|
|
55497
|
-
}
|
|
56202
|
+
};
|
|
56203
|
+
inputs.push(chosen);
|
|
56204
|
+
if (desk !== void 0 && roleOfField(field) === "party") queued = chosen;
|
|
55498
56205
|
}
|
|
55499
|
-
|
|
56206
|
+
const asked = entry.screen.create;
|
|
56207
|
+
const drafted = queued !== void 0 ? [queued] : asked.length === 0 ? [...inputs.filter((input) => input.required), ...inputs.filter((input) => !input.required)] : asked.flatMap((field) => inputs.filter((input) => input.field.alias === field.alias));
|
|
56208
|
+
if (drafted.length === 0) return void 0;
|
|
55500
56209
|
const section = parent === void 0 || parentField === void 0 ? void 0 : parent.screen.children.get(childKey(entity.alias, parentField.alias));
|
|
55501
56210
|
const mount = section !== void 0 ? { kind: "section", key: sectionKey(entity.alias, section.linkAlias) } : shapeListsRows(entry.screen.screen.shape) ? { kind: "register" } : void 0;
|
|
55502
56211
|
if (mount === void 0) return void 0;
|
|
55503
56212
|
const run = section?.itinerary;
|
|
55504
|
-
const day = run === void 0 ? void 0 :
|
|
56213
|
+
const day = run === void 0 ? void 0 : drafted.find((input) => input.link === void 0 && input.field.alias === run.when.alias)?.name;
|
|
55505
56214
|
return {
|
|
55506
56215
|
entry,
|
|
55507
56216
|
entity,
|
|
@@ -55511,11 +56220,14 @@ function planCreate(entry, roles, on = entry) {
|
|
|
55511
56220
|
...parent === void 0 ? {} : { parent },
|
|
55512
56221
|
...opening === void 0 ? {} : { opening },
|
|
55513
56222
|
...party === void 0 ? {} : { party },
|
|
55514
|
-
|
|
56223
|
+
...queued === void 0 ? {} : { queued },
|
|
56224
|
+
// THE ROWS THE BODY READS ARE THE ONES THE DRAFT NAMES. A desk's create
|
|
56225
|
+
// takes the destination and nothing else, so a picker's narrowing or a
|
|
56226
|
+
// copied default over a link nobody states would read a row by an input the
|
|
56227
|
+
// workflow never declared.
|
|
56228
|
+
reads: [...reads.values()].filter((read2) => queued === void 0 || read2.input === queued.name),
|
|
55515
56229
|
unique: unique3,
|
|
55516
|
-
|
|
55517
|
-
// without are the ones a person came to fill — the rest is what they may add.
|
|
55518
|
-
inputs: [...inputs.filter((input) => input.required), ...inputs.filter((input) => !input.required)],
|
|
56230
|
+
inputs: drafted,
|
|
55519
56231
|
mount
|
|
55520
56232
|
};
|
|
55521
56233
|
}
|
|
@@ -55633,6 +56345,7 @@ function childRole(child, role) {
|
|
|
55633
56345
|
return void 0;
|
|
55634
56346
|
}
|
|
55635
56347
|
function runOf(archetype, slot2, child) {
|
|
56348
|
+
if (child.publish !== void 0) return void 0;
|
|
55636
56349
|
const byDay = archetype === "work_record" && slot2 === "children";
|
|
55637
56350
|
const byYear = archetype === "profile" && slot2 === "named_by";
|
|
55638
56351
|
if (!byDay && !byYear) return void 0;
|
|
@@ -55692,6 +56405,7 @@ function specField(bound, declared, extras = {}) {
|
|
|
55692
56405
|
...bound.description === void 0 ? {} : { description: bound.description },
|
|
55693
56406
|
...bound.format === void 0 ? {} : { format: bound.format },
|
|
55694
56407
|
...multi ? { multi: true } : {},
|
|
56408
|
+
...declared?.type === "date" && declared.derive_from !== void 0 ? { derive_from: declared.derive_from } : {},
|
|
55695
56409
|
...extras.opens === void 0 ? {} : { opens: extras.opens },
|
|
55696
56410
|
...extras.route === void 0 ? {} : { route: extras.route },
|
|
55697
56411
|
...money === void 0 ? {} : { money },
|
|
@@ -55770,6 +56484,7 @@ function childExtras(child, roles) {
|
|
|
55770
56484
|
}
|
|
55771
56485
|
function boundAct(entry, templates, act, input) {
|
|
55772
56486
|
const place = act.place === void 0 ? {} : { place: act.place };
|
|
56487
|
+
const asks = act.confirm === true ? { confirm: true } : {};
|
|
55773
56488
|
const gateField = act.when === void 0 ? void 0 : entry.fields.get(act.when.field.alias);
|
|
55774
56489
|
const stands = gateField === void 0 ? {} : { when: { field: gateField.alias, in: optionIds(gateField, act.when?.in ?? []) } };
|
|
55775
56490
|
if (act.kind === "workflow") {
|
|
@@ -55778,14 +56493,14 @@ function boundAct(entry, templates, act, input) {
|
|
|
55778
56493
|
const bound = entry.fields.get(taken.field.alias);
|
|
55779
56494
|
return bound === void 0 ? [] : [{ name: taken.name, field: bound.alias }];
|
|
55780
56495
|
});
|
|
55781
|
-
return inputs.length < act.inputs.length ? void 0 : { kind: "workflow", label: act.label, key: act.workflow, workflow: act.workflow, inputs, ...place, ...stands };
|
|
56496
|
+
return inputs.length < act.inputs.length ? void 0 : { kind: "workflow", label: act.label, key: act.workflow, workflow: act.workflow, inputs, ...place, ...stands, ...asks };
|
|
55782
56497
|
}
|
|
55783
56498
|
if (act.kind === "agent") {
|
|
55784
56499
|
const fills = act.fills.flatMap((field) => {
|
|
55785
56500
|
const bound = entry.fields.get(field.alias);
|
|
55786
56501
|
return bound === void 0 ? [] : [bound.alias];
|
|
55787
56502
|
});
|
|
55788
|
-
return fills.length === 0 ? void 0 : { kind: "agent", label: act.label, key: act.agent, agent: act.agent, fills, ...place, ...stands };
|
|
56503
|
+
return fills.length === 0 ? void 0 : { kind: "agent", label: act.label, key: act.agent, agent: act.agent, fills, ...place, ...stands, ...asks };
|
|
55789
56504
|
}
|
|
55790
56505
|
return templates.has(act.template.alias) ? {
|
|
55791
56506
|
kind: "template",
|
|
@@ -55794,7 +56509,8 @@ function boundAct(entry, templates, act, input) {
|
|
|
55794
56509
|
workflow: generateAlias(act.template.alias),
|
|
55795
56510
|
input,
|
|
55796
56511
|
...place,
|
|
55797
|
-
...stands
|
|
56512
|
+
...stands,
|
|
56513
|
+
...asks
|
|
55798
56514
|
} : void 0;
|
|
55799
56515
|
}
|
|
55800
56516
|
function specRecordActs(entry, templates) {
|
|
@@ -56043,6 +56759,8 @@ function specScreen(entry, roles, templates, inbound) {
|
|
|
56043
56759
|
const summary = specSummary(entry, roles);
|
|
56044
56760
|
const tabs = ref(screen.tabs);
|
|
56045
56761
|
const period = ref(screen.period);
|
|
56762
|
+
const desk = [...entry.children.values()].find((child) => child.publish !== void 0);
|
|
56763
|
+
const destinations = specDestinations(entry, desk);
|
|
56046
56764
|
return {
|
|
56047
56765
|
alias,
|
|
56048
56766
|
label: screen.screen.label,
|
|
@@ -56065,6 +56783,7 @@ function specScreen(entry, roles, templates, inbound) {
|
|
|
56065
56783
|
...lenses.length === 0 ? {} : { lenses },
|
|
56066
56784
|
...period === void 0 ? {} : { period },
|
|
56067
56785
|
...runs === void 0 ? {} : { runs },
|
|
56786
|
+
...destinations === void 0 ? {} : { destinations },
|
|
56068
56787
|
...caption === void 0 ? {} : { caption },
|
|
56069
56788
|
...search.length === 0 ? {} : { search },
|
|
56070
56789
|
...summary === void 0 ? {} : { summary },
|
|
@@ -56074,6 +56793,22 @@ function specScreen(entry, roles, templates, inbound) {
|
|
|
56074
56793
|
...specPresentation(entry)
|
|
56075
56794
|
};
|
|
56076
56795
|
}
|
|
56796
|
+
function specDestinations(entry, desk) {
|
|
56797
|
+
const publish = desk?.publish;
|
|
56798
|
+
if (desk === void 0 || publish === void 0) return void 0;
|
|
56799
|
+
const across = (column) => destinationColumnAlias(publish.party.alias, column.alias);
|
|
56800
|
+
return {
|
|
56801
|
+
query: placedQueryAlias(desk.entity.alias),
|
|
56802
|
+
param: recordsParam(entry.screen.entity.alias),
|
|
56803
|
+
child: desk.entity.alias,
|
|
56804
|
+
link: desk.link.alias,
|
|
56805
|
+
party: publish.party.alias,
|
|
56806
|
+
stage: publish.stage.alias,
|
|
56807
|
+
states: publish.states,
|
|
56808
|
+
...publish.destination.mark === void 0 ? {} : { mark: across(publish.destination.mark) },
|
|
56809
|
+
network: { field: across(publish.destination.network.field), options: publish.destination.network.options }
|
|
56810
|
+
};
|
|
56811
|
+
}
|
|
56077
56812
|
function specPresentation(entry) {
|
|
56078
56813
|
const stated3 = entry.screen.screen.presentation;
|
|
56079
56814
|
if (stated3 === void 0 || Object.values(stated3).every((clause) => clause === void 0)) return {};
|
|
@@ -56188,7 +56923,15 @@ function specChild(child, roles, archetype, slot2, entry, expected) {
|
|
|
56188
56923
|
...files === void 0 ? {} : { files },
|
|
56189
56924
|
...owed === void 0 ? {} : { expected: owed },
|
|
56190
56925
|
...opens === void 0 ? {} : { opens },
|
|
56191
|
-
...written === void 0 ? {} : { write: { alias: writeAlias(written), param: recordParam(written) } }
|
|
56926
|
+
...written === void 0 ? {} : { write: { alias: writeAlias(written), param: recordParam(written) } },
|
|
56927
|
+
// A SECTION THAT OPENS AND WITHDRAWS ITS OWN ROWS WITH NO PANEL — a publish
|
|
56928
|
+
// desk, whose chip IS the draft: the destination is the whole of what a new
|
|
56929
|
+
// row says, and a dialog asking for it again would be a second way to queue
|
|
56930
|
+
// one.
|
|
56931
|
+
...child.publish === void 0 ? {} : {
|
|
56932
|
+
create: { alias: createAlias(child.entity.alias), param: recordParam(entry.screen.entity.alias) },
|
|
56933
|
+
remove: { alias: removeAlias(child.entity.alias), param: recordParam(child.entity.alias) }
|
|
56934
|
+
}
|
|
56192
56935
|
};
|
|
56193
56936
|
}
|
|
56194
56937
|
function specWorksheet(sheet) {
|
|
@@ -56360,6 +57103,59 @@ function specRecord(entry, roles, templates, register) {
|
|
|
56360
57103
|
case "children": {
|
|
56361
57104
|
const child = entry.children.get(childKey(section.child.alias, section.link.alias));
|
|
56362
57105
|
if (child === void 0) break;
|
|
57106
|
+
const desk = child.publish;
|
|
57107
|
+
if (desk !== void 0) {
|
|
57108
|
+
sections.push({
|
|
57109
|
+
kind: "publish",
|
|
57110
|
+
key: sectionKey(child.entity.alias, child.linkAlias),
|
|
57111
|
+
heading: child.heading,
|
|
57112
|
+
child: specChild(child, roles, archetype, slot2, entry, void 0),
|
|
57113
|
+
party: desk.party.alias,
|
|
57114
|
+
stage: desk.stage.alias,
|
|
57115
|
+
states: desk.states,
|
|
57116
|
+
...desk.when === void 0 ? {} : { when: desk.when.alias },
|
|
57117
|
+
...desk.text === void 0 ? {} : { text: desk.text.alias },
|
|
57118
|
+
...desk.permalink === void 0 ? {} : { permalink: desk.permalink.alias },
|
|
57119
|
+
...desk.error === void 0 ? {} : { error: desk.error.alias },
|
|
57120
|
+
destination: {
|
|
57121
|
+
entity: desk.destination.entity.alias,
|
|
57122
|
+
table: desk.destination.table.id,
|
|
57123
|
+
query: desk.destination.alias,
|
|
57124
|
+
fields: specFields(desk.destination.fields, desk.destination.entity.fields),
|
|
57125
|
+
identity: desk.destination.identity.alias,
|
|
57126
|
+
...desk.destination.mark === void 0 ? {} : { mark: desk.destination.mark.alias },
|
|
57127
|
+
...desk.destination.contact === void 0 ? {} : { contact: desk.destination.contact.alias },
|
|
57128
|
+
...desk.destination.group === void 0 ? {} : { group: desk.destination.group.alias },
|
|
57129
|
+
network: { field: desk.destination.network.field.alias, options: desk.destination.network.options },
|
|
57130
|
+
...desk.destination.connected === void 0 ? {} : { connected: desk.destination.connected.alias },
|
|
57131
|
+
...desk.destination.offered === void 0 ? {} : { offered: { field: desk.destination.offered.field.alias, in: [...desk.destination.offered.in] } },
|
|
57132
|
+
...desk.destination.language === void 0 ? {} : {
|
|
57133
|
+
language: {
|
|
57134
|
+
field: desk.destination.language.field.alias,
|
|
57135
|
+
// THE BODY IS A COLUMN OF THIS RECORD, so the map's values
|
|
57136
|
+
// are rewritten from the model's aliases into the ones the
|
|
57137
|
+
// record's own read projects.
|
|
57138
|
+
options: Object.fromEntries(
|
|
57139
|
+
Object.entries(desk.destination.language.options).flatMap(([option, body]) => {
|
|
57140
|
+
const bound2 = entry.fields.get(body);
|
|
57141
|
+
return bound2 === void 0 ? [] : [[option, bound2.alias]];
|
|
57142
|
+
})
|
|
57143
|
+
)
|
|
57144
|
+
}
|
|
57145
|
+
}
|
|
57146
|
+
},
|
|
57147
|
+
bodies: desk.bodies.map((body) => body.alias),
|
|
57148
|
+
...desk.media === void 0 ? {} : { media: desk.media.alias },
|
|
57149
|
+
...desk.link === void 0 ? {} : { link: desk.link.alias },
|
|
57150
|
+
publish: {
|
|
57151
|
+
workflow: desk.publish,
|
|
57152
|
+
param: recordParam(entity),
|
|
57153
|
+
rows: recordsParam(child.entity.alias)
|
|
57154
|
+
},
|
|
57155
|
+
...stands
|
|
57156
|
+
});
|
|
57157
|
+
break;
|
|
57158
|
+
}
|
|
56363
57159
|
const door = entry.mounts && child.entity.alias === register;
|
|
56364
57160
|
if (draw === "related") {
|
|
56365
57161
|
related.push({
|
|
@@ -56598,7 +57394,14 @@ function specCreate(plan) {
|
|
|
56598
57394
|
};
|
|
56599
57395
|
}
|
|
56600
57396
|
function bindAppSpec(app, entry, roles, templates = /* @__PURE__ */ new Map()) {
|
|
56601
|
-
const
|
|
57397
|
+
const desks = new Set(
|
|
57398
|
+
appSurfaces(entry).flatMap(
|
|
57399
|
+
(surface) => [...surface.children.values()].flatMap(
|
|
57400
|
+
(child) => child.publish === void 0 ? [] : [sectionKey(child.entity.alias, child.linkAlias)]
|
|
57401
|
+
)
|
|
57402
|
+
)
|
|
57403
|
+
);
|
|
57404
|
+
const creates = planCreates(entry, roles).filter((plan) => plan.mount.kind !== "section" || !desks.has(plan.mount.key));
|
|
56602
57405
|
const screen = specScreen(entry, roles, templates, [...registerFilters(entry).keys()]);
|
|
56603
57406
|
const records = appSurfaces(entry).map(
|
|
56604
57407
|
(surface) => [surface.screen.entity.alias, specRecord(surface, roles, templates, entry.screen.entity.alias)]
|
|
@@ -56639,14 +57442,20 @@ var COMPONENTS_DIR = "src/components";
|
|
|
56639
57442
|
var projectionSchema = zod_default.object({
|
|
56640
57443
|
kind: zod_default.literal("project"),
|
|
56641
57444
|
from: zod_default.object({ kind: zod_default.literal("from_table"), table_id: zod_default.string() }),
|
|
56642
|
-
columns: zod_default.array(zod_default.union([zod_default.string(), zod_default.object({ source: zod_default.
|
|
57445
|
+
columns: zod_default.array(zod_default.union([zod_default.string(), zod_default.object({ source: zod_default.unknown(), output: zod_default.string().optional() })]))
|
|
56643
57446
|
});
|
|
56644
57447
|
function projectionOf(declaration) {
|
|
56645
57448
|
const parsed = projectionSchema.safeParse(declaration?.ast);
|
|
56646
57449
|
if (!parsed.success) return void 0;
|
|
56647
57450
|
return {
|
|
56648
57451
|
table: parsed.data.from.table_id,
|
|
56649
|
-
outputs: new Set(
|
|
57452
|
+
outputs: new Set(
|
|
57453
|
+
parsed.data.columns.flatMap((column) => {
|
|
57454
|
+
if (typeof column === "string") return [column];
|
|
57455
|
+
if (column.output !== void 0) return [column.output];
|
|
57456
|
+
return typeof column.source === "string" ? [column.source] : [];
|
|
57457
|
+
})
|
|
57458
|
+
)
|
|
56650
57459
|
};
|
|
56651
57460
|
}
|
|
56652
57461
|
function checkSurface(at2, alias, table, fields, manifest) {
|
|
@@ -56704,6 +57513,17 @@ function itineraryRunAliases(plan) {
|
|
|
56704
57513
|
reference: plan.reference
|
|
56705
57514
|
};
|
|
56706
57515
|
}
|
|
57516
|
+
function checkDeskIsSoleEditor(at2, desk, record2) {
|
|
57517
|
+
const filed = new Map(
|
|
57518
|
+
record2.sections.flatMap(
|
|
57519
|
+
(section) => section.kind === "facts" ? section.groups.flatMap((group) => group.facts.map((fact) => [fact.field, section.key])) : []
|
|
57520
|
+
)
|
|
57521
|
+
);
|
|
57522
|
+
return [...desk.bodies, ...desk.media === void 0 ? [] : [desk.media]].flatMap((alias) => {
|
|
57523
|
+
const band = filed.get(alias);
|
|
57524
|
+
return band === void 0 ? [] : [{ at: at2, message: `sends "${alias}", which "${band}" also draws as a fact \u2014 the desk is that column's editor, so it stands in one of the two` }];
|
|
57525
|
+
});
|
|
57526
|
+
}
|
|
56707
57527
|
function checkRecord(spec, key, record2, manifest) {
|
|
56708
57528
|
const at2 = `records.${key}`;
|
|
56709
57529
|
const findings = [];
|
|
@@ -56772,6 +57592,45 @@ function checkRecord(spec, key, record2, manifest) {
|
|
|
56772
57592
|
}
|
|
56773
57593
|
}
|
|
56774
57594
|
}
|
|
57595
|
+
if (section.kind === "publish") {
|
|
57596
|
+
const reach = section.destination;
|
|
57597
|
+
findings.push(...checkSurface(childAt, reach.query, reach.table, reach.fields, manifest));
|
|
57598
|
+
for (const alias of [
|
|
57599
|
+
reach.identity,
|
|
57600
|
+
...reach.mark === void 0 ? [] : [reach.mark],
|
|
57601
|
+
...reach.contact === void 0 ? [] : [reach.contact],
|
|
57602
|
+
...reach.group === void 0 ? [] : [reach.group],
|
|
57603
|
+
reach.network.field,
|
|
57604
|
+
...reach.connected === void 0 ? [] : [reach.connected],
|
|
57605
|
+
...reach.offered === void 0 ? [] : [reach.offered.field],
|
|
57606
|
+
...reach.language === void 0 ? [] : [reach.language.field]
|
|
57607
|
+
]) {
|
|
57608
|
+
if (!Object.hasOwn(reach.fields, alias)) {
|
|
57609
|
+
findings.push({ at: childAt, message: `reads "${alias}" off its destinations, which "${reach.query}" does not project` });
|
|
57610
|
+
}
|
|
57611
|
+
}
|
|
57612
|
+
for (const alias of [
|
|
57613
|
+
...section.bodies,
|
|
57614
|
+
...section.media === void 0 ? [] : [section.media],
|
|
57615
|
+
...section.link === void 0 ? [] : [section.link],
|
|
57616
|
+
...Object.values(reach.language?.options ?? {})
|
|
57617
|
+
]) {
|
|
57618
|
+
if (!Object.hasOwn(record2.fields, alias)) {
|
|
57619
|
+
findings.push({ at: childAt, message: `sends "${alias}", which this record does not project` });
|
|
57620
|
+
}
|
|
57621
|
+
}
|
|
57622
|
+
findings.push(...checkDeskIsSoleEditor(childAt, section, record2));
|
|
57623
|
+
const sends = manifest.workflows?.[section.publish.workflow];
|
|
57624
|
+
if (sends === void 0) {
|
|
57625
|
+
findings.push({ at: childAt, message: `sends through "${section.publish.workflow}", which package.json#lotics.workflows does not declare` });
|
|
57626
|
+
} else {
|
|
57627
|
+
for (const name of [section.publish.param, section.publish.rows]) {
|
|
57628
|
+
if (!Object.hasOwn(sends.inputs ?? {}, name)) {
|
|
57629
|
+
findings.push({ at: childAt, message: `sends "${name}" to "${section.publish.workflow}", which declares no such input` });
|
|
57630
|
+
}
|
|
57631
|
+
}
|
|
57632
|
+
}
|
|
57633
|
+
}
|
|
56775
57634
|
const child = sectionChild2(section);
|
|
56776
57635
|
if (child === void 0) continue;
|
|
56777
57636
|
findings.push(...checkSurface(childAt, child.query, child.table, child.fields, manifest));
|
|
@@ -56779,6 +57638,17 @@ function checkRecord(spec, key, record2, manifest) {
|
|
|
56779
57638
|
findings.push({ at: childAt, message: `filters on "${child.param}", which "${child.query}" declares no param for` });
|
|
56780
57639
|
}
|
|
56781
57640
|
const reads = [
|
|
57641
|
+
// A DESK'S OWN COLUMNS are read off the CHILD's rows like every other
|
|
57642
|
+
// section's: the destination each stands on, where it stands, the override
|
|
57643
|
+
// it goes out with, where it landed and what the platform said.
|
|
57644
|
+
...section.kind === "publish" ? [
|
|
57645
|
+
section.party,
|
|
57646
|
+
section.stage,
|
|
57647
|
+
...section.when === void 0 ? [] : [section.when],
|
|
57648
|
+
...section.text === void 0 ? [] : [section.text],
|
|
57649
|
+
...section.permalink === void 0 ? [] : [section.permalink],
|
|
57650
|
+
...section.error === void 0 ? [] : [section.error]
|
|
57651
|
+
] : [],
|
|
56782
57652
|
...section.kind === "timeline" ? [section.planned, section.actual] : [],
|
|
56783
57653
|
...section.kind === "thread" ? [
|
|
56784
57654
|
section.body,
|
|
@@ -56826,6 +57696,27 @@ function checkRecord(spec, key, record2, manifest) {
|
|
|
56826
57696
|
if (child.write !== void 0 && manifest.workflows?.[child.write.alias] === void 0) {
|
|
56827
57697
|
findings.push({ at: childAt, message: `changes its rows through "${child.write.alias}", which package.json#lotics.workflows does not declare` });
|
|
56828
57698
|
}
|
|
57699
|
+
if (child.create !== void 0) {
|
|
57700
|
+
const opens = manifest.workflows?.[child.create.alias];
|
|
57701
|
+
if (opens === void 0) {
|
|
57702
|
+
findings.push({ at: childAt, message: `opens its rows through "${child.create.alias}", which package.json#lotics.workflows does not declare` });
|
|
57703
|
+
} else {
|
|
57704
|
+
const named3 = [child.create.param, ...section.kind === "publish" ? [section.party] : []];
|
|
57705
|
+
for (const input of named3) {
|
|
57706
|
+
if (!Object.hasOwn(opens.inputs ?? {}, input)) {
|
|
57707
|
+
findings.push({ at: childAt, message: `opens its rows with "${input}", which "${child.create.alias}" declares no such input` });
|
|
57708
|
+
}
|
|
57709
|
+
}
|
|
57710
|
+
}
|
|
57711
|
+
}
|
|
57712
|
+
if (child.remove !== void 0) {
|
|
57713
|
+
const takes = manifest.workflows?.[child.remove.alias];
|
|
57714
|
+
if (takes === void 0) {
|
|
57715
|
+
findings.push({ at: childAt, message: `withdraws its rows through "${child.remove.alias}", which package.json#lotics.workflows does not declare` });
|
|
57716
|
+
} else if (!Object.hasOwn(takes.inputs ?? {}, child.remove.param)) {
|
|
57717
|
+
findings.push({ at: childAt, message: `withdraws its rows with "${child.remove.param}", which "${child.remove.alias}" declares no such input` });
|
|
57718
|
+
}
|
|
57719
|
+
}
|
|
56829
57720
|
if (child.write !== void 0 && !writesChildren(spec.screen.writes)) {
|
|
56830
57721
|
findings.push({
|
|
56831
57722
|
at: childAt,
|
|
@@ -56974,6 +57865,29 @@ function checkAppSpec(spec, manifest, components) {
|
|
|
56974
57865
|
findings.push({ at: at2, message: `lands a file through "${taken.workflow}", which package.json#lotics.workflows does not declare` });
|
|
56975
57866
|
}
|
|
56976
57867
|
}
|
|
57868
|
+
const marks = screen.destinations;
|
|
57869
|
+
if (marks !== void 0) {
|
|
57870
|
+
if (manifest.queries?.[marks.query] === void 0) {
|
|
57871
|
+
findings.push({ at: at2, message: `reads its destinations from "${marks.query}", which package.json#lotics.queries does not declare` });
|
|
57872
|
+
} else {
|
|
57873
|
+
if (!paramsOf(manifest, marks.query).has(marks.param)) {
|
|
57874
|
+
findings.push({ at: at2, message: `reads its destinations by "${marks.param}", which "${marks.query}" declares no param for` });
|
|
57875
|
+
}
|
|
57876
|
+
const projection2 = projectionOf(manifest.queries[marks.query]);
|
|
57877
|
+
const read2 = [
|
|
57878
|
+
marks.link,
|
|
57879
|
+
marks.party,
|
|
57880
|
+
marks.stage,
|
|
57881
|
+
...marks.mark === void 0 ? [] : [marks.mark],
|
|
57882
|
+
...marks.network === void 0 ? [] : [marks.network.field]
|
|
57883
|
+
];
|
|
57884
|
+
for (const alias of read2) {
|
|
57885
|
+
if (projection2 !== void 0 && !projection2.outputs.has(alias)) {
|
|
57886
|
+
findings.push({ at: at2, message: `reads its destinations' "${alias}", which "${marks.query}" does not project` });
|
|
57887
|
+
}
|
|
57888
|
+
}
|
|
57889
|
+
}
|
|
57890
|
+
}
|
|
56977
57891
|
for (const slot2 of screen.slots) {
|
|
56978
57892
|
if (slot2.quick !== true) continue;
|
|
56979
57893
|
const refused = quickRefusal(slot2.role, slot2.order);
|
|
@@ -57272,6 +58186,14 @@ function planWorkflows(entry, roles, templates = /* @__PURE__ */ new Map()) {
|
|
|
57272
58186
|
...importColumns(entry, taken).map((column) => column.bound.alias)
|
|
57273
58187
|
]);
|
|
57274
58188
|
}
|
|
58189
|
+
const removes = /* @__PURE__ */ new Set();
|
|
58190
|
+
for (const desk of publishDesks(entry)) {
|
|
58191
|
+
declared[removeAlias(desk.entity.alias)] = {
|
|
58192
|
+
inputs: { [recordParam(desk.entity.alias)]: { type: "record_link", table_id: desk.table.id, required: true } },
|
|
58193
|
+
description: `Take one row of ${desk.entity.label} back off the desk it was queued on \u2014 refused once it has gone out.`
|
|
58194
|
+
};
|
|
58195
|
+
removes.add(desk.tableAlias);
|
|
58196
|
+
}
|
|
57275
58197
|
for (const act of templateActs(entry)) {
|
|
57276
58198
|
if (!templates.has(act.template.alias)) continue;
|
|
57277
58199
|
const entity = act.entry.screen.entity;
|
|
@@ -57285,7 +58207,8 @@ function planWorkflows(entry, roles, templates = /* @__PURE__ */ new Map()) {
|
|
|
57285
58207
|
}
|
|
57286
58208
|
return {
|
|
57287
58209
|
workflows: declared,
|
|
57288
|
-
writes: Object.fromEntries([...written].map(([table, fields]) => [table, [...fields].sort()]))
|
|
58210
|
+
writes: Object.fromEntries([...written].map(([table, fields]) => [table, [...fields].sort()])),
|
|
58211
|
+
deletes: [...removes].sort()
|
|
57289
58212
|
};
|
|
57290
58213
|
}
|
|
57291
58214
|
function writtenColumns(entry) {
|
|
@@ -57398,18 +58321,18 @@ function scopePasses(rule, local) {
|
|
|
57398
58321
|
return `(!isNull(${caller}) && ${names})`;
|
|
57399
58322
|
}).join(" || ");
|
|
57400
58323
|
}
|
|
57401
|
-
function scopeGuard(
|
|
57402
|
-
const rule =
|
|
58324
|
+
function scopeGuard(on, param, over = `i.${param}`, pad = "") {
|
|
58325
|
+
const rule = on.scope;
|
|
57403
58326
|
if (rule === void 0) return "";
|
|
57404
58327
|
const reads = rule.clauses.some((clause) => clause.kind !== "member_of");
|
|
57405
|
-
const fetched = reads ? `${pad}const target = await get_record({ table_id: ${str(
|
|
58328
|
+
const fetched = reads ? `${pad}const target = await get_record({ table_id: ${str(on.table.id)}, record_id: ${over} });
|
|
57406
58329
|
` : "";
|
|
57407
58330
|
const passes = scopePasses(rule, "target");
|
|
57408
58331
|
return `${pad}// THE ROW RULE, ON THE WRITE PATH. The record's id arrives from whoever called
|
|
57409
58332
|
${pad}// this workflow and is proven against the TABLE, never against the rows that
|
|
57410
58333
|
${pad}// caller may read.
|
|
57411
58334
|
${fetched}${pad}validate({
|
|
57412
|
-
${pad} checks: [{ fail_when: !(${passes}), message: ${str(
|
|
58335
|
+
${pad} checks: [{ fail_when: !(${passes}), message: ${str(on.words.refusals.outOfScope)} }],
|
|
57413
58336
|
${pad}});
|
|
57414
58337
|
`;
|
|
57415
58338
|
}
|
|
@@ -57459,6 +58382,22 @@ validate({
|
|
|
57459
58382
|
${scopeGuard(entry, param)}${blocks.join("\n")}
|
|
57460
58383
|
`;
|
|
57461
58384
|
}
|
|
58385
|
+
function removeSource(entry, desk) {
|
|
58386
|
+
const param = recordParam(desk.entity.alias);
|
|
58387
|
+
return `// ${removeAlias(desk.entity.alias)} \u2014 take one row of ${desk.entity.label} back.
|
|
58388
|
+
// ONLY WHILE IT IS STILL WAITING. A row that has gone out is the record of
|
|
58389
|
+
// something that exists elsewhere, and removing it here would leave that standing
|
|
58390
|
+
// with nothing in the workspace saying so \u2014 so the stage is read first and
|
|
58391
|
+
// anything past the queue is refused.
|
|
58392
|
+
const i = trigger.app_workflow.inputs;
|
|
58393
|
+
${scopeGuard({ scope: desk.scope, table: desk.table, words: entry.words }, param)}const standing = await get_record({ table_id: ${str(desk.table.id)}, record_id: i.${param} });
|
|
58394
|
+
validate({
|
|
58395
|
+
checks: [{ fail_when: standing.data[${str(desk.publish.stage.id)}] != ${str(desk.publish.states.queued)}, message: ${str(entry.words.refusals.sent)} }],
|
|
58396
|
+
});
|
|
58397
|
+
await delete_records({ table_id: ${str(desk.table.id)}, record_ids: [i.${param}] });
|
|
58398
|
+
return({ status: "success", message: ${str(entry.words.refusals.withdrawn(desk.entity.label))} });
|
|
58399
|
+
`;
|
|
58400
|
+
}
|
|
57462
58401
|
function historyAppend(entry) {
|
|
57463
58402
|
for (const { section } of entry.sections) {
|
|
57464
58403
|
if (section.kind !== "progress" || section.history === void 0) continue;
|
|
@@ -57521,6 +58460,7 @@ ${pad} ],
|
|
|
57521
58460
|
${pad}});`;
|
|
57522
58461
|
}
|
|
57523
58462
|
function createValue(input) {
|
|
58463
|
+
if (input.field.type === "files") return `i.${input.name} ?? []`;
|
|
57524
58464
|
const list3 = input.link !== void 0 || input.field.type === "select_member" && input.field.multi === true;
|
|
57525
58465
|
return list3 ? `isNull(i.${input.name}) ? null : [i.${input.name}]` : `i.${input.name}`;
|
|
57526
58466
|
}
|
|
@@ -57566,7 +58506,7 @@ function createSource(plan) {
|
|
|
57566
58506
|
for (const input of plan.inputs) {
|
|
57567
58507
|
if (input.into === "party" && input.through?.matched !== true) continue;
|
|
57568
58508
|
if (input.required) {
|
|
57569
|
-
const empty = input.field.type === "text" || input.into === "party" ? `isEmpty(trim(coalesce(i.${input.name}, "")))` : `isNull(i.${input.name})`;
|
|
58509
|
+
const empty = input.field.type === "files" ? `isEmpty(i.${input.name})` : input.field.type === "text" || input.into === "party" ? `isEmpty(trim(coalesce(i.${input.name}, "")))` : `isNull(i.${input.name})`;
|
|
57570
58510
|
checks.push(checkLine(empty, input.name, words.refusals.required(input.label)));
|
|
57571
58511
|
}
|
|
57572
58512
|
if (input.rule?.min !== void 0) {
|
|
@@ -57596,6 +58536,20 @@ function createSource(plan) {
|
|
|
57596
58536
|
]).trimEnd()
|
|
57597
58537
|
);
|
|
57598
58538
|
}
|
|
58539
|
+
const queued = plan.queued;
|
|
58540
|
+
if (queued !== void 0 && plan.parent !== void 0) {
|
|
58541
|
+
const standing = [
|
|
58542
|
+
`{ node_type: "condition", field_key: ${str(plan.parent.bound.id)}, operator: "has_any_of", value: [i.${plan.parent.param}] }`,
|
|
58543
|
+
`{ node_type: "condition", field_key: ${str(queued.bound.id)}, operator: "has_any_of", value: [i.${queued.name}] }`
|
|
58544
|
+
];
|
|
58545
|
+
lines.push(
|
|
58546
|
+
`const placed = await query_records({
|
|
58547
|
+
table_id: ${str(plan.table.id)},
|
|
58548
|
+
filters: { node_type: "group", logic: "and", children: [${standing.join(", ")}] },
|
|
58549
|
+
});`,
|
|
58550
|
+
validateBlock([checkLine(`!isNull(first(placed.records))`, queued.name, words.refusals.placed(queued.label))]).trimEnd()
|
|
58551
|
+
);
|
|
58552
|
+
}
|
|
57599
58553
|
for (const read2 of plan.reads) {
|
|
57600
58554
|
lines.push(`const ${read2.local} = await get_record({ table_id: ${str(read2.link.table.id)}, record_id: i.${read2.input} });`);
|
|
57601
58555
|
const narrowed = [
|
|
@@ -57690,6 +58644,10 @@ function createInputs(plan) {
|
|
|
57690
58644
|
inputs[input.name] = { type: "record_link", table_id: input.link.table.id, required: input.required };
|
|
57691
58645
|
continue;
|
|
57692
58646
|
}
|
|
58647
|
+
if (input.field.type === "files") {
|
|
58648
|
+
inputs[input.name] = { type: "file", multi: true, required: input.required };
|
|
58649
|
+
continue;
|
|
58650
|
+
}
|
|
57693
58651
|
if (input.field.type === "select") {
|
|
57694
58652
|
inputs[input.name] = { type: "select", field: input.bound.id, ...input.field.multi === true ? { multi: true } : {}, required: input.required };
|
|
57695
58653
|
continue;
|
|
@@ -57824,6 +58782,8 @@ function buildPlanFiles(app, entry, roles, templates = /* @__PURE__ */ new Map()
|
|
|
57824
58782
|
{ path: COMPONENTS_INDEX, content: COMPONENTS },
|
|
57825
58783
|
...editors,
|
|
57826
58784
|
...creates.map((plan) => ({ path: `src/workflows/${createAlias(plan.entity.alias)}.ts`, content: createSource(plan) })),
|
|
58785
|
+
// THE OTHER HALF OF A CHIP — the press that takes a queued destination back.
|
|
58786
|
+
...publishDesks(entry).map((desk) => ({ path: `src/workflows/${removeAlias(desk.entity.alias)}.ts`, content: removeSource(entry, desk) })),
|
|
57827
58787
|
...entry.screen.acts.import === null ? [] : [{ path: `src/workflows/${importAlias(entry.screen.acts.import.entity.alias)}.ts`, content: importSource(entry) }],
|
|
57828
58788
|
...templateActs(entry).flatMap((act) => {
|
|
57829
58789
|
const id = templates.get(act.template.alias);
|
|
@@ -57833,7 +58793,16 @@ function buildPlanFiles(app, entry, roles, templates = /* @__PURE__ */ new Map()
|
|
|
57833
58793
|
];
|
|
57834
58794
|
}
|
|
57835
58795
|
function readmeSource(app, entry) {
|
|
57836
|
-
const tables = [
|
|
58796
|
+
const tables = [
|
|
58797
|
+
entry.screen.entity.label,
|
|
58798
|
+
...[...entry.children.values()].flatMap((child) => [
|
|
58799
|
+
child.entity.label,
|
|
58800
|
+
// …AND THE TABLE A PUBLISH DESK OFFERS ITS DESTINATIONS FROM, which no
|
|
58801
|
+
// section lists and no relation reaches: a reader opening the project a
|
|
58802
|
+
// month later would meet a read over a table this page never named.
|
|
58803
|
+
...child.publish === void 0 ? [] : [child.publish.destination.entity.label]
|
|
58804
|
+
])
|
|
58805
|
+
];
|
|
57837
58806
|
return `# ${app.name}
|
|
57838
58807
|
|
|
57839
58808
|
${app.description ?? `Generated from its plan \u2014 one register, and the record its rows open.`}
|
|
@@ -61484,6 +62453,7 @@ function readAppMeta(projectDir) {
|
|
|
61484
62453
|
// Same rule as `bundle_calls` below: absent is no declaration, which is a
|
|
61485
62454
|
// different claim from an empty one.
|
|
61486
62455
|
writes: pkg.lotics.writes,
|
|
62456
|
+
deletes: pkg.lotics.deletes,
|
|
61487
62457
|
// Deliberately NOT defaulted: absent must stay absent, because absent is
|
|
61488
62458
|
// what tells the orphan report it has no baseline to compare against and
|
|
61489
62459
|
// must say nothing. An `?? {queries:[],…}` here would read as "the last
|
|
@@ -62319,6 +63289,7 @@ async function resolvePlanForCreate(client, from) {
|
|
|
62319
63289
|
templates,
|
|
62320
63290
|
live,
|
|
62321
63291
|
writes: bindings.writes,
|
|
63292
|
+
deletes: bindings.deletes,
|
|
62322
63293
|
...Object.keys(bindings.workflows).length === 0 ? {} : { workflows: bindings.workflows }
|
|
62323
63294
|
};
|
|
62324
63295
|
}
|
|
@@ -62442,6 +63413,7 @@ function withPlanFiles(targetPath, starter, plan) {
|
|
|
62442
63413
|
const generated = buildPlanFiles(plan.app, plan.bound, plan.roles, plan.templates);
|
|
62443
63414
|
const replaced = /* @__PURE__ */ new Set([...generated.map((file2) => file2.path), "src/App.tsx"]);
|
|
62444
63415
|
const writes = Object.keys(plan.writes).length === 0 ? void 0 : plan.writes;
|
|
63416
|
+
const deletes = plan.deletes.length === 0 ? void 0 : plan.deletes;
|
|
62445
63417
|
return {
|
|
62446
63418
|
files: [
|
|
62447
63419
|
...starter.map((file2) => {
|
|
@@ -62458,7 +63430,8 @@ function withPlanFiles(targetPath, starter, plan) {
|
|
|
62458
63430
|
// Absent leaves the scaffold's own empty map; overwriting it with one
|
|
62459
63431
|
// would say the plan declared no writes, which is a different claim.
|
|
62460
63432
|
...plan.workflows === void 0 ? {} : { workflows: plan.workflows },
|
|
62461
|
-
...writes === void 0 ? {} : { writes }
|
|
63433
|
+
...writes === void 0 ? {} : { writes },
|
|
63434
|
+
...deletes === void 0 ? {} : { deletes }
|
|
62462
63435
|
};
|
|
62463
63436
|
return { path: file2.path, content: `${JSON.stringify(manifest, null, 2)}
|
|
62464
63437
|
` };
|
|
@@ -63113,15 +64086,16 @@ function reportUndescribedQueries(queries) {
|
|
|
63113
64086
|
}
|
|
63114
64087
|
async function reportUndeclaredWrites(client, projectDir, meta3, tables) {
|
|
63115
64088
|
const aliases = Object.keys(meta3.workflows ?? {});
|
|
63116
|
-
if (meta3.writes === void 0) {
|
|
64089
|
+
if (meta3.writes === void 0 && meta3.deletes === void 0) {
|
|
63117
64090
|
if (aliases.length > 0) {
|
|
63118
64091
|
warn(
|
|
63119
64092
|
`\u26A0 This app declares no writes, so nothing checks what its ${aliases.length} workflow bod${aliases.length === 1 ? "y" : "ies"} change.
|
|
63120
|
-
Declare them in package.json#lotics.writes as {"<F.TABLE alias>": ["<field alias>", \u2026]} \u2014 'lotics app create --from' seeds
|
|
64093
|
+
Declare them in package.json#lotics.writes as {"<F.TABLE alias>": ["<field alias>", \u2026]}, and the tables whose rows they DELETE in package.json#lotics.deletes as ["<F.TABLE alias>", \u2026] \u2014 'lotics app create --from' seeds both, and 'lotics app check' then refuses a body that writes outside them.`
|
|
63121
64094
|
);
|
|
63122
64095
|
}
|
|
63123
64096
|
return 0;
|
|
63124
64097
|
}
|
|
64098
|
+
const declaration = { writes: meta3.writes ?? {}, deletes: meta3.deletes ?? [] };
|
|
63125
64099
|
const bodies = aliases.flatMap((alias) => {
|
|
63126
64100
|
const file2 = workflowFilePath(projectDir, alias);
|
|
63127
64101
|
if (!fs11.existsSync(file2)) return [];
|
|
@@ -63129,7 +64103,7 @@ async function reportUndeclaredWrites(client, projectDir, meta3, tables) {
|
|
|
63129
64103
|
});
|
|
63130
64104
|
if (tables === null) {
|
|
63131
64105
|
warn(
|
|
63132
|
-
`\u26A0 Did not check the ${bodies.length} workflow bod${bodies.length === 1 ? "y" : "ies"} against package.json#lotics.writes \u2014 the workspace schema could not be read, and the declaration names fields by alias. The warning above says why.`
|
|
64106
|
+
`\u26A0 Did not check the ${bodies.length} workflow bod${bodies.length === 1 ? "y" : "ies"} against package.json#lotics.writes/deletes \u2014 the workspace schema could not be read, and the declaration names fields and tables by alias. The warning above says why.`
|
|
63133
64107
|
);
|
|
63134
64108
|
return 0;
|
|
63135
64109
|
}
|
|
@@ -63145,10 +64119,10 @@ async function reportUndeclaredWrites(client, projectDir, meta3, tables) {
|
|
|
63145
64119
|
return 0;
|
|
63146
64120
|
}
|
|
63147
64121
|
}
|
|
63148
|
-
const { findings, unresolved, unknownTools } = writeFindings(
|
|
64122
|
+
const { findings, unresolved, unknownTools } = writeFindings(declaration, bodies, resolved);
|
|
63149
64123
|
if (unresolved.length > 0) {
|
|
63150
64124
|
warn(
|
|
63151
|
-
`\u26A0 package.json#lotics.writes names ${unresolved.length} table this app neither reads nor writes (${unresolved.join(", ")}) \u2014 ${unresolved.length === 1 ? "it covers" : "they cover"} nothing. Name it by the F.<TABLE> alias the field map carries, or drop the entry.`
|
|
64125
|
+
`\u26A0 package.json#lotics.writes/deletes names ${unresolved.length} table this app neither reads nor writes (${unresolved.join(", ")}) \u2014 ${unresolved.length === 1 ? "it covers" : "they cover"} nothing. Name it by the F.<TABLE> alias the field map carries, or drop the entry.`
|
|
63152
64126
|
);
|
|
63153
64127
|
}
|
|
63154
64128
|
if (unknownTools.length > 0) {
|
|
@@ -63163,7 +64137,8 @@ async function reportUndeclaredWrites(client, projectDir, meta3, tables) {
|
|
|
63163
64137
|
` + findings.map((finding) => ` \u2022 ${finding.message}`).join("\n") + `
|
|
63164
64138
|
|
|
63165
64139
|
What an app writes is a contract its catalogue publishes and other desks are built against. Either the body is writing a column that belongs to another desk, or the declaration is missing it \u2014 add the field to package.json#lotics.writes, and name the other apps in a
|
|
63166
|
-
{"field": "<field>", "shared_with": ["<app alias>"]} entry when the split is deliberate
|
|
64140
|
+
{"field": "<field>", "shared_with": ["<app alias>"]} entry when the split is deliberate.
|
|
64141
|
+
A body that DELETES names no column: add its table to package.json#lotics.deletes.`
|
|
63167
64142
|
);
|
|
63168
64143
|
return findings.length;
|
|
63169
64144
|
}
|
|
@@ -65401,7 +66376,7 @@ Captured ${totalRows} row${totalRows === 1 ? "" : "s"} across ${result.captured.
|
|
|
65401
66376
|
}
|
|
65402
66377
|
|
|
65403
66378
|
// src/model_reference.md
|
|
65404
|
-
var model_reference_default = '# The Lotics workspace model (`model.json`)\n\nOne JSON file describing the tables, fields, options, views, roles and first rows\na workspace starts with. `lotics scaffold check model.json` proves it offline \u2014\nno account, no network. `lotics setup model.json --email you@company.com` creates\nthe account and applies it. `lotics scaffold apply model.json` applies it again,\ninto the workspace the credential names.\n\n**There are two forms of this file.** The full one, below, spells the model out.\nThe `from` one names a published preset and carries only what this business\ndiffers by \u2014 see \xA7 Starting from a preset, and prefer it whenever a preset fits\nthe trade.\n\nApps are PLANNED here and built afterwards: `apps` names each app\'s screens as a\nshape over an entity, checked against the roles `field_roles` gives its fields,\nso the plan is refused before anyone builds a screen (\xA7 Apps and screens). The\nbuilt app lives in the workspace; publishing that workspace as a package is how\nit ships.\n\n## The rules\n\n- **At least one entity, at most 50.** More tables than that is a data model\n being designed, not scaffolded \u2014 scaffold the rest in a second call.\n- **Adoption is explicit.** `lotics setup` REFUSES an entity whose `label`\n already names a table in the workspace, naming every colliding label at once.\n `lotics scaffold apply` adopts those tables and adds the fields, options and\n views they are missing. Nothing is ever modified or deleted, so applying the\n same model twice creates nothing the second time.\n- **Adoption is by LABEL, not alias.** Change an entity\'s `label` and the next\n run asks for a NEW table beside the old one. Renaming a FIELD is\n `lotics field rename <table> <field> "<new label>" --model <this file>`, which\n moves the platform, this file and every app bound to it together; deleting is\n `lotics run delete_table`. Neither goes through the file.\n- **The file\'s own majority is the language.** A model names no locale \u2014 which\n language it is in is what it mostly says, and `scaffold check` notes the label\n written the other way. The generated screens read the kit\'s pack, and a\n generated WRITE cannot: its refusals run on the server, so they are worded in\n that same majority. Mix the two and the workspace answers in two languages.\n- **`lotics scaffold diff model.json` says where the file and the workspace have\n come apart**, joined on label, entity then field \u2014 the join adoption itself\n makes. It exits 1 on any difference, so a model that is about to be published\n as a preset carries the labels in use rather than the ones it was written with.\n- **Rows land only where every bound table is empty.** One table already holding\n records and no rows are written anywhere, and the result says\n `rows_skipped: true`: sample rows landing among a customer\'s real ones cannot\n be told apart from them.\n- **After the first run the WORKSPACE is the source of truth.** The file is an\n authoring input, not a mirror \u2014 scaffold never deletes what the file stopped\n naming.\n- **`lotics scaffold check` decides all of it offline**, and reports every\n problem in one run rather than the first: an alias that resolves to nothing, a\n link whose pair is not symmetric, and the rows themselves \u2014 a field the entity\n does not declare, an option alias the field does not declare, a link naming no\n row in the file, a `ref` used twice, a date that is not one, a value on a\n platform-computed field, and a files cell that is neither a relative path\n beside this file nor a `fil_` id.\n\n## Top level\n\n```jsonc\n{\n "entities": [ /* the tables */ ],\n "roles": [ /* workspace groups to create */ ], // optional\n "templates":[ /* inline html / email templates */ ], // optional\n "rows": { /* first records, keyed by entity alias */ }, // optional\n "field_roles": { /* the reporting role each field plays, keyed by entity then field */ }, // optional\n "write_rules": { /* what a CREATE finds, defaults and refuses, keyed by entity */ }, // optional\n "apps": [ /* the screens each app will have, as shapes over entities */ ], // optional\n "apply": [ /* published packages to copy in afterwards */ ], // optional\n "preset": { /* a trade\'s branches, for a PUBLISHED model */ } // optional\n}\n```\n\nThe other form names a preset instead of restating one:\n\n```jsonc\n{\n "from": "field_service", // the preset this model starts from, by slug\n "variants": ["crews"], // optional \u2014 its branches to merge in, in order\n "rename": { // optional \u2014 what THIS business calls each table\n "job": { "label": "\u0110\u01A1n h\xE0ng", "fields": { "code": "M\xE3 \u0111\u01A1n" } }\n },\n "entities": [ /* tables the preset does not declare */ ], // optional\n "rows": { /* first records, keyed by entity alias */ }, // optional\n "field_roles": { /* roles on the preset\'s fields and this business\'s own */ }, // optional\n "write_rules": { /* create-time clauses on the preset\'s entities and its own */ }, // optional\n "apps": [ /* the screens each app will have */ ], // optional\n "apply": [ /* published packages to copy in afterwards */ ] // optional\n}\n```\n\n**A model may not carry** `fixtures`, `knowledge` or `knowledge_expects`, and no\n`excel` / `word` / `pdf-form` template: each of those is content that lives in a\npublished bundle, which a model has none of. `apps` here is a plan of screens,\nnever built code. An unknown top-level key is an error, never ignored.\n\n### Aliases\n\nEvery `alias` is a lowercase slug \u2014 a letter, then letters, digits and\nunderscores (`unit_price`, `so_1001`). Aliases are how the file cross-references\nitself; they are never shown to anyone. `label` is what a person sees.\n\nLabels must be unique within their namespace \u2014 two entities, two fields on one\nentity, two options on one field, two views on one entity, two roles or two\ntemplates cannot share a label, because scaffold matches by label.\n\n## Entity\n\n```jsonc\n{\n "alias": "order",\n "label": "Orders", // the table\'s name, which names the SET it holds\n "singular": "Order", // optional \u2014 ONE of them, for the act that opens one\n "description": "\u2026", // optional\n "fields": [ /* at least one */ ],\n "views": [ /* optional; an entity with none still gets the default grid */ ],\n "read_scope": { /* optional; absent, everyone with access to the table reads every row */\n "any": [\n { "member_of": "sales" }, // a role alias this model declares\n { "field": "scope", "is": ["shared"] }, // a single select on this entity, by option alias\n { "field": "assignee", "is": "self" } // a select_member on this entity \u2014 the members it names\n ]\n }\n}\n```\n\n**`singular` is what a create says.** A register\'s own act and the panel it\nopens name the row being made \u2014 `New Order`, `H\u1ED3 s\u01A1 m\u1EDBi` \u2014 while `label` names\nthe table, so without it the button reads `New Orders`. Nothing derives it\n(English plurals are irregular), and most models need none: a Vietnamese noun is\nthe same word either way. `scaffold check` prints it beside the table under *Who\nwrites what* and NOTES a table that will name the set. An act on a child section\nkeeps the label: it adds a line to the register under the record the reader is\nalready standing in.\n\n**`read_scope` is a ROW rule, enforced by the platform.** A row is readable when\nANY clause holds: the reader is in that role\'s group, the named select carries\none of those options, or the named `select_member` column names the reader\n(`"is": "self"`, on a column of one member or several). It is resolved at\nscaffold 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. `any` may not be empty (a rule\nnobody satisfies hides the table), and every role alias and option alias in it\nmust be one this model declares. Scaffold 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**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 `alias`, `label`, an optional `description`, and an optional\n`required` \u2014 advisory only, read by app forms and workflows; the table itself has\nno required constraint. `label` may not contain `{` or `}` (formulas reference\nfields by label at the platform level).\n\n`default` is the value pre-filled into a NEW record. It applies on create only;\nexisting records are never backfilled. Only the types listed below accept one.\n\n### `text`\n\n```jsonc\n{ "alias": "name", "label": "Name", "type": "text",\n "unique": false, // optional \u2014 require distinct values\n "format": "text", // optional \u2014 "text" | "link" | "markdown"\n "default": "" } // optional\n```\n\n### `number`\n\n```jsonc\n{ "alias": "amount", "label": "Amount", "type": "number",\n "format": "currency", // optional \u2014 "number" | "currency" | "percentage"\n "currency": "VND", // optional \u2014 ISO 4217\n "default": 0 } // optional\n```\n\n`format` is what the number IS, and every surface reads it: `currency` prints as\nmoney in the code the row or the field states, `percentage` as a whole percent\nwith its sign. An ABSENT number is drawn absent \u2014 the one exception is a\n`sum` or count rollup the plan reads as a **`measure`**: that is the thing\naccumulated toward a bound, so nothing accumulated yet is zero and the meter\ndraws it. The same rollup read as an `amount` keeps its blank, and so does every\nother role: nothing added to what a row is WORTH means unpriced, not free. A\n`min`, an `avg`, a percentage of nothing and every FORMULA stay blank in any\nrole, because none of them has an answer to give. This is why the pair on one\nscreen reads two ways \u2014 what has come in against what is owed \u2014 and why a\nmeasure\'s own LIMIT, an amount, leaves an unquoted row out of the count rather\nthan reporting it as nothing collected. **AND WHERE THAT LIMIT IS ABSENT THERE IS\nNO LEVEL AT ALL**: a level is a reading AGAINST a bound, so a row that states no\nbound draws nothing \u2014 cell, fact and all \u2014 rather than a numerator whose whole\nmeaning was the comparison. "Collected 0" beside a blank total reads as money\nagainst a job worth nothing. A measure the model gives no limit is a plain figure\nand is unaffected. A share is stored in percent units \u2014 68.1 is 68.1 % \u2014 and the\ncolumn, the fact behind it, the meter it is judged by and the figure over the\nregister all say so.\n\n### `date`\n\n```jsonc\n{ "alias": "placed_on", "label": "Placed on", "type": "date",\n "format": "date", // optional \u2014 "date" | "datetime" | "date_range" | "datetime_range"\n "timezone": "Asia/Ho_Chi_Minh", // optional \u2014 IANA name\n "derive_from": "created_at", // optional \u2014 "created_at" | "updated_at"; makes the field read-only\n "default": "2026-01-01" } // optional; refused together with derive_from\n```\n\n### `boolean`\n\n```jsonc\n{ "alias": "paid", "label": "Paid", "type": "boolean", "default": false }\n```\n\n### `select`\n\n```jsonc\n{ "alias": "tier", "label": "Tier", "type": "select",\n "options": [ // at least one\n { "alias": "standard", "label": "Standard", "color": "slate" },\n { "alias": "gold", "label": "Gold", "color": "amber" }\n ],\n "multi": false, // optional\n "default": ["standard"] } // optional \u2014 option ALIASES; one unless multi\n```\n\n`color` is one of: `red`, `orange`, `amber`, `yellow`, `lime`, `green`,\n`emerald`, `teal`, `cyan`, `sky`, `blue`, `indigo`, `violet`, `purple`,\n`fuchsia`, `pink`, `rose`, `slate`, `gray`, `zinc`, `neutral`, `stone`.\n\n### `select_member`\n\nA person picker over the workspace\'s members. No default: a model cannot name\nmembers of a workspace that does not exist yet.\n\n```jsonc\n{ "alias": "owner", "label": "Owner", "type": "select_member", "multi": false }\n```\n\n### `select_record_link`\n\n```jsonc\n{ "alias": "customer", "label": "Customer", "type": "select_record_link",\n "target_entity": "customer", // an entity alias this model declares\n "cardinality": "one", // optional \u2014 "one" | "many" (default "many")\n "sync_both_ways": true, // optional \u2014 keep a paired field on the target\n "paired_field_alias": "orders", // the partner field ON THE TARGET entity\n "display_field_aliases": ["name"] } // optional \u2014 what the link shows / the picker\'s columns\n```\n\nA two-way link is declared on BOTH sides, each naming the other as its\n`paired_field_alias`; the pair must be symmetric or the model is refused.\n\n**A single-valued link needs no partner.** `"cardinality": "one"` on its own is a\nlink that holds one row \u2014 one customer on an invoice, one project on a device \u2014\nand nothing is created on the target. The mirror invariant belongs to a PAIRED\nlink: pair a link when the target\'s own record should list what points at it, and\nleave it unpaired when it should not. Either way the record plan draws the\nrelation as a section on the side it points at, so an unpaired link costs the\ntarget nothing.\n\n### `files`\n\n```jsonc\n{ "alias": "attachments", "label": "Attachments", "type": "files" }\n```\n\n### `formula`\n\n```jsonc\n{ "alias": "total", "label": "Total", "type": "formula",\n "formula": {\n "expression": "{amount} * 1.1", // fields on THIS entity, by alias, in braces\n "output_type": "number", // optional \u2014 what it YIELDS: "number" | "text" | "date" | "datetime" | "boolean"\n "format": "currency", // optional \u2014 how that result is DRAWN: "number" | "currency" | "percentage" | "link"\n "currency": "VND" // optional\n } }\n```\n\n`output_type` is what the expression answers WITH; `format` is how it is printed.\nThe platform infers the result at write time and ignores what you declare, so\nthis is a statement the offline checks read \u2014 which is what lets a role or a\nscreen clause accept a computed value: a caption over a derived name\n(`output_type: "text"`), a period over a settled date (`"date"`). A formula\ndeclaring neither says nothing about its result, and every rule that needs one\nrefuses it by name. `lotics scaffold export` writes the result a live workspace\ncomputed, so exporting a workspace fills these in.\n\n### `rollup`\n\nAggregates the records reached through a link on this entity.\n\n```jsonc\n{ "alias": "total_ordered", "label": "Total ordered", "type": "rollup",\n "source_field_alias": "orders", // a select_record_link field on THIS entity\n "aggregate_option": {\n "operation": "sum", // count | sum | avg | median | min | max | range |\n // empty | filled | percent_empty | percent_filled |\n // unique | percent_unique |\n // earliest | latest | date_range |\n // checked | unchecked | percent_checked |\n // percent_unchecked\n "field_key": "amount" // a field ALIAS on the linked entity ("count" may omit it)\n },\n "filter": { /* optional \u2014 see Views; every field_key is an alias on the LINKED entity */ } }\n```\n\nThe operation must be one the aggregated field\'s type allows \u2014 `sum` over a\nnumber, `earliest` over a date, `filled` over anything.\n\n### `lookup`\n\nDisplays a field from the linked records.\n\n```jsonc\n{ "alias": "customer_tier", "label": "Customer tier", "type": "lookup",\n "source_field_alias": "customer", // a select_record_link field on THIS entity\n "lookup_field_alias": "tier", // a field alias on the linked entity\n "order_by": { "field_key": "placed_on", "direction": "desc" } } // optional \u2014 pick the single extreme row\n```\n\n### `autonumber`\n\n```jsonc\n{ "alias": "seq", "label": "No.", "type": "autonumber",\n "prefix": "SO-", // optional \u2014 ignored when template is set\n "padding": 4, // optional \u2014 1..20, zero-pads the integer\n "template": "SO-{YEAR}-{N:4}" } // optional \u2014 {N}, {N:W}, {YEAR}, {YEAR:2}, {MONTH}, {DAY}\n```\n\n## Views\n\nSaved views live under the entity they belong to. Every field reference is a\nfield ALIAS on that entity.\n\n```jsonc\n{\n "alias": "gold",\n "label": "Gold customers",\n "description": "\u2026", // optional\n "columns": [ // optional \u2014 omit to show every field\n { "field_alias": "name", "visibility": "visible", "width": 240 },\n { "field_alias": "tier", "visibility": "hidden" }\n ],\n "filters": { // optional\n "node_type": "group",\n "logic": "and", // "and" | "or"\n "children": [\n { "node_type": "condition", "type": "select", "field_key": "tier",\n "operator": "has_any_of", "value": ["gold"] }\n ]\n },\n "sort": [ { "field_key": "name", "order": "asc" } ], // optional; order is "asc" | "desc" | null\n "summary": { "amount": "sum" }, // optional \u2014 field alias \u2192 footer operation\n "frozen_columns": 1 // optional\n}\n```\n\nA condition\'s `type` is the field\'s type and its `operator` is one that type\nadmits \u2014 `has_any_of` / `has_none_of` / `has_all_of` / `is_empty` /\n`is_not_empty` for a select, `equals` / `greater_than` / `less_than` for a\nnumber, `on` / `before` / `after` / `between` for a date, `contains` /\n`is_any_of` for text. A select condition\'s `value` names option ALIASES.\n\n`columns`, when present, is exhaustive and must not be empty: a view renders\nexactly the entries it holds. Omit the key to show every field.\n\n## Roles\n\nA role becomes a workspace group. Members are added afterwards, in the app.\n\n```jsonc\n{ "alias": "sales", "label": "Sales" }\n```\n\n## Templates\n\nOnly inline `html` and `email` templates \u2014 the rest are file-backed and a model\nhas no bytes. An `html` template renders to a PDF when a workflow generates\nfrom it; `{{name}}` is filled from the workflow\'s data.\n\n```jsonc\n{ "alias": "order_ack", "label": "Order acknowledgement", "type": "email",\n "content": "<p>Hello {{customer}}\u2026</p>" }\n```\n\nA paper that has to look like a counterparty produced it \u2014 an official letter,\nan acceptance minute, a supplier\'s bill \u2014 is the same `html` template with a\nshell around the body: a letterhead, a reference line, a seal and a signature\nblock, and paper grain over everything. One shell, many bodies; the data is the\nonly thing that changes, so a workflow can re-issue it over any record.\n\n```jsonc\n{ "alias": "cong_van", "label": "C\xF4ng v\u0103n", "type": "html",\n "content": "\u2026the page below, as one JSON string\u2026" }\n```\n\n```html\n<style>\n .sheet{position:relative;width:718px;padding:44px 58px 30px;background:#fbfaf6;color:#111;font:14.2px/1.5 \'Liberation Serif\',serif}\n .grain{position:absolute;inset:0;opacity:.34;mix-blend-mode:multiply;background:url("data:image/svg+xml;utf8,<svg xmlns=\'http://www.w3.org/2000/svg\' width=\'140\' height=\'140\'><filter id=\'f\'><feTurbulence baseFrequency=\'.9\' numOctaves=\'2\'/><feColorMatrix values=\'0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 .35 0\'/></filter><rect width=\'140\' height=\'140\' filter=\'url(%23f)\'/></svg>")}\n .top{display:flex;text-align:center;font-size:13.4px} .top>div{flex:1} .u{display:inline-block;border-bottom:1px solid #111;font-weight:700}\n .ref{display:flex;text-align:center;font-size:13.4px;margin-top:6px} .ref>div{flex:1} .ref .r{font-style:italic}\n h1{text-align:center;font-size:15.6px;margin:26px 0 18px} p{text-align:justify;text-indent:26px;margin:0 0 9px}\n .sig{display:flex;margin-top:20px} .sig .l{flex:1} .sig .r{width:290px;text-align:center;position:relative}\n .sig .nm{font-weight:700;margin-top:96px} .seal{position:absolute;left:4px;top:8px;width:166px;height:166px;opacity:.66;mix-blend-mode:multiply;transform:rotate(-17deg)}\n </style>\n <div class=\'sheet\'><div class=\'grain\'></div>\n <div class=\'top\'><div><b>{{issuer_parent}}</b><br><span class=\'u\'>{{issuer}}</span></div>\n <div><b>C\u1ED8NG H\xD2A X\xC3 H\u1ED8I CH\u1EE6 NGH\u0128A VI\u1EC6T NAM</b><br><span class=\'u\'>\u0110\u1ED9c l\u1EADp - T\u1EF1 do - H\u1EA1nh ph\xFAc</span></div></div>\n <div class=\'ref\'><div>S\u1ED1: {{number}}</div><div class=\'r\'>{{place}}, ng\xE0y {{day}} th\xE1ng {{month}} n\u0103m {{year}}</div></div>\n <h1>{{title}}</h1>\n <p>K\xEDnh g\u1EEDi: {{recipient}}.</p>\n {{{body}}}\n <div class=\'sig\'><div class=\'l\'><b>N\u01A1i nh\u1EADn:</b><br>- Nh\u01B0 tr\xEAn;<br>- L\u01B0u VT.</div>\n <div class=\'r\'><img class=\'seal\' src=\'{{seal_url}}\'><b>{{signer_title}}</b><div class=\'nm\'>{{signer}}</div></div></div>\n </div>\n```\n\n`lotics preview <file.html>` renders any such page to a PNG the way a demo\'s\nprops are made, sized to its content, so a paper can be looked at before it is\nput in a template.\n\n## Rows\n\nFirst records, keyed by entity alias. Up to 200 rows per entity and 2000 across\nthe model, attaching at most 2000 documents between them \u2014 a real data set\nbelongs in an import, not a model.\n\n```jsonc\n"rows": {\n "customer": [\n { "ref": "acme", "fields": { "name": "Acme Trading", "tier": "gold" } }\n ]\n}\n```\n\n`ref` is a local handle (lowercase letters, digits, underscores) that other rows\'\nlink fields address. It is never persisted.\n\nA `files` cell attaches documents: paths relative to this file (no `..`, never\nabsolute), which `check` proves exist and `apply` uploads into the workspace\nbefore any row is written \u2014 a paperwork business seeds its papers with its\nrows. The server accepts only `fil_` ids of files this workspace owns, which is\nwhat the upload leaves behind. After a run that wrote rows, 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 |\n| `select_member` | `"self"` only \u2014 the person applying the model |\n| `files` | paths beside this file \u2014 `["scans/pccc_letter.png"]` \u2014 uploaded by `apply`/`setup` before the rows are posted; or `fil_` ids of files already in this workspace |\n| `formula`, `rollup`, `lookup`, `autonumber` | not allowed \u2014 the platform writes these |\n\n**Documents can be attached on their own, afterwards.** Rows land only into empty\ntables, so a model whose binaries were added after the first apply has no second\napply to carry them: `lotics scaffold apply model.json --documents` writes ONLY\nthe `files` cells, onto the records the first run created, found by each row\'s\nown `ref` in the binding the workspace holds \u2014 so a row added to or removed from\nthe file since changes nothing about the rest. Running it twice attaches nothing\nthe second time: the same path keeps the same file, and a cell is a set.\n\n### Relative dates\n\nA date cell holds a literal `YYYY-MM-DD`, or an expression relative to the day\nthe model is applied, so a screen that opens on "this month" is not empty a month\nlater:\n\n- `@today` \u2014 the day of the run, in the workspace\'s timezone\n- `@month-start` \u2014 the 1st of that month\n- either with a whole-day offset: `@today-14`, `@month-start+9`\n\n`@month-start` exists because `@today-N` cannot promise a month: applied on the\n2nd, `@today-3` lands in the previous one.\n\n## Field roles\n\n`field_roles` names the reporting role a field plays on its entity \u2014 keyed by\nentity alias, then field alias \u2014 so every screen over the entity agrees on\nwhich column names the row and which select is the stage. A shape\'s slot binds\nto it (\xA7 Apps and screens). Like `rows` and `apps`, it is this file\'s: `check`\nproves it and the workspace never sees it. Each role sits on the types that can\nanswer it:\n\n| Role | On | Meaning |\n|---|---|---|\n| `identity` | `text`, `select_record_link`, `formula` | what a PERSON calls the row \u2014 the register\'s first column; a link where the row is "the product, at this branch"; a formula where the name is computed, and then it may not be `format`ted as a figure. NEVER an autonumber: a minted code is a `reference`. One per entity |\n| `reference` | `text`, `autonumber`, `formula` | the key the SYSTEM files the row under \u2014 an order number, a container code \u2014 drawn as the supporting line under the name rather than as a column of its own, and leading where the entity has no `identity` at all. One per entity |\n| `mark` | `files`, or a `lookup`/`rollup` that resolves to one | the row\'s picture \u2014 its own, or the linked record\'s. NEVER A COLUMN: a register draws words, so a mark is projected only where a surface leads with one \u2014 the subject rung of a picture-led grid, and a record\'s itinerary. A child entity\'s mark reaches a record\'s run and nothing else. One per entity |\n| `lifecycle` | single `select` | the ordered stages a row walks; option order is the order. Which of them END the flow is `outcomes`, which parts of the flow they group into is `phases`, and `history` keeps the day each one was reached. One per entity |\n| `category` | single `select`, or a `lookup` that resolves to one | what the row IS \u2014 a kind, a service, a book. One badge in the field\'s own colour, with no ladder behind it and no flow to advance. Often a fact about the thing the row books rather than about the row, and then it is the lookup that reads it. Repeats: a row can be of two kinds of thing |\n| `measure` | `number`, `formula`, `rollup` | a level read against a limit \u2014 see `against` and `alert`. Money where the model says so, and then it prints as money |\n| `expected_set` | `select` | its OPTIONS are the required set (documents, checks, services); an option no row has is a gap to show, not nothing. A multi-select draws as a ring and a fraction wherever it is drawn \u2014 absence is the information, and a list of what IS there says nothing about what is missing |\n| `amount` | `number`, `formula`, `rollup` | THE money of a ledger row; `signed_by` says which way it moved, `against` names the reference it is quoted off. One per entity |\n| `when` | `date`, or a `formula`/`rollup`/`lookup` that yields one | the ledger or timeline date \u2014 stated, or derived: the day money MOVED is a formula over the two columns that could hold it. `due` makes it a DEADLINE; without it the date is the plain day it is. `until` names the stage of this entity\'s own `lifecycle` at which the countdown is spent, and says `due` by saying so. One per entity |\n| `slot` | single `select`, or a `datetime` `date` | where in the DAY a row sits \u2014 the run it is read in under its day\'s heading on an itinerary. A select\'s option order IS the run\'s order; a time is ordered by the clock. A plain `date` is refused: that is the day itself. One per entity |\n| `party` | `select_record_link`, `select_member` | WHO THE ROW IS FOR \u2014 the counterparty it was transacted with, or the workspace MEMBER whose work it is (a shift, an assignment, a timesheet period). A member is drawn as the person and opens nothing: they have no record in the app. One per entity \u2014 and left off where the `identity` IS the link to them: the row is then NAMED by that record, and a strip under the title would state them twice |\n| `parent` | `select_record_link` | the record this row belongs to \u2014 a line\'s order, a paper\'s case. The parent\'s record shows these rows; the row shows the parent as a fact. One per entity, and it links to an entity this model declares |\n| `contact` | `text` | a way to reach a party \u2014 an address, a number, the town it is in. The record draws them as ONE row of links under the name, so the role sits on every field that is one of them. Repeats |\n| `currency` | single `select` whose option LABELS are ISO 4217 codes | the money THIS ROW\'s figures are in. Every amount and every money measure on the entity is then printed per row rather than in one code for the whole column, and the select is not drawn as a fact of its own. One per entity |\n| `verdict` | `boolean`, `formula` | a settled pass/fail \u2014 ticked, or computed. TRUE is the PASSING side wherever it is drawn, a register\'s risk flag included, so a field whose true means trouble is the same fact asked the other way round. One per entity |\n| `obligation` | `date` | a day something is OWED by \u2014 a cut-off, a permit expiry, a payment due. It counts down by definition. `satisfied_by` names what CLOSES it (or `until`, the stage the record stops owing it at), `label` what is owed. Repeats: a row owes several things, and a shape that fans them draws one row each |\n| `origin` | `text`, single `select`, or a one-row `select_record_link` | where the row STARTS. Declared with a `destination` \u2014 a route has two ends, and one alone is refused \u2014 and then every surface over the entity draws the two as ONE reading, `Warehouse 4 \u2192 Site B`: on the register\'s supporting line after the key, and as one column on the child registers and party histories that list these rows. The two fields are then not facts of their own, and a `columns` clause naming either end is refused. One per entity, holding ONE place |\n| `destination` | `text`, single `select`, or a one-row `select_record_link` | where the row ENDS \u2014 the other half of the `origin`\'s route, and read only with it. One per entity, holding ONE place |\n\n**`due` is what makes a `when` a deadline.** A date a row is merely filed by \u2014 the\nday a lead arrived, the day a movement happened \u2014 is neither early nor late,\nhowever long ago it was. Without the clause every desk drew every `when` as a\ncountdown and a two-month-old lead read "40 days overdue" on a record nobody was\nlate on. So a bare `when` is a plain date, and `{ "role": "when", "due": true }`\nis the one that counts down. An `obligation` needs no clause: a day something is\nowed by is a deadline already.\n\n**`until` stops a countdown where the work is DONE.** A deadline counts down so\nsomebody acts on it, and a row that has arrived needs nothing: without the clause\na delivered order is tinted for a date it met, and every settled row past its day\njoins the urgent ones in the reader\'s glance. It names one option of the entity\'s\n`lifecycle`; at that stage, at every stage past it and at every `outcome`, the\ndate is drawn as the plain day it is. On a `when` it also says the date is `due`.\n\n"One per entity" is one COLUMN, never one value on screen. A second counterparty\nor a second parent stays a ROLELESS `select_record_link`: which field fills a\nslot is the screen\'s answer (\xA7 Apps and screens), and the extra link is a fact on\nthe row with its own section on the record, headed by that link\'s own label. A\nrole naming two fields would move the ambiguity into every shape that reads it.\n\nA COMPUTED field is admitted where its RESULT is what the role needs, and\nrefused where it is not: `identity` and `reference` need `text`, `when` a `date`,\n`mark` `files`. A formula\'s result is its `output_type`; a rollup\'s and a\nlookup\'s is read through the walk. So a formula yielding a number is refused in a\nname\'s place, a lookup landing on anything but `files` is refused as a `mark`, and\na formula declaring NO result is refused by all four \u2014 naming `output_type` as\nthe remedy, because a value drawn as a type nobody stated is the wrong cell with\nnothing saying so. The result also decides the DEVICE every screen draws the value\nwith: a rollup taking the `latest` of a set of dates is drawn as a date, a lookup\nof a select as the words it holds.\n\nA bare role name is the common form. A role that is read against a SECOND field\ntakes the object form:\n\n- **`measure`** names its limit: `against` \u2014 a constant, or a field on the same\n entity the row states as a number (a `number`, or a formula or rollup whose\n result is one) \u2014 and `alert`, which side of it needs attention, `over` a\n capacity or `under` a minimum. The two come together.\n- **`measure`** names what its number COUNTS, where the unit changes how the row\n is DRAWN rather than how the figure reads: `counts: "days"` spans a stop across\n that many days of a run, so a three-night stay fills three of its days instead\n of only the one it starts on, and `counts: "hours"` is how long a stop takes\n INSIDE its day, stated beside it. Stated, never inferred \u2014 nothing about a `3`\n says whether it is nights, pallets or hours, and a quantity drawn across a week\n is a run nobody can read.\n- **`lifecycle`** names the stages that END the flow: `outcomes`, option aliases\n of that select. A row in one has ARRIVED \u2014 delivered, cancelled, written off \u2014\n so a desk draws them apart from the ladder instead of chaining them after each\n other. Not every option may be an outcome, and terminal-ness belongs here, never\n written into a stage\'s label.\n- **`lifecycle`** groups its stages into the parts of the flow they belong to:\n `phases`, each a `label` and the `stages` under it. Nine rungs down one column\n is a list nobody reads as a flow; the same rungs under three headings are three\n pieces of work, and which belong together is the business\'s answer. Every stage\n but an `outcome` falls in exactly one \u2014 a stage in two is drawn twice, a stage\n in none vanishes off the ladder \u2014 and the outcomes may be placed in a phase or\n left out, and are then read as a last part of their own.\n- **`lifecycle`** keeps every stage the row reached: `history: true`, or\n `history: "<alias>"` to name the table. A ladder with no history reports where\n a row IS and nothing about how it got there, which is the whole of what a\n reader asks a pipeline. It is declared BESIDE `phases`, and refused without\n them: the rungs a ladder draws are the live select\'s options, which nothing\n reading a file or an app\'s spec can see, so `phases` is the one statement of\n what the dates have to cover \u2014 and without it a rung the history cannot reach\n draws undated on every record with nothing anywhere saying so. The flag is the\n ONE statement: the model DERIVES a\n child table from it \u2014 the link back to this record, the stage, the moment it\n was reached and who moved it \u2014 and `check`, `apply`, `diff` and the app\n generator all see that table exactly as they see a written one. Nothing else\n declares it, and a file that also writes it by hand is refused. The generated\n create stamps the rung the flow opens at and the generated editor stamps every\n move after it; a stage changed anywhere ELSE is a rung with no date\n (`docs/platform_gaps.md`). **A first apply backfills nothing** \u2014 `apply` writes\n rows only into empty tables, and rows that existed before the flag walked their\n stages before that table existed. The honest backfill is ONE opening row per\n existing parent, at its FIRST stage, dated when that parent was created (true\n by construction); the stage it stands on NOW is not dated unless that is also\n the first. It is a create per parent \u2014 `lotics run create_records` \u2014 and\n `scaffold apply` prints it as the work it did not do.\n- **`amount`** names the REFERENCE it is read against: `against`, a field on the\n same entity the row states as a number. A price against the list it is quoted\n off, a rate against the going one \u2014 and the catalogue item\'s record draws the\n pair on one axis with the gap said in words. Never a constant (a price typed\n into the model ages with nothing to update it) and never an `alert`: a limit is\n a ceiling a row can pass, and beating a reference is the point.\n- **`amount`** names the select that SIGNS it: `signed_by`, a select on the same\n entity, and `outflow`, the options of that select under which the amount is\n money going out. Without it every shape over the ledger adds both directions\n together: a month whose net was small reads as the total of everything that\n moved, and the trend plots one rising line. The two come together, and\n `outflow` is some of that select\'s options, never all.\n- **`expected_set`** names what CONDITIONS the set: `required_by`, a field on the\n same entity, and `options`, which of that field\'s values require which entries.\n A row whose value is not named requires nothing. Without it the denominator is\n every option, so a set whose entries are mutually exclusive by kind reads\n `1 of 4` on every complete row \u2014 a denominator no row can reach is worse than\n no role. The field is a single `select`, keyed by its option aliases \u2014 or a\n YES/NO the row answers itself (a `boolean`, or a formula resolving to one),\n keyed by `"true"` and `"false"`: a check that FAILED owes its consequence \u2014 the\n photograph, the owner, the day it is due \u2014 and there is no select of the record\n that says so. `from: "parent"` reads that field off the record these rows hang\n under instead, through this entity\'s own `parent` link: which documents an order\n owes is the ORDER\'s type, and the papers carry no column that says it. Which of\n the two a set takes is the set\'s own shape and not a choice \u2014 a multi-select\n holds the whole set on one row and is narrowed by that row (`from` omitted), a\n single select is one entry per row and takes `from: "parent"` \u2014 and the other\n way round is refused rather than left conditioning nothing.\n- **`obligation`** names what ENDS it: `satisfied_by`, a `date` or a `files` field\n on the same entity \u2014 the day it was done, or the paper that proves it \u2014 or,\n where the business stamps nothing, `until`, the stage of this entity\'s\n `lifecycle` at which the record stops owing it: a deposit is owed until the\n order is paid, and nobody records the act of paying twice. One or the other,\n never neither \u2014 without one every obligation the business ever met stays on the\n desk. Optionally `label`, what is OWED as the reader says it ("G\u1EEDi SI"), which\n is not what the column holding the date is called ("SI cut-off").\n\n```jsonc\n"field_roles": {\n "product": { "name": "identity", "photo": "mark" },\n "stock": { "on_hand": { "role": "measure", "against": "minimum", "alert": "under" },\n "uptime": { "role": "measure", "against": 80, "alert": "under" } },\n "order": { "stage": { "role": "lifecycle", "outcomes": ["delivered", "cancelled"],\n // Two pieces of work, and every step dated.\n "phases": [{ "label": "Taking it on", "stages": ["placed", "confirmed"] },\n { "label": "Getting it out", "stages": ["picked", "shipped"] }],\n "history": true },\n // A DEADLINE COUNTS DOWN \u2014 `payment.paid_on` below is a plain day.\n "ship_by": { "role": "when", "due": true },\n // The day the papers are owed by, and the day they went.\n "papers_due": { "role": "obligation", "label": "File the papers", "satisfied_by": "papers_sent" },\n // Nothing stamps a deposit paid \u2014 the order\'s own stage ends it.\n "deposit_due": { "role": "obligation", "label": "Take the deposit", "until": "delivered" } },\n // WHICH PAPERS THIS ONE OWES IS THE ORDER\'S OWN TYPE, and no document says it.\n "document": { "order": "parent",\n "kind": { "role": "expected_set",\n "required_by": { "from": "parent", "field": "kind",\n "options": { "export": ["invoice", "customs"] } } } },\n // `kind` is the direction select \u2014 options `received` and `paid_out`, no role of its own.\n // Every figure on a quotation is in the currency that row names.\n "quote": { "unit": "currency", "total": "amount" },\n // What the price is quoted off, so the item\'s page reads one against the other.\n "service": { "price": { "role": "amount", "against": "list_price" },\n "kind": "category" },\n // The day the money moved is neither early nor late, so it stays a bare `when`.\n "payment": { "paid_on": "when",\n "value": { "role": "amount", "signed_by": "kind", "outflow": ["paid_out"] },\n // A receipt owes a receipt voucher; a payment owes an invoice and a payment voucher.\n "papers": { "role": "expected_set",\n "required_by": { "field": "kind",\n "options": { "received": ["receipt_voucher"],\n "paid_out": ["invoice", "payment_voucher"] } } } },\n // A CHECK THAT FAILED OWES ITS CONSEQUENCE \u2014 keyed by the row\'s own answer,\n // and the answer that owes nothing is omitted rather than named with an empty list.\n "check": { "passed": "verdict",\n "evidence": { "role": "expected_set",\n "required_by": { "field": "passed",\n "options": { "false": ["photo", "owner", "due"] } } } }\n}\n```\n\nIn a file that starts from a preset (\xA7 Starting from a preset), `field_roles`\nmay name the preset\'s fields as well as this business\'s own; a role the preset\ndeclares itself is kept unless this file names the same field, and `null`\nclears it.\n\n## Write rules\n\n`write_rules` is what only a CREATE meets \u2014 keyed by entity alias, like\n`field_roles`, and this file\'s in the same way: `check` proves it and the\nworkspace never sees it. An update names one field that moved; a create takes a\ndraft whole, so it has to know which row a name already belongs to, where a\nvalue comes from when nobody types it, and what a figure or a picker may hold.\nThe generator writes one `create_<entity>` workflow and one `New<Entity>Dialog`\nper entity an app can operate, from these clauses and the fields\' own\n`required`.\n\n```jsonc\n"write_rules": {\n // A customer is RECOGNISED by their address. Where an entity names a customer\n // as its `party`, the create takes the email instead of a picker, reuses the\n // row it matches and mints one only where nothing does \u2014 so the book never\n // grows a second Acme because somebody typed the name differently.\n "customer": { "natural_key": ["email"] },\n "order_line": {\n "fields": {\n // A line of nothing is not a line. Refused on create and on update, at\n // the control the figure was typed into.\n "quantity": { "min": 1, "max": 9999 },\n // The price is fixed at the moment of ordering \u2014 COPIED off the product,\n // not looked up for ever after, so the price list moving next week does\n // not silently reprice an order already placed.\n "unit_price": { "default_from": "product.price" },\n // And nothing is sold off an empty shelf. The picker reads only the rows\n // that answer this, and the write refuses the same rows again \u2014 a caller\n // who never opened the picker is bound by it too.\n "product": {\n "options_where": {\n "node_type": "group", "logic": "and",\n "children": [{ "node_type": "condition", "type": "number",\n "field_key": "in_stock", "operator": "greater_than", "value": 0 }]\n }\n }\n }\n }\n}\n```\n\n| Clause | On | Meaning |\n|---|---|---|\n| `natural_key` | the entity | the field aliases a row is recognised by. A `text` key declares `unique: true` on the field itself \u2014 two rows sharing it would make find-or-create pick whichever the read answered first \u2014 and a key is `text` or `number`, because a person types it back |\n| `default_from` | a field | `"<link alias>.<field alias>"` \u2014 the value is copied from the linked row when the row is created, never asked. The link is a one-row link on this entity and `required`, because there has to be a row to read, and the two field types must match |\n| `min` / `max` | a `number` | the figure is refused outside them, on create and on update |\n| `options_where` | a `select_record_link` | an `and` group of plain conditions over the TARGET\'s own fields, each `field_key` a field alias. It narrows the picker\'s read and is refused again where the write lands, so the link is `required` |\n\nA field\'s own `required` is not here: the contract carries it, every write path\nrefuses the row by field name from it, and the generated panel stands its commit\ndown until the same set is filled. `unique` is the text field\'s own clause\n(\xA7 `text`) \u2014 the create says so at the control before the column does.\n\nWhat a create asks is what a person STATES. A `default_from` field is not drawn,\na `lifecycle` opens at its first rung, the stamp an obligation\'s `satisfied_by`\nnames is not asked (the row is only now taking that on), and files are attached\nto the row afterwards \u2014 one fact, one place. `scaffold check` prints every clause\nunder its entity in **Who writes what**.\n\n**The `parent` is prefilled only in the door that mounts the act.** A row opened\nfrom the record it hangs under takes that record from the door it was pressed\nin, so the link is neither asked for nor drawn. The same entity\'s act on its own\nregister has no such row, so there the parent is an ordinary picker the draft\ncannot be committed without \u2014 a payment opened from a money app belongs to a\ncase either way.\n\n## Apps and screens\n\n`apps` is the plan: each app the reader will build, as ONE REGISTER \u2014 a SHAPE\nover an ENTITY \u2014 under `screen`. Nothing here is built by the scaffold: the plan\nis what `lotics scaffold check` prints back, register by register with the field\nin every slot, so it is read and corrected before a screen exists.\n\n**THE PRINTOUT EXPLAINS ITSELF.** Everything the shape and the roles answered by\nthemselves is marked `(default)` \u2014 the door, the tab strip, the band\'s figures,\nwhat the rows lead with, how much room they take, which end the register is read\nfrom, the set a member lens opens on, how many rows a capped history stands at \u2014\nso a clause nobody wrote is never read back as one somebody did. And\n`lotics scaffold check --why` prints the RULE behind every binding and every\nsection, indented under the line it explains: which field filled a slot and what\nit was chosen from, why the record opens where it does, why a child\'s rows are a\njournal rather than a register, why a history is capped and a relation counted.\nEvery sentence is the derivation\'s own, so what the printout explains is what\ngets built.\n\n**THREE THINGS DECIDE EVERY SCREEN AND EVERY RECORD**: the SHAPE, whose slots are\nthe table below; the ROLES the entities declare (\xA7 Field roles), which fill those\nslots and decide what each child\'s rows become; and the CLAUSES, which are where\nthe plan overrides what the two would answer. This index says which is which, so\na clause is reached for only where the first two have no answer.\n\n**What a child\'s rows become.** One section per LINK into the record, and what\nthe CHILD declares decides its kind \u2014 the first line it answers wins.\n\n| Printed as | The child declares | The reader gets |\n|---|---|---|\n| `documents:` | an `expected_set` select, one entry per row | the desk of what is owed, each row\'s files attached to it |\n| `timeline:` | an `obligation` closed by a DATE, on rows with no `lifecycle` of their own | the ordered stops, the promised day against the day it happened |\n| `thread:` | a `markdown` body, a `party` and a `when` | the correspondence, its answer, and whose move it is |\n| `journal:` | a `markdown` body and a day, on rows a record OWNS (`parent`) with no `lifecycle` | the log \u2014 each entry read in full under its day, its selects beside it, and a composer that writes the next one |\n| `itinerary:` | a `when` and a `lifecycle`, on rows a JOB\'s record OWNS | the day heads the run, one line per stop, the sum under it |\n| `lines:` | anything else the record owns (`parent`) | the register of the rows it is made of |\n| `history:` | anything else NAMED BY it \u2014 the link is their `party` or their `identity` | the same rows read as that record\'s own history \u2014 on a PROFILE, every one of them stands open at its latest 5, headed by how many there are in all, with the rest a press away |\n| `via <link>:` | a link carrying neither role | the register, headed by that link\'s own label |\n| `related:` | rows of an entity this app lists NOWHERE | one counted line, and a press that opens their register |\n\nA child carrying the BODY and the party who wrote it, and no day, is a plain\nregister, and `scaffold check` names the declaration it is one short of rather\nthan letting it fall through silently. A `party` beside a `when` and no body is\nnot a near miss \u2014 that is every ledger line there is.\n\n**A LOG\'S DAY is the child\'s `when`, unless that `when` counts down** (`due`,\n`until`): a deadline is the day an answer is OWED, so a log stacked on it would\nput the entries nobody has chased at the top. Then \u2014 and where the child declares\nno `when` at all \u2014 the day is the first `date` no role claims. A child carrying a\n`lifecycle` is never a log, on the same rule a run of stops reads: it is a thing\nbeing WORKED, and the prose on it is a note about the job.\n\n**A child\'s columns read, and are given up, in one order**: identity \xB7 when \xB7\nlifecycle \xB7 category \xB7 amount \xB7 measure \xB7 expected_set \xB7 party \xB7 contact \xB7\nverdict. The DAY outranks the counterparty because a list of rows belonging to\none record is scanned by when \u2014 the counterparty repeats on every row, and on a\nlog there is none. Ranked the other way, a narrow register shed the date and kept\na column that said the same thing on every line.\n\n**A section names its link where two of them would read alike.** One child can\nreach a record through several links and land under the same heading twice, which\nis the same sentence with nothing saying which rows either holds \u2014 so each of\nthose says the link it is read through (`timeline: Stops via Billed to`), and a\nchild reaching the record once is named by the rows it lists.\n\n**The clauses, by name.** On the APP: `scope`. On the `screen`: `record`, `tabs`,\n`slots` (`roles` on a `custom` one), `columns`, `writes` (`false`, or\n`"children"` for the rows a record owns and not the record), `period`, `filters`,\n`summary` (`above`, `totals`, `ageing`, `trend`), `facts`, `presentation`\n(`lead`, `density`, `layout`, `until`), `acts` (`row`, `record`, `selection`,\n`export`, `import`), `section_acts`, `sections`. Every one is optional, every one\nis printed back by `scaffold check`, and a screen that states none gets what its\nshape and its roles answer by themselves.\n\n**ONE APP IS ONE REGISTER AND THE RECORDS IT OPENS.** A second register beside\nit is a second job on one page: the reader arrives on whichever the nav listed\nfirst and decides, every time, which of the two they came for. So an app has one\ndestination and everything else about a row is DISCLOSED by opening it \u2014 the\nfacts by tier, the children as lists, the related as counts, one primary act, and\na filter or a group where a strip of destinations would have been. What used to\nbe a second screen is a section of the record, or another app; a model still\nsaying `screens` is refused by name.\n\n**The unit of an app is a JOB, not a person and not a table.** A job has its\nown outcome (something exists or is settled when it is done), its own subject\n(the record it advances), and an end that does not wait on the rest of the\nwork. Two tasks are ONE job when neither finishes without the other and both\nadvance the same record. One app per job, and that is what makes an app a\nwrite-ownership boundary: what the job settles is its app\'s alone to write,\nplus everything it must read to settle it well. A person holding several jobs\nopens several apps \u2014 one app for everything one person does is the dump. A step\nof the job is a section of the record, never a register beside it; a reference\nthe job needs at hand is read inside the record it is about.\n\n**Two people signing is two jobs**, because the outcome belongs to the signer:\nthe desk that prepares against the gate that releases. So is a different\ncadence \u2014 reference data edited monthly and read by the public site, beside a\ndaily desk \u2014 and a different reader, the owner\'s read-only questions. Device,\nplace and step never split a job. A super app holds more than one job; an\nover-split holds less than one, a job cut by device, place or step. The count\nper workspace falls out of the jobs, typically two to six, and is never the\ninput: review the plan app by app, naming who holds it, the job in one\nsentence, what it writes and what it reads.\n\n**A party is not a job.** The people and companies a job\'s rows name \u2014 the\ncustomers, the suppliers, the carriers \u2014 are not a job of their own, however\nmany rows name them: nobody\'s day is "the customer book". They live inside the\napp whose rows name them: a create takes the party by its natural key, the row\'s\nname opens the party\'s own record (a profile \u2014 how to reach them, what is live\nwith them, their history, their journal), and its facts are edited there. A\nparty earns an app of its own only where someone\'s job IS the relationship, with\nan outcome of its own \u2014 an account plan signed, a supplier qualified \u2014 never\n"keeping the book". Two apps for one seller, the deals and the customers, is the\ndump cut in two.\n\n*A two-van appliance repair shop.* The technician\'s job is the call-out: the\noutcome is a visit done, the subject is the call-out record, and quoting it and\nscheduling it are one job, because neither finishes without the other and both\nadvance that record. The owner holds two of his own \u2014 billing the month\n(outcome invoiced, subject the invoice, settled against visits already closed)\nand the price list the booking page quotes from, edited monthly. Three jobs,\nthree apps, and the owner opens two of them.\n\n**Inside a job the caps hold**: six flow stages on a desk, seven facts before a\nrecord\'s fold. Past them, look for the second job hiding inside. And a job\'s app\nis DENSE: every create the job needs lives in it, so its user never leaves it to\ncorrect a figure the screen in front of them is stating \u2014 three workflows is a\nscreen, not a desk.\n\n**A handoff is a stage change.** Where one job ends the record moves stage and\nthe next job\'s desk opens on it; a field two jobs must both write is declared\nshared at the split, naming the stage each may write it in.\n\n**Name an app for the JOB, in the trade\'s own words, never for who it is for.**\nA title on the door says nothing about what the person who opened it came to\ndo, and it is wrong the day the org chart moves.\n\n```jsonc\n"apps": [\n {\n "alias": "sales", "name": "Sales",\n "description": "\u2026", "icon": "briefcase", "theme": { "color": "blue" }, // optional\n "screen":\n { "alias": "orders", "label": "Orders", "shape": "lifecycle_desk", "entity": "order",\n "record": "drawer", // optional \u2014 "drawer" | "page"; absent, the shape decides\n "tabs": "stage", // optional \u2014 the entity\'s lifecycle, or null; absent, the shape decides. Any other select is a filter\n "writes": false, // optional \u2014 default TRUE; false is a reader, "children" operates only the rows the record owns\n\n "period": "due_date", // optional \u2014 any field whose value is a date, derived ones included\n "filters": ["kind", // optional \u2014 a single-select, or a lens the model states itself\n { "label": "Qu\xE1 h\u1EA1n l\u01B0u", "predicates": [\n { "label": "\u0110\xE3 qu\xE1", "tone": "red",\n "where": { "node_type": "group", "logic": "and", "children": [\n { "node_type": "condition", "field_key": "owed", "operator": "greater_than", "value": 0 }] } }] }],\n "summary": { "totals": ["total"], "above": ["total", { "field": "owed", "at": "end" }],\n "ageing": { "amount": "owed", "due": "due_date", "buckets": [30, 60, 90] },\n "trend": { "field": "total", "direction": "up" } }, // optional \u2014 see below\n "facts": { "groups": [{ "caption": "Pricing", "fields": ["rate", "surcharge"] }] }, // optional \u2014 the record\'s named bands\n "presentation": { "lead": "none", "density": "dense" }, // optional \u2014 how it is DRAWN; absent, what the rows are decides\n "acts": { "row": [{ "label": "Issue the note", "template": "debit_note", "place": "cta" },\n { "kind": "agent", "label": "Read the papers", "agent": "reader", "fills": ["ref", "due_date"] },\n { "kind": "workflow", "label": "Hand it to dispatch", "workflow": "hand_to_dispatch",\n "inputs": { "order_id": "record", "owed_by": "due_date" } }],\n "record": [{ "label": "Print the file", "template": "dossier" }], // optional \u2014 the record\'s own header menu\n "selection": [{ "label": "Statement", "template": "statement" }], // optional \u2014 the work it hands on\n "export": true, // optional \u2014 the rows in view, saved\n "import": { "kind": "import", "label": "Upload the sheet", // optional \u2014 a file turned into rows\n "entity": "order", "key": "code", "columns": ["rate"] } },\n "section_acts": { "line": [{ "label": "Chase the lines", "template": "chaser" }] }, // optional \u2014 a verb on one section\n "sections": { "line": { "draw": "worksheet", "cost": "buy_rate", "sell": "rate" }, // optional \u2014 priced lines, worked down in place\n "payment": { "draw": "ledger", "summary": ["total", "paid", "outstanding", "due_on"] } }, // optional \u2014 a book headed by what it is read against\n "slots": { "identity": "code", // optional \u2014 slot \u2192 field, where the roles cannot decide alone\n "subject": ["customer", "project"], // \u2026or the TIERS a fold walks down, outermost first\n "stage": { "field": "state", "quick": true } }, // \u2026or the field AND the reader\'s own control for it\n "columns": ["source", "owner"] } // optional \u2014 extra facts after the slots, shed first at narrow width\n }\n]\n```\n\nA shape is a proven screen with named SLOTS, each filled by a field carrying a\nrole (\xA7 Field roles). A slot with exactly one candidate on the entity binds by itself;\ntwo candidates need naming in `slots`; a field fills one slot; a required slot\nwith none is refused \u2014 a lifecycle desk over an entity with no `lifecycle`\nselect cannot be built.\n\n**A LIST IS A HIERARCHY, not a set of columns.** A group register\'s `subject`\ntakes one \u2014 `"subject": ["customer", "project", "order"]`, outermost first \u2014 and\nthe register becomes one the reader walks DOWN: it folds by the first tier, a\nnode narrows to the tier inside it, the last tier opens the set behind the\nfigure, and a trail says where they are. Every tier is a field of this entity\ncarrying a role that slot accepts, and none of them fills a second slot. It is\nthe slot\'s own answer that decides: a list on any other is refused, because two\ncolumns in one cell is not a fold. Without it the same business needs a register\nper depth, and the same money is folded twice with nothing saying it is the same\nmoney.\n\n**AND ON A SLOT READ AGAINST A LIMIT A LIST IS SEVERAL MEASURES** \u2014\n`"level": ["received", "invoiced"]`, at most four, each drawn as a named meter\nline inside the ONE column and each read against its OWN `against`/`alert`. A\nrow is regularly scored against more than one target at once, and the second\nreading put in `columns` instead draws a figure bare with the comparison handed\nback to the reader. Every entry states a limit \u2014 a bare measure in the list is\nrefused, naming the clause \u2014 and `quick` is refused beside one, because a typed\nfigure is one cell. The FIRST leads: it is what `summary.above: "counts"` counts\npast-limit rows on and what a live board ranks its exceptions by, both of which\nname one set.\n\n**A QUICK SLOT IS FOR A DECISION THE READER MAKES FROM THE ROW ALONE.** A slot\'s\nvalue in `slots` is normally the field\'s alias; `{"field": \u2026, "quick": true}`\nsays the column is not a reading of that value but the CONTROL for it \u2014 pressed,\nit writes that one field as a diff through the record\'s own update, so the\nregister and the page behind it land the same write. THREE roles are such a\ndecision, and `quick` on any other is refused: a `lifecycle`, which rests as the\nstage\'s own dot and label, takes a rule under it on hover and focus, and opens\nthe field\'s stages with the ones that END the flow last \u2014 ONE control, which IS\nthe reading;\na `verdict`, which is the switch; and a `measure`, which is the figure typed in\nits own cell \u2014 a reading taken per row IS the row, which is what a timesheet\'s\nhours and a count sheet\'s tally are. Everything else is decided against what the\nrecord holds, and the record has to be open. All three carry the record\'s own\nblockers \u2014 a move the ladder holds is a move the cell holds, and it names the\nentries the row still owes \u2014 and an outcome asks before it commits. Add `"order": "sequence"`\nwhere the stages are a WALK rather than a set of destinations: the picker is then\nordered as the walk and the step the row takes next is MARKED in it. It never\nadds a second control \u2014 a column that answered with a reading in some rows and a\nverb in others is two columns about one value. A screen stating `writes: false`\nrefuses `quick`: a reader who may operate nothing has no decision to take.\n\n**THE SLOTS ARE THE ROW\'S ANSWER; `columns` IS ITS CONTEXT.** `"columns":\n["nguon", "phu_trach", "nhu_cau"]` names extra fields of the entity, drawn AFTER\nevery slot and in this order, each by what its column IS \u2014 a chip for a select,\nthe person\'s name for a member, the day for a date, the figure for a number, a\ntick for a boolean, the words for everything else. They are ranked BENEATH every\nslot, so a phone sheds all of them before it sheds any of the shape\'s own\ncolumns. **A COUNT IS NOT A COLUMN**: an entry whose field is a `rollup` that\nCOUNTS rows is read as the thing it counts \u2014 `3 b\xE1o gi\xE1 \u0111ang m\u1EDF`, the number then\nthe column\'s own label \u2014 on the name\'s supporting line at every width, so it\ntakes no seat and a count of nothing draws no run at all. Where the record LISTS\nthose rows, the reading is the press that opens it. At most four, and refused\nwhere one would say what a slot already says,\nover a files field (files are read, so they are a section of the record), over a\ncell holding several (a column of comma lists compares nothing), on a shape whose\nrows are not the entity\'s records in a table, and beside a `presentation.layout`\nthat draws the rows with a device of its own. A fifth fact worth the width is a\nslot the shape is missing \u2014 say so rather than widening this clause.\n\n| Shape | Answers | Required | Also fills | Record | Tabs |\n|---|---|---|---|---|---|\n| `lifecycle_desk` | what is stuck, what do I move next | `lifecycle`, `identity` | `mark`, `party`, `amount`, `measure` (level, one or several), `when` | page, or drawer where the entity carries `parent` | the lifecycle\'s stages |\n| `party_register` | who is this, our history, is there a risk | `identity` | `mark`, `contact`, `measure` (worth), `verdict` (risk) | page | none |\n| `offering_register` | what do we offer, at what price, can I sell it | `identity` | `mark`, `amount` (price), `measure` (availability) | page | none |\n| `transaction_ledger` | does this period reconcile, what is unexplained | `when`, `amount` | `identity` (reference \u2014 the line\'s own number), `party`, `lifecycle` (classification), `expected_set` (document) | drawer | none |\n| `monitored_asset_set` | what needs attention, is that number normal | `identity`, `measure` (level, one or several) | `mark`, `lifecycle` | drawer | none |\n| `obligation_desk` | what is due next, and has it been done | `identity`, and at least one `obligation` on the entity | `when` (runway) | page | none |\n| `trend_deep_dive` | how did the period go, and why | `when` | `measure`, `amount` | drawer | the series, where the model names a select |\n| `reconciliation_desk` | what does not match, and by how much | `identity` (reference), and two figures \u2014 `ours` and `theirs`, each an `amount` or a `measure` | `category` (reason), `verdict`, `when` | drawer, on the PAIR | the dated runs, where the model names a select |\n| `entry_matrix` | one value per subject per period | `identity` (subject), `across` \u2014 a `when`, or a `category` for a fixed column set | `measure` (value), `lifecycle` (state), `category` (note) | drawer, on the cell\'s row | none |\n| `guided_run` | complete an ordered sequence, a step at a time | `identity` (step) | `slot` (sequence), `capture` \u2014 a `measure`, `verdict` or `expected_set` \u2014 `verdict` (gate), `mark` (media) | page: the run IS the record | none |\n| `live_board` | is everything OK, right now | `lifecycle` (state), `identity` | `verdict` (exception), `when` (since), `measure` (level, one or several), `category` (where) | drawer | none |\n| `group_register` | what does each group come to, and what is behind it | `subject` \u2014 a `category`, `party` or `when` | `amount`, `measure`, `identity` (member) | page: a row is an aggregate, and the door is the SET behind it | none |\n| `media_set` | scan a body of pictures by where they belong | `mark` (picture), `identity` | `category` or `party` (group), `when`, `verdict` (current against superseded) | drawer | none |\n| `day_sheet` | what happened on each day | `when` (the day) | `lifecycle` (state), `measure` (level, one or several), `amount`, `category` (note) | page: a day composes several child logs | none |\n| `resource_schedule` | what is on each resource, in what order, under what ceiling | `lane` \u2014 a `party` or a `parent` \u2014 `identity`, `when` (the start) | `measure` (duration), and `load` and `bulk`, each a `measure` read against a ceiling of the LANE\'s \u2014 a vehicle fills by weight and by room and stops at whichever runs out first; `lifecycle` (stage) | drawer, over the lanes | none |\n| `worksheet` | priced lines the reader edits, totalling to one figure | `identity` | `category` (group), `measure` (quantity), and `cost` and `sell`, each an `amount` or a `measure` | drawer, per line | the versions or the jobs, where the model names a select |\n\n**A record is one of five KINDS, and the shape plus the roles decide which.**\nEvery one of them is ONE READING COLUMN, and the ORDER of that column is the\nwhole of what the kind means.\n\n| Kind | The column, top to bottom |\n|---|---|\n| WORK RECORD \u2014 a `lifecycle_desk` over rows that belong to nothing and that nothing transacts with | the one act that moves it \xB7 what it IS \xB7 the papers it owes \xB7 the rows it is made of \xB7 the book of what it came to |\n| LINE \u2014 the same shape over rows carrying `parent` | its ladder \xB7 the arithmetic behind its amount \xB7 the rows and papers under it \xB7 its particulars, last (the key it is filed by is the line under its title) |\n| PROFILE \u2014 a `party_register`, and any shape over an entity whose rows other entities are NAMED by (their `party` or their `identity`) | where it stands \xB7 its history, OPEN, grouped by year \xB7 the rest of its registers \xB7 its facts (the ways to reach it are under its name, its settlement is the band, and a `when` it owes closes that band as a countdown) |\n| CATALOGUE ITEM \u2014 an `offering_register` | its own words (the picture leads the header at media scale, the price is read against its reference in the band) \xB7 what uses it \xB7 its facts |\n| EVIDENCE \u2014 a `transaction_ledger` | the PROOF \xB7 what the paper is, compactly \xB7 the rest \xB7 the links to the record it settles and the party it was with |\n\nNothing in the file states the kind: a clause that could say it could say the\nwrong one, and the model already says it \u2014 `parent` says whether a row stands\nalone, and a link NAMING this record (`party`, `identity`) says whether other\nrows are ABOUT it. So a desk over accounts\nwalking their own stages opens a PROFILE: the quotes and the invoices naming\neach account are what its record is read for, and a work record would have\ncounted them in one line at the foot. `record`\noverrides the DOOR where the shape\'s own answer is not the one wanted \u2014 and its\nthird value, `"expand"`, is not a door at all: the row REVEALS what it holds in\nplace, where there is nothing behind it worth navigating to. A row read that way\nreads as a LINE whatever the shape would have opened: its ladder, the arithmetic\nbehind its amount, then its particulars, because there is no header above it to\nhave stated the name first. It is REFUSED for two reasons, and they are not the\nsame refusal. On a shape whose record is a page by nature \u2014 a `guided_run`, a\n`group_register`, a `day_sheet`, an `obligation_desk` \u2014 because each of those\nstates the opposite where its door is declared: what its row leads to is read\nwhole, not as four more columns than the register had room for. And on a shape\nthat draws its rows with a device of its OWN \u2014 an `entry_matrix`, a `media_set` \u2014\nbecause a cell of a grid and a tile on a wall have no band under them for the row\nto reveal into. For the same reason a revealed register is drawn as a TABLE: a\n`presentation.layout` of anything else is refused, and the reader is not offered\nthe arrangement either, since it would take away the only door the register has.\n\n**`scope` \u2014 the one subject every read of the app narrows to.** Stated on the\nAPP rather than on its screen, because it is not a filter: a record opened under\nit stays under it, the pick survives closing the app, and every app declaring the\nsame `entity` shares one pick per viewer \u2014 which is the whole value, and what\nstops a construction workspace asking which project twelve times. `entity` is the\nmodel\'s entity whose one row the app is read inside, and `param` is what the\napp\'s reads take that row by.\n\n```jsonc\n{ "alias": "site_work", "name": "Site work",\n "scope": { "entity": "project", "param": "project_id" },\n "screen": { \u2026 } }\n```\n\nThe register\'s own entity has to REACH it \u2014 its own rows, or rows that name one\nthrough a link \u2014 or the switcher is a control that changes nothing. Until a\nsubject is picked the app reads NOTHING and says which one it is waiting for: an\nunscoped read over a scoped app is every row in the workspace drawn as one\nproject\'s work, which looks correct.\n\n**ONE RECORD SURFACE PER ENTITY, AND ITS KIND IS THE ENTITY\'S.** An app is one\nregister, so the record its rows open is the register\'s own and the kind comes\nfrom that register\'s shape. Another app over the same entity opens the same kind\nof record: a payment read from a cash book and one read from a trend are both\nmovements, and both read proof first. A shape with no kind of its own (a trend, a\nmonitored set, a `custom` screen) says only where the record opens.\n\n**A NAMED PARTY HAS A RECORD SURFACE IN THE APP.** One register, but the party\nits rows name \u2014 by `identity` or by `party` \u2014 opens a record of its own, a\nPROFILE, from the cell that names it and from the record\'s header: the ways to\nreach them, what is live with them, every register that names them at its latest\nfive, their journal, their facts. It is the app\'s to write as far as the job\nneeds \u2014 the party\'s facts and its journal \u2014 and the writers matrix says so; the\nregister\'s own record stays the register\'s. A party named through a link\ncarrying no role opens nothing, and neither does a party that is a MEMBER \u2014 a\nperson of this workspace has no row in any book the app carries, so the cell and\nthe header state them and stop.\n\n**Two things a register states about a row beyond which field fills which slot.**\nA `measure` is read AGAINST its limit (`against`/`alert`) where the shape\'s slot\ntakes one \u2014 `level`, on a lifecycle desk, a monitored set, a live board and a day\nsheet, a schedule\'s `load` and `bulk`, and every slot of a\n`custom` screen, which composes the frame\'s own column; `worth`, `availability`\nand a trend\'s headline are plain figures, and a limit there would read as a meter\nin the record over a number the row printed bare. And the name carries a\nSUPPORTING LINE where the entity states a key to file the row under: the `parent`\nit belongs to, its `reference`, an `autonumber`, a text formula \u2014 in that order,\nfirst hit wins, never a field a person types prose into. Every record heads with\nthat same key, so the register and the door it opens name one row one way.\n\n**The act that opens a row belongs to a register that LISTS the entity\'s rows**\n\u2014 a desk, a register, a ledger, a monitored set, or a `custom` screen. A shape\nwhose rows are not records has neither that act nor the door a counted line\nelsewhere leads to: an obligation desk\'s rows are deadlines and a trend\'s are\nperiods, so a create pressed there would open a row the screen cannot show. An\nentity whose only app is one of those has no create at all \u2014 give it an app whose\nregister its rows are read in.\n\n**An `obligation_desk`\'s rows are not its entity\'s rows.** The shape fans the\nentity\'s `obligation` fields out \u2014 one row per thing still owed, which is one\nwhose date is SET and whose `satisfied_by` is empty \u2014 so a l\xF4 with four cut-offs\nis four rows, and the three it has met are not there at all. The row\'s name is\nthe obligation\'s, its supporting line the record\'s `identity`, and the door is\nthat record. A `when` bound to `runway` is what the countdown\'s ring is measured\nfrom. Over an entity declaring no `obligation` the shape is refused: there is\nnothing to count down to.\n\n**What a register carries beyond its columns.** Six clauses, each emitted as\nthe prop the kit draws it with; every one is optional and a screen that states\nnone gets a register of columns and nothing else.\n\n- **`period`** \u2014 a field on the entity whose value is a DATE. The toolbar gains a\n date-range control (this month to start), and the rows it keeps are what the\n register AND every figure below are computed over, so a band can never be over a\n different window than the rows under it. Any such field, not only the `when`\n role, and however the date got there: one table is read two ways (a cost ledger\n by the day a line arose, the cash book over the same table by the day it\n settled), a role is one field\'s, and the day money MOVED is often a formula over\n the two columns that could hold it \u2014 which then declares `output_type: "date"`.\n A `trend_deep_dive` brings its own period and takes none here; a `live_board`\n is PERIODLESS and takes none either, for the opposite reason \u2014 it answers what\n is true NOW, and a date range over it draws what was true in the window in the\n live state\'s own colours.\n- **`filters`** \u2014 the chips beside the search. A single-`select` field\'s alias\n offers that field\'s own live options; it is the lens a register is read through\n over and above the one band its strip gives, so the strip\'s own field is\n refused here (one dimension, one control) and so is a multi-select (a row would\n answer the chip several ways at once). The rows the register and every figure\n below are computed over are what the chips AND the period kept.\n A `select_member` field\'s alias is the third form and asks one question \u2014 whose\n rows these are: two peers, MINE and ALL, read against the person looking rather\n than a directory of everybody. `{"field": \u2026, "default": "mine"}` opens the\n register on the reader\'s own; that form on any other type is refused. Alone of\n the chips it narrows the READ (a `member` param on the register\'s query), so a\n desk that opens on the reader\'s work opens on all of it rather than on\n whichever of their rows the first page happened to hold.\n A DERIVED LENS is the other form \u2014 `{"label": \u2026, "predicates": [{"label": \u2026,\n "tone": \u2026, "where": \u2026}]}` \u2014 where the sets a reader narrows by are ones the\n MODEL names and no column holds: rate validity, plan urgency, free-time\n overrun. Each `where` is an `and` group of plain conditions over the entity\'s\n own fields, each `field_key` a field alias, and it is read off the row, so the\n operators are the comparisons a value answers \u2014 `number` and `text` equality,\n `number` ordering, `boolean` equality, `select` `has_any_of`/`has_none_of`, and\n `is_empty`/`is_not_empty` on any of them. Nothing DATED: a relative point needs\n a clock, and a screen states one date control (`period`), so a second beside it\n would be two windows with nothing saying which a figure was computed over \u2014 a\n date question is asked as a column the model derives and read here as a number\n or a yes/no. `tone` is the option palette\'s, because the chip draws these\n exactly as it draws a select\'s options.\n- **`summary`** \u2014 what the rows in view come to. `above` is a band OVER the\n register: `"counts"` for how many rows are in view and, for the FIRST\n `measure` the model gives a limit, how many are past it (accented only when\n one is) \u2014 one such set, because a band naming three is the dashboard a\n register is not; a list of number-field aliases for what they add up to, and a\n `percentage` named there is refused, since a share summed over the rows in\n view is a figure of no kind (draw it as a column, whose own total reads the\n mean). A figure written `{"field": \u2026, "at": "end"}` is a STOCK instead: the\n reading standing at the END of the window rather than the sum of the readings\n in it, which is the only way opening + in \u2212 out = closing reconciles \u2014 a month\n of daily closing stocks added together is thirty warehouses. The band labels it\n as the snapshot it is, and it is refused on a screen that states no `period`,\n since there is then no window for it to stand at the end of.\n `ageing` stands in that same\n band: an `amount` split by how many days past its `due` date each row is,\n drawn as a `Breakdown` of the edges in `buckets` (30, 60 and 90 days unless the\n model states its own, ascending). The ladder is the kit\'s \u2014 its boundary, its\n words and its ramp \u2014 and it opens with what is NOT YET DUE, because a bar of\n overdue bands alone is full at every input: a book with one late invoice and a\n book that has gone entirely bad would paint the same solid width. Against the\n current money the bar\'s own shape is the reading. A row whose date is empty is\n in no band. `totals` is what the whole\n view adds up, and it is drawn in that SAME band above the rows: a register\n states one aggregate, over the rows the reader can see, in one place, and a\n closing line under them is a second place to look and a second question about\n which rows it covers. A closing line belongs to the one device that IS a\n statement \u2014 a record\'s `Ledger`, whose balance is what it is read for, labelled\n by its amount\'s own word. Where a shape has no band above (a trend, whose own\n band is the chart) the figure stands under the register instead. A\n `transaction_ledger` sums its amount column itself and takes `above` alone. An\n `obligation_desk`\'s rows are DEADLINES and not records, so its band states\n `"counts"` \u2014 how many are open \u2014 and a figure list, an `ageing` or a `trend`\n over it is\n refused: a l\xF4 with three cut-offs is three rows, and its freight added over\n them is three times the freight.\n `trend` is `{"field": \u2026, "direction": "up"|"down"}` and states WHICH WAY that\n figure moved across the window: the rows in view are cut into the window\'s own\n buckets \u2014 days over a month, weeks over half a year, months beyond \u2014 and the\n later half of the CLOSED ones is read against the earlier half, drawn beside\n the figures as one line, so a window the reader is still inside states the\n movement of the days that have run rather than a fall into the ones that have\n not. `direction` is which way is GOOD, which nothing about the number\n says: a book wants its takings rising and a backlog wants its days falling, and\n the same arrow is a win on one and an alarm on the other. It needs a `period`\n to be a window of, and it is refused on a shape that draws its own (a\n `trend_deep_dive` already draws the series) or that reads none at all (a\n `live_board` answers what is true now). A window whose earlier half took\n nothing draws no line: there is no base for a percentage, and 0 % would claim a\n steadiness nobody measured.\n- **`facts`** \u2014 how the RECORD\'s facts are banded. `groups` is a list of\n `{caption, fields}`: what a set of facts has in common is a sentence about the\n business and nothing derives it, so a plan that states one gets exactly those\n bands, in its own order, captioned. EACH BAND IS A SECTION of the record \u2014 its\n own heading, its own entry in the rail, its own place in the column: under one\n heading the reader is handed every particular at once, in a caption weight that\n cannot out-rank the heading above it, with nothing in the rail to reach any of\n them by. Everything the plan leaves unnamed is the LAST section and carries no\n heading, since there is no sentence to head "whatever no group claimed" with \u2014\n ONE section, even where the record\'s own recipe bands what was left in two (an\n evidence record reads its particulars before the record and the party it was\n with): both are groups inside it, because the word the kit heads "the rest"\n with cannot name two entries in the rail.\n A band of more than eight facts is NOTED: a section that long is two. A field the header, the band, the ladder, a required\n set, the files or a register below already draws is refused: it is not a fact,\n so a band naming it would either state it twice or draw nothing. Naming any\n band IS the answer to which facts fold \u2014 see the TIERS below.\n- **`presentation`** \u2014 how the screen and the record it opens are DRAWN, where\n what the rows are is not the answer wanted. `lead` is what each row leads\n with: `"mark"` the subject\'s own mark, always spent because a party\'s initials\n stand in for a picture nobody uploaded; `"picture"` the photograph, spent only\n on the rows that carry one and, on the record, at media scale above the name;\n `"paper"` the row\'s own files, drawn as the cards a pile of documents is read\n as; `"figure"` the amount, with no gutter at all; `"none"` neither. `density` is\n how many lines a row\'s subject may take \u2014 `"roomy"` two, `"dense"` one.\n Both default, and the DEFAULT is what the rows are: a party register leads\n with the mark, an offering register with the picture, a ledger with its\n figure, and a desk \u2014 rows that are work still to do, scanned \u2014 stands its rows\n one line each while a record\'s own rows are read roomy. So state it only to\n differ, and a plan that states nothing still gets screens that vary. A lead\n the rows cannot carry is refused: a mark, a picture or a paper where no column\n is drawn as the rows\' mark, a\n figure where no column is the amount, and the other subject\'s mark (which of\n the two a row wears is the shape\'s, so the register and the record it opens\n cannot call one row two kinds of thing).\n `layout` is how the rows are ARRANGED \u2014 `"table"`, `"list"`, `"cards"`,\n `"gallery"`, `"board"`, `"calendar"`, `"timeline"`, `"gantt"` \u2014 and it is\n AFFORDED by what the rows carry, not by the shape: every register affords a\n table and a list, a `mark` affords the two picture-led grids, a `lifecycle`\n affords a board, a `when` affords a calendar and a timeline, and a `when`\n beside a `measure` that `counts` DAYS affords a gantt, because a bar with no\n length is a calendar drawn sideways and a bar measured in money or litres is a\n length of another kind. `until` names the date field a bar ENDS on, where the\n rows state one, and the days measure is what the bar runs for without it. A\n layout the bound roles do not afford is refused, naming the ones they do.\n- **`acts`** \u2014 where this screen hands work on: `row` is the \u22EF menu on every row,\n `record` is the \u22EF on the RECORD\'s own page header \u2014 the same reach as a row\'s,\n standing where the work is open instead of while a list is scanned, and refused\n on a record that opens as a drawer, which has no header of its own and whose\n row already carries the register\'s menu \u2014 and `selection` is the bar over the\n TICKED rows. `export` is the third reach \u2014\n the WHOLE VIEW, saved in one press: `true` writes the rows as the register drew\n them, in the columns it drew, as an `.xlsx`, and\n `{"template": "\u2026"}` says the layout is the trade\'s and makes the paper with a\n workflow like any other. It is never a list of columns: which columns the\n export carries is what the screen already draws, and a second statement of it\n disagrees the moment a slot moves \u2014 the generator reads them off the drawn\n slots and writes them into the app, so the sheet\'s headings are the labels the\n register drew them under.\n `import` is the reach OPPOSITE it \u2014 a file turned into rows:\n `{"kind": "import", "label": \u2026, "entity": \u2026, "key": \u2026, "columns": [...]}`. The\n file is mapped column by column, each row is validated, what would change is\n previewed, and the commit UPSERTS by `key` \u2014 which is why a sheet sent twice\n moves no counts. That key is one of the entity\'s own `natural_key` fields\n (\xA7 Write rules), and an entity declaring none is refused: without a key the\n second run is a second set of the same rows. `entity` is the register\'s own \u2014\n an app is one register, and rows filed into another entity are a count nobody\n on this screen can check. `columns` is what the sheet may fill, in that order;\n absent, it is every column a person states IN WORDS. A computed one is refused\n because the workspace writes it, and a link, a member or a files column is\n refused because a cell of a sheet is words and those hold a row of another\n table or bytes \u2014 a file reaches another table through the party pattern (a\n `natural_key` on the target, named rather than picked), which is a create\'s.\n The generator writes the whole verb, as it does for a paper: an\n `import_<entity>` workflow that finds the row by `key` and then changes it or\n opens it, answering which of the two, and the staged run over it \u2014 drop, map,\n the per-row verdict, the commit. A line whose cell the column cannot hold is\n refused with the reason and the rest of the sheet still lands. A file MINTS\n rows of the register, which is the Add many times over, so a screen stating\n `writes: false` or `"children"` \u2014 which opens no row of its own \u2014 refuses the\n clause; the `export` beside it stays, because it only reads.\n Both spreadsheet reaches are read and written IN THE BROWSER, so the generated\n `src/main.tsx` imports `@lotics/app-runtime/sheets` wherever the plan states\n one of them and not otherwise; an app that has neither carries no spreadsheet\n engine at all. An act names ONE of three things, never two:\n - **`template`** \u2014 an `html` template this model declares (\xA7 Templates). The\n paper is filled from ONE row in `row`, and from the whole ticked set in\n `selection`. The generator writes the whole verb \u2014 a `generate_<template>`\n workflow that reads the row and hands the file back, and a menu item that\n opens it \u2014 so there is nothing left to bind. An `email` template is sent\n rather than opened and is refused here; a file-backed template (`excel`,\n `word`, `pdf-form`) is bytes a model has none of, so those stay the author\'s\n own `lotics app workflow set`. Two screens over DIFFERENT entities cannot\n name one template: one paper is filled from one kind of row.\n - **`{"kind": "agent", \u2026}`** \u2014 a run over ONE row. `agent` is an alias the APP\n declares (`package.json#lotics.agents`), because an agent is prose and tools\n rather than anything a workspace model can state \u2014 `lotics app check` refuses\n one nothing declares, exactly as it refuses a workflow nothing bound. `fills`\n is the fields the run may write: pressed, the reader watches the run, reviews\n what it proposes field by field against what the row holds now, and applies\n the ones they kept as ONE write through the record\'s own update. A run can\n reach nothing outside `fills`, a computed column there is refused (the\n workspace writes those), and a screen stating `writes: false` refuses an\n agent act outright. It belongs in `row`; the selection bar makes ONE paper\n from many rows, and a run per ticked row is the row\'s own act many times.\n - **`{"kind": "workflow", "workflow": "\u2026", "inputs": {\u2026}}`** \u2014 the work HANDED\n ON: a lead becomes an opportunity, an order is split into the orders placed\n on its suppliers, a request is closed. `workflow` is an alias the APP binds\n (`package.json#lotics.workflows`), because the rule behind it is the\n business\'s and no model states one \u2014 so the generator writes NO body and\n `lotics app check` refuses an alias nothing bound, exactly as it refuses an\n agent nothing declares. `inputs` is what the press hands that body: the\n workflow\'s own input name \u2192 `"record"` for the row it was pressed on, or a\n field of this entity for a value off that row. The value is what the\n workspace HOLDS \u2014 an option\'s key, a linked row\'s id, the figure \u2014 never the\n rendering. A files column is refused (the body reads the row\'s files off the\n row it is handed), a column that is not a field of the entity is refused by\n name, and one input short of the declaration is refused at the press, so the\n whole act is dropped rather than shipped half-bound. It runs ON the press:\n there is nothing to review first, which is the agent arm\'s law, and the\n body\'s own sentence is what the reader is told either way. It stands in\n `row`, in `record` and on a `section_acts` band \u2014 each reaches ONE row \u2014 and\n is refused over the ticked set, where one act makes one paper from many. The\n row it reaches is the RECORD\'s, so a screen stating `writes: false` or\n `"children"` refuses a hand-off on every one of those surfaces, exactly as it\n refuses an agent act.\n\n **`"when"` stands an act down until the work is ready.** `{ "field": "<the\n entity\'s lifecycle>", "in": ["<stage>", \u2026] }` offers the verb only while the\n row stands at one of those stages. A verb that cannot work yet is worse than\n no verb: the reader presses it, the body refuses, and nothing on the screen\n ever said the work was not ready. Readiness is what a lifecycle states, so the\n field named is this entity\'s `lifecycle` and nothing else \u2014 a category says\n what a row is and a verdict answers a question, and neither moves. Refused: a\n field that is not the lifecycle, a stage it does not declare, and every stage\n at once, which is the act with no condition.\n\n **`"place": "cta"` draws an act ON the row** instead of in its \u22EF \u2014 the one verb\n a reader presses without opening a menu first. At most one per screen and every\n other act stays in the menu, because the trailing gutter is paid for by every\n row: a second is refused with *one verb on the row, the rest in its menu*. The\n ticked set has no row of its own, so `cta` is refused there.\n\n A hand-off\'s BODY is still the author\'s: `lotics app workflow set <alias>`\n binds it, and `scaffold check` prints the act as `"<label>" \u2192 <alias>(\u2026) [you\n write the body]` so it is read as work owed rather than as a verb already\n wired.\n\n A `selection` act is a `template` and nothing else: an act over many rows makes\n ONE paper from them. The generator turns the register\'s checkbox gutter\n on, emits a `generate_<template>` workflow taking the ticked ids, and the body\n reads each row and passes them as `rows` \u2014 which is what the template iterates\n (`{{#each rows}}`). One template is one paper AND one reach: naming the same\n one from a row\'s \u22EF and from the bar is refused, because those are two bodies.\n A shape whose rows are DERIVED rather than records \u2014 an `obligation_desk` \u2014\n has no set to tick and refuses the clause. The paper is handed BACK, never\n filed: a ticked set can hold rows of three different parents, so there is no\n record on which "this document belongs here" is true.\n\n**`section_acts` puts a verb WHERE ITS EFFECT LANDS.** "Chase the paperwork"\nbelongs on the papers, not in the register\'s \u22EF two surfaces away. It is keyed by\nthe alias each section is DERIVED from \u2014 a field\'s (its progress, its prose, its\nrequired set, its charge, one of its files) or a CHILD ENTITY\'s (its register,\nits desk, its run, its thread) \u2014 because the sections come off the roles and the\nkey the app addresses one by does not exist until it is built. An alias naming no\nsection is refused, listing the ones that do; the facts band is what every other\nsection left over, so nothing addresses it. Each act is a `template`, an `agent`\nor a `workflow`, exactly as a row\'s is \u2014 the reach is the same one row \u2014 and\n`place: "cta"` is refused: a section is a band with a heading and has no row to\ndraw a verb on. `scaffold check` prints\nwhich section each alias landed on, which is the half an author cannot see in\ntheir own file.\n\n**`sections` says HOW one of those sections is DRAWN**, keyed by the same\naliases. It carries three readings.\n\n`"draw": "ledger"` heads a book of movements with what the record it is against\nis read against: `summary`, number or DATE fields of THIS record, in the order\nthey read \u2014 what is owed, and by when. Each is drawn as the record\'s own band\ndraws it: a measure with a bound is a meter, a date is a countdown, every other\nfigure is an amount. A date the record works TO is the default; a date a right\nRUNS OUT at \u2014 a free cancellation, a quote\'s validity \u2014 is written\n`{ "field": "refund_until", "sense": "expires" }` and reads closed rather than\nlate once it has passed. A\nsection with nothing in it is otherwise a heading, a verb and a void \u2014 and the\nemptiness is exactly when the reader most needs to be told what the rows would\nbe for, so the statement is drawn at nought rows too. The figures are the\nRECORD\'s rather than the child\'s for the same reason: a sum of the rows under it\nis the one statement a book with no rows cannot make.\n\n`"draw": "worksheet"`: the child\'s rows\nare PRICED LINES the reader works down in place, each part of the job footed and\nthe sheet closing under them. `cost` and `sell` name the child\'s two figures,\nwhich is the one thing the roles cannot decide \u2014 an entity carries one `amount`,\nand a line that is costed and sold carries two. Everything else is read off the\nchild\'s own roles: what a line is FOR is its `identity`, how many it is for is\nthe `measure` the pair left, and the part it falls under is its `category`. The\nMARGIN is on none of them, because it is arithmetic over the pair.\n\n`"draw": "itinerary"` says WHICH FOUR STATEMENTS each stop of a run is read by:\n`line`, in the order they read, from `when`, `kind`, `provider`, `stage`,\n`duration`, `caption` and `reference`. A stop is one press and its supporting\nline holds to one row at every width, so four is the whole of it \u2014 a fifth is a\nfact the stop\'s own drawer answers. Absent, a stop is read by `when`, `kind`,\n`provider`, `stage`, which is how a travel row reads; a roster is read by its\nhours and a courier\'s run by who is at the door, and nothing derives which:\n\n```jsonc\n// A cleaning round: the hour it takes and the flat it is at say more than the kind.\n"sections": { "visit": { "draw": "itinerary", "line": ["when", "duration", "provider", "stage"] } }\n```\n\nEvery run named is one the stops already carry \u2014 `duration` is the child\'s\n`measure` that `counts` hours, `caption` the second `party` a row names, `kind`\nits `category` \u2014 so a word the child declares no role for is refused rather than\ndrawn as a gap in every line, and so is the same word twice.\n\nA sheet is the WRITE side of a price, so it is the one child section whose cells\nare editors: a figure typed into a cell is written as a diff through the child\'s\nown update, which is the same editor the line\'s own drawer saves through. Every\nother child section is read where it is drawn and edited in the row\'s drawer. A\nline is ADDED from the section\'s heading as every child\'s row is, with the record\nit hangs under as the act\'s own context.\n\nRefused: an alias naming no register of this record, rows the record does not\nOWN (a line added to a history belongs to the job it is about, not to what is\nreading it), a figure that is not a field of the child or whose role is neither\n`amount` nor `measure`, a sheet naming neither figure, one figure priced as both,\na book headed by anything but a number field of the record, and a line on a\nsection this record draws as a register rather than as a run.\n\n**A CHILD NOBODY REGISTERS IS A NOTE, not a refusal.** Where a record lists a\nchild entity no app is a register of, `scaffold check` says `"<entity>" has no\nregister \u2014 its rows are read here and created nowhere` and the plan printout\nmarks that entity `(no app)`. Rows can arrive from an import or a workflow, so\nit is a reading rather than a rule; what it catches is the register somebody\nmeant to plan and did not. A section the record ADDS to is exempt, because its\nheading is where those rows are typed: the rows a record owns on a PAGE, a\nthread on any door \u2014 a reply is written where it is read \u2014 and a sheet, which\nopens its own lines. A record opening in a DRAWER adds none of them, so its\nowned rows are noted like anybody else\'s.\n\n`scaffold check` prints every clause a screen states, on its own line under the\nslots, spelling the screen\'s `writes` clause `operable` \u2014 the app manifest\'s\n`lotics.writes` keeps that word, and the two are different facts: `app create`\nSEEDS the manifest from the fields the screens\' own editors write, and from then\non the app owns the declaration `lotics app check` holds its bodies to.\n\nEach shape is a `@lotics/ui` component of the same name (`LifecycleDesk`,\n`PartyRegister`, \u2026) whose props are these slots, so once the tables exist\n`lotics app create <name> --from <this file>#<app alias>` scaffolds the app with\none screen per entry, each slot reading the field the plan bound.\n\n`"shape": "custom"` is the screen no registry row covers: it declares its own\n`roles` (slot name \u2192 role), and it is emitted on `ShapeFrame` \u2014 the one anatomy\nthe six shapes above are each a configuration of \u2014 so its strip, search,\nordinal, fit budget, empty and waiting states and record door are the same ones\nthey have. It opens the record its `record` says, drawer or page, like any other.\nA bound `lifecycle` slot IS its strip, the stages in the field\'s order as on a\ndesk, so such a screen names no `tabs`. Only a lifecycle earns a strip \u2014 each\ntab a queue of work at one stage \u2014 and a screen naming any other select as\n`tabs` is refused: a kind, a book, a channel is a `filters` entry beside the\nsearch. The three shapes whose strip means something of their own take any\nselect: a reconciliation\'s dated runs, a trend\'s series, a worksheet\'s versions.\n\n**An app IS its `app.json`.** `create --from` writes the bound plan \u2014 every\nscreen with its shape and slots, the record each row opens with its sections in\nthe archetype\'s order, the create panels, the acts \u2014 as one JSON document, plus\na five-line `src/main.tsx` that mounts `@lotics/app-runtime` over it and one\n`src/workflows/<alias>.ts` per write. There is no screen source: the runtime\ndraws the spec, so a kit correction reaches every app with its next install.\nWhere the plan has no word for what a screen or a section IS \u2014 or for what a\npaper act should ASK before it is made \u2014 the spec names one of the app\'s own\ncomponents and `src/components/index.ts` registers it, the one file under `src/`\na regeneration never rewrites. `lotics app eject <screen|<section key>|<act\nlabel>>` writes each of those, starting from what the runtime already drew.\n\n**How a spec reads a row.** Each query projects its columns under the alias the\nworkspace mints from the field\'s LABEL \u2014 never the alias this file keys it\nunder, which is the model\'s own namespace and which the workspace never saw. So\na field this file calls `partner` and labels `\u0110\u1ED1i t\xE1c` is referenced as\n`doi_tac` throughout the spec. Every id in it is live: the `tbl_` a screen is\nover, the `fld_` of each column, and the `opt_` of every option a role names.\n\n**Every query is described, in the model\'s own language.** The line an agent\nchooses between aliases by is written from this file\'s nouns \u2014 the entity\'s\nlabel, the screen\'s, the link\'s \u2014 and the sentence around them is the language\nthe file is mostly written in, decided the same way a generated write\'s refusals\nare. Hand-editing one is erased by the next regeneration; `lotics app check`\nrefuses an alias that carries none.\n\nA screen is that list and the RECORD it opens, and the record comes off the same\nroles \u2014 nothing to declare for it. It opens with a **header**: the entity\'s\n`identity` as the record\'s name \u2014 whether or not the list has a column for it, so\na ledger\'s and a trend\'s records are named too \u2014 the `mark` beside it on a PAGE,\nwhich is what the subject is recognised by (so the pile below is the papers that\nare left, and a `presentation.lead` spending that gutter on something else leaves\nthe picture in the pile), the `when` this screen reads\nunder it, and ONE headline\nfigure (the `amount` the screen reads, else its `measure`). BOTH DOORS state the\nname: which door a screen uses is a layout answer, and what the record is ABOUT\nis not.\n\n**A JOB\'S HEADER ALSO STATES WHO IT IS FOR.** Where the record is the work itself\n\u2014 a desk\'s rows that belong to nothing, opened on a page \u2014 its `party` is a link\nunder the name, beside the ways to reach the subject, or, where that party is a\nworkspace MEMBER, the person themselves with no door behind them, and it is then\nnot a fact\nas well: a page headed by a code alone said nothing about whose work it is, and\nthe one thing a reader arriving from the desk already knows the row by sat in the\ngrid below as a row among forty. A record opening in a DRAWER has no row of links\nto hang it on, so its party stays a fact; so does a LINE\'s, whose register files\nit under the thing it belongs to and whose party is that parent\'s, restated per\nrow.\n\n**A record whose figures are a SENTENCE states them under the header, in a\nband**, and the header then states neither of them: a job reads what it comes to,\nhow far through it is (the `measure` against its limit), what is left of it (a\nformula that is exactly `{amount} - {measure}`) and how long there is (an\n`obligation`, else its `when`, counted down); a party reads its settlement, the\n`amount` and `measure` roles it carries in the order the model declares them; a\ncatalogue item reads its `amount` against the reference that amount names. Two\nfigures are the floor and four the ceiling \u2014 under two, the header keeps its one.\nA band is a PAGE\'s strip, so a screen whose `record` is a drawer keeps every\nfigure where a drawer reads them. Each figure is drawn exactly once: the limit a\nmeter states is not a fact beside it, and the day a countdown counts to is not\nthe provenance line above it. The band draws them, so the band is where they are\nCHANGED \u2014 a date a person states is edited on the countdown and a limit a person\nstates on the meter, while the level between them is read. The `lifecycle` is\nnot badged there, because the progress section is the rung it stands on. Then\nits sections, in the order its KIND reads them: the\n**facts** (every field neither the header, the band nor another section owns, the\nrow\'s own `parent` among them \u2014 led by the ones THIS screen\'s slots bound, then\nthe key it is filed under, then the order the model declares them in \u2014 a\n`markdown` field among them, drawn across the grid\'s whole row rather than\nwrapped to a third of it), the record\'s own **body** (that same markdown, taken\nas a section instead, ONLY where the kind leads with it: a catalogue item IS its\ndescription, and a job is not its notes), the **charge** (where the `amount` is a formula over exactly one\nquantity and one money field, the line states `quantity \xD7 unit price = amount`\nand neither figure is typed twice \u2014 the section is headed by the amount\'s own\nlabel and the closing row says what the figure IS, so the word is said once),\nthe **progress** (the `lifecycle`\'s stages as a ladder the reader ticks a rung of\nto move the record on, grouped into its `phases` where the model states them, and\neach rung carrying the day it was reached where the lifecycle keeps a\n`history`), a\n**required set** (a multi-select `expected_set`, or a child entity whose rows\ncarry one entry of it each \u2014 that child\'s `files` field is what a paper attaches\nto), the record\'s **own rows** (any other child, its role-bound fields as\ncolumns), and its **files** (every `files` field the header does not lead with,\nthe `mark` first where it keeps one \u2014 a section of\nthe column like any other, straight after the facts it is the evidence for,\nexcept on an EVIDENCE record, which OPENS on the paper it exists for; the\nheading carries the Add verb, and stands it down while the pile is empty, where\nthe drop well is already the door; and a `verdict` whose FORMULA reads one of\nthose files fields is the pile\'s OWN state rather than a fact row \u2014 the file it\nis short, drawn as a ghost, and the mark the rail sends a reader to). A child is\nan entity that LINKS here, ONE SECTION PER LINK: the rows it owns (`parent`), the\nrows NAMED by it (their `party` or their `identity` \u2014 so this record\'s history),\nand the rows reaching it through a link carrying neither role, headed by that\nlink\'s own label. A child hanging off two records therefore declares one `parent` and\nleaves the second relationship a plain link, which still gets its section.\n\n**THE ROWS A RECORD OWNS ARE ADDED AND OPENED FROM THEIR SECTION.** A `parent`\nsection\'s heading carries the Add for its rows, handing the record as the\nparent \u2014 filled, never asked. Every row opens its own record \u2014 a line of a book,\na paper on a desk, a stop on a timeline alike: where this app already routes a\npage for those rows (the register\'s own, under one of them) it is navigated to;\notherwise it is a DRAWER mounted on the record it belongs to, stepped \u25C0 \u25B6 over\nthe section\'s rows as they are drawn, with the row\'s framed editors, its ladder\nand its files, saving through `update_<child>` \u2014 the one editor those rows have,\nwhich any control the section draws over a row writes through too. Never the\n`parent` link itself: it is the key the row is FILED under, stated by the record\nthe drawer was opened from, so it is neither a fact of the drawer nor an input\nof that editor. ONE LEVEL, and only from a PAGE: a child\'s own children draw as\nthe kit\'s panel of the row, with no drawer and no Add, and a register whose own\nrecord is a DRAWER is the same for the children it owns \u2014 a drawer mounts no\ndrawer. **A ROW IS ADDED WHERE IT CAN BE OPENED AGAIN**, so those sections lose\ntheir Add with their drawer; a row of the register\'s OWN kind keeps it, because\nthe register\'s list opens it. A correspondence\'s entries are the one owned row\nthat opens nothing anywhere: the composer under them is the section\'s Add\nwhatever door the record has, and the answer mark is one field of the reply\'s\nown editor. The rows a record is merely NAMED by, and those reaching it\nthrough a roleless link, are read only \u2014 a history, never a place to open or\nadd.\n\n**TWO CHILDREN ARE READ AS A RUN RATHER THAN AS A REGISTER**, and the model says\nwhich by the roles it declares on them. A child carrying an `obligation` whose\n`satisfied_by` is a DATE \u2014 and no `lifecycle`, because an entity with stages of\nits own is a JOB being worked and its rows under another record are that record\'s\nlines or its history \u2014 is a **timeline**: its rows are the ordered stops, each\nstating the day it was promised for and the day it happened, with the delta named\nwhere the rung was late \u2014 drawn as a register those are two date columns and the\nreader subtracts them by eye, which is exactly what "three days late at\ndischarge" costs to learn. An obligation closed by a FILE says nothing about\nwhen, so those rows stay the register they were. A child carrying a `markdown`\ntext field beside a `party` and a `when` \u2014 and naming no member of this\nworkspace, which would make it a log \u2014 is a **thread**: the entry is the body,\nthe author and the day lead it, and a `verdict` on the child is the yes/no one\nreply is marked the ANSWER with \u2014 a register of messages is a table whose one\nuseful column is the one it cannot draw. A SECOND `party` on those rows is who\nthe entry was left WITH, and the latest one names the ball in court: while\nnothing answers, the record says whose move it is and counts its own deadline\n(an `obligation`, else its `when`) against them. Both are read only under the\nrecord that OWNS the messages \u2014 under the party who wrote them the same rows are\nthat party\'s history, several questions at once, and the last of them says\nnothing about any. That record also REPLIES: the section\'s Add is a composer \u2014\nthe message is all a reply asks \u2014 and the answer mark is one field of the\nreply\'s own editor.\n\n**A body and a day, under a record that OWNS the rows, is a LOG** \u2014 what was\ndone, when, and the entry in the words somebody wrote. WHO WRITES THE ENTRY\nDECIDES WHICH IT IS: a `select_member` on the child makes it a log whatever else\nit names, because a member of this workspace keeps a log and a party writes a\nmessage. So a `party` on a log entry is who it was WITH, drawn on that entry\'s\nown line beside the `contact` it reached them at and the files that came with\nit \u2014 never its author, and never a move outstanding. The other selects on the\nrow are facts ABOUT the entry. A NAMED row is a log entry, not a message: a\nvisit or an inspection is a thing in its own right, which is what keeps it out\nof the thread. Its Add is a composer too, taking the entry, the day (opening on\ntoday) and the entry\'s own selects; the record it hangs under is not asked.\n\n**A PROFILE LEADS WITH ITS LADDER.** An account walks stages like any other work,\nso a profile carrying a `lifecycle` reads `progress` first \u2014 right under the\nband, the same place a job leads with the act that moves it \u2014 and its histories\nfollow. Under them the ladder was seven bands down a page nobody scrolls that\nfar.\n\n**NOTHING SITS BESIDE THE RECORD.** A page is ONE reading column \u2014 its fields,\nits files, the rows it owns, its notes, all sections of it in the archetype\'s\norder \u2014 over a reserved left gutter holding a floating NAVIGATION RAIL: the way\nback, then one line per section with that section\'s category mark, and nothing\nelse. No files, no counts, no group headings. Too narrow to seat the rail, it is\none pinned bar of the same list across the top. A section HEADING never wears\nthe mark; the rail\'s line for it does.\n\n**A RELATION THE RECORD NEVER ACTS ON CLOSES THE COLUMN.** Every register that\nmerely NAMES the record is one counted line of a single last section: its label,\nhow many rows name this record (a COUNT, never a page of rows measured), and a\npress that opens the register owning them narrowed to this record. An entity\nthis app lists nowhere is a count with no door, and where every count is zero\nthe section is not drawn at all. The one exception is a PROFILE, whose history\nIS its body and stands open in the column.\n\n**Rows the record owns whose `amount` is `signed_by` a direction are a\nBOOK**, drawn as a statement \u2014 each line dated and signed, the balance under\nthem where there is more than one line to add up, labelled by the amount\'s own\nword \u2014 where the same rows on the party they\nwere transacted with stay that party\'s history, which is scanned for the line\nshort of its paperwork rather than struck to a balance. **Rows that carry a\n`when` read as RUNS**: a party\'s history under the year \u2014 and only where the rows\nin hand span more than one, since a subhead over every line of a book names the\nyear and separates nothing \u2014 and a job\'s own rows under the day they fall on,\nwhich, where those rows also carry a `lifecycle`, is an ITINERARY rather than a\ngrouped table: the day heads the run, each row is one line (its `slot`, its name,\nits party, its stage, its `category` and `reference`, its amount), the figures end\non one edge and the run closes with their sum \u2014 unless the record\'s own BAND is a\nsum rollup over exactly these rows, which states it once already. A `category`\npaints the node the run is read down, in the option\'s own colour; a `reference`\ncloses the line with the code somebody quotes on the phone. **A `mark` LEADS the\nline with the stop\'s own picture** \u2014 a venue, a vehicle, a machine \u2014 and it is the one\nplace on a record where a child entity\'s mark is drawn at all, since no register\ncolumn is ever a picture; a line whose subject holds none is marked with its\n`category`\'s glyph instead, so the run keeps one left edge. All three are\nPROJECTED for the run whether or not the register\'s column budget would have\ndrawn them, and a mark is projected NOWHERE ELSE.\nA line\'s mark is most often the SUBJECT\'s rather than the line\'s own \u2014 the\nservice, the product, the asset it is a line of \u2014 which is a `lookup` through\nthat link onto the subject\'s own `files`, given the role on the line.\n**EACH DAY\'S HEAD CARRIES THE SECTION\'S OWN ADD** as well, handing the panel that\nday, so a line made from Tuesday opens with Tuesday answered; the heading keeps\nthe verb too, for the first line and for a day nothing is planned on yet. **THE\nKEY LEAVES THE COLUMNS where the head states the whole of it** \u2014 a day head does,\na year head states four digits of the date and the day is what the column is\nstill there to carry.\n**A run is READ FROM THE END THE READER WANTS**: a job\'s own rows and an itinerary\nascending, a book, a history and a log descending.\n**A REGISTER WHOSE `when` IS A DEADLINE IS READ FROM THE NEAR END** whatever its\nshape does \u2014 `due` (or an `until` that says where the countdown stops) makes the\ndate a promise, so the rows in breach of it lead and a row nobody dated falls\nlast. A plain `when` keeps the shape\'s own end. `scaffold check` prints the\npromise (`order: Theo d\xF5i ti\u1EBFp, overdue first`).\n\n**A DESK OVER AN ENTITY THAT CARRIES A `slot` IS ITSELF A DAY\'S RUN.** Its own\nregister is drawn under one subhead per day, sorted by that day and then by the\npart of it, and the date column goes \u2014 the subhead states it once for the whole\nrun, and a column repeating it down every line is the same value twice. Without\nit an itinerary was one flat list in whatever order the rows arrived.\nA document desk keeps its head either way \u2014 the entries it owes are its\ncontent, filed or not. The name and whatever figure is left to it are the header\'s\nalone \u2014 it states them in full, so a fact for either would be the same sentence\ntwice.\n\n**A FACT IS PRIMARY OR IT IS PROVENANCE.** An `autonumber`, a date the platform\nstamps (`derive_from`) and a date a formula works out that no role names are what\nthe SYSTEM wrote; they fold, with every optional field this record does not\nstate, behind the grid\'s ONE link. Everything a person types or picks is primary\nand is shown, empty or not, because absence is work somebody owes. **A screen\nthat named `facts.groups` has already said which:** a field in a group is\nprimary, and one in none folds \u2014 grouping says what the reader decides with, and\nby saying so says the rest are not. A `tier` on a `facts` entry exists only for\nthe case that derivation gets provably wrong.\n\n**A `contact` field whose kind the\nmodel decides** \u2014 an email, a number, a place, a `link`-formatted text, or a\nMESSAGING APP its label names (WhatsApp, Zalo, Telegram, Viber, WeChat, Line,\nMessenger) \u2014 leaves the facts too, for the row of links under the name on a\nrecord that opens as a page; one whose kind nothing decides stays a labelled\nfact, because a scheme nobody stated is a link that opens nothing. A handle is\ndrawn under its app\'s name \u2014 the app is what identifies the person there, and a\nbare number beside an envelope says the wrong thing \u2014 and it dials only where its\nvalue IS a number.\n`check` prints the record under each screen\'s slots:\n\n```\n Orders \u2014 lifecycle desk over Orders (12 rows) \xB7 page \xB7 tabs: Stage (New \u2192 Quoted \u2192 Confirmed \u2192 Shipped \u2192 Done)\n stage Stage \xB7 identity Order no. \xB7 mark Photo \xB7 party Customer \xB7 amount Total \xB7 level (none \u2014 no field declares "measure") \xB7 when Due\n record: header (Order no. \xB7 Customer) \xB7 band (Total \xB7 Shipped against Lines \xB7 Due counting down) \xB7 progress: Stage (5 stages) \xB7 facts (Ship to \xB7 \u2026 2 filed) \xB7 files: Photos \xB7 documents: Papers (Kind: 4 required) \xB7 itinerary: Order lines by Ship by at Window, kind Handling, ref Waybill (Product \xB7 Quantity \xB7 Line total) \xB7 related: Visits (count via Order)\n```\n\nThe ORDER of that line is the record\'s own, top to bottom, and it is the KIND\'s,\nand every entry of it is a section of the one column. `related:` is the counted\nline that closes it \u2014 a register the record never acts on, named and counted \u2014\nand `\u2026 N filed` is how many facts fold behind the grid\'s one link.\nA child register whose `amount` is `signed_by` a direction is a BOOK of movements\n\u2014 what the record came to rather than what it is made of \u2014 so it closes the\ncolumn rather than standing among the rows the job is made of.\n\n**A section over a child is named by the CHILD\'s own label**, because the reader\nalready knows which record they are on: "\u0110\u01A1n h\xE0ng", not "\u0110\u01A1n h\xE0ng \u2014 Kh\xE1ch h\xE0ng".\nWhere two links from one entity reach this record, that label names two sections\nand each takes its own link\'s label as a qualifier \u2014 `H\u1ED3 s\u01A1 (\u0110\u01A1n h\xE0ng)` and\n`H\u1ED3 s\u01A1 (\u0110\u01A1n g\u1ED1c)`.\n\nIn that line `documents:` is a `RecordExpectedSet`, and `lines:` a `RecordChildren` over the rows\nthis record owns \u2014 `history:` where they NAME it instead, as their `party` or their `identity`, capped at its latest 5\nwhere the record is a profile (`latest 5 of N by <day>`, the whole of them behind the foot); a required set\nwhose entries are a CHILD entity carrying files takes `kind="files"`, and the entity\'s own\nmulti-select takes `kind="items"`, since nothing attaches to an option.\n\n**A section that knows its size says so.** Where a `measure` on the record is a\n`count` rollup over the very link a rows section hangs on, the limit it is read\n`against` is how many rows that section is OWED: the printout adds `\u2014 expects\n<limit>` and the screen draws that many ghost rows until the first one lands,\ninstead of "nothing here" two bands under a count saying three are outstanding.\n\nA figure printed as `Total (USD)` is MONEY in the currency named \u2014 a `formula`\nstates it in `formula.currency`, and a `rollup` or a `lookup` inherits it from the\nfield it reads, so read those brackets: a rate that should be in dollars and is\nprinted bare will be drawn in the workspace\'s own money. Where the entity carries\na `currency` role, the ROW\'s code outranks the field\'s on every line that states\none, and a line stating none falls back to the field\'s.\n\n**A record surface can be OPERATED, and that is the default.** Every screen with\na record gets a workflow that writes its editable facts \u2014 every field a person\nstates \u2014 so the record\'s values are edited in place and its stage is advanced\nfrom the progress section. `"writes": false` makes one screen a reader: use it\nfor a screen over rows another desk owns, never as the default. A desk nobody\ncan act on is a viewer of state somebody must go and set somewhere else. A\nreader keeps the acts that only READ the row \u2014 its papers and its `export` \u2014\nand refuses every one that writes it: the quick column, the agent act, the\nhand-off on any surface, and the file an `import` would mint rows from.\n\n**`"writes": "children"` splits the record from the rows under it.** The record\nis drawn exactly as `false` draws it \u2014 the same refusals, and the register opens\nno row of its own \u2014 while every section listing the rows that record OWNS keeps\nits Add, its drawer and its own editor. Use it where one desk states the plan\nand another logs against it: the hours typed under a period the office set, the\nitems ticked under a job somebody else opened.\n\n**Which facts those are is the FIELD\'s answer.** Text, number, date, yes/no and\nselect are typed or picked. A `"cardinality": "one"` link is RE-POINTED, through\na picker over the target entity named by its `display_field_aliases` \u2014 else the\ntarget\'s `identity` \u2014 narrowed by what the reader types and carrying that\nentity\'s own `read_scope`, so a link the plan gives neither column is read\ninstead of offering an empty list. A files field is ATTACHED TO and DETACHED\nFROM, as the delta rather than the pile, so two readers filing at once each keep\ntheir file. A many-link, an `autonumber` and every computed field are read: the\nfirst is a list a fact cannot state, and the rest are the platform\'s to write.\n\n**A one-link is that fact WHATEVER its sync.** `sync_both_ways` is one relation\nwith a field on each side, and the sides are not the same surface: the record\nthat names ONE row states it as a fact, and the MANY side is the register on the\nother entity\'s record. Declaring both directions does not give this record a\nregister of the single row its own fact already names.\n\n**The write is the record SURFACE\'s.** Its inputs are the fields that surface\noffers to save \u2014 the facts with an editor, the lifecycle its ladder advances, a\nfiles section over exactly one stated field, and what the BAND states rather than\nreads: its countdown\'s date, and the limit under a meter where that limit is a\ncolumn a person types \u2014 never every field a person could in principle type. What\nthe header states, a limit a level folds into a meter among the FACTS, a required\nset with a section of its own and the pile a page\'s mark draws are read there, so\nno input is declared for them. A second screen over the same entity draws no\nsurface of its own and adds nothing.\n\nWhere two operable screens of one plan reach the same entity, `scaffold check`\nnames them and the apps they belong to: two desks writing one record is a\ndecision, and the split is stated on the FIELD in each app\'s\n`package.json#lotics.writes` rather than left to whoever edits second.\n\nThat workflow lands in the app as source, like every other: its body in\n`src/workflows/update_<entity>.ts` and its declaration in\n`package.json#lotics.workflows`. The two together are the binding \u2014 `lotics app\ndeploy` pushes whatever differs from what it last saw live \u2014 so the write is\nversion-controlled and travels with the app rather than being bound by hand\nafterwards.\n\nA `custom` screen declares its slots under `roles` (slot \u2192 role) and they bind\nthe same way:\n\n```jsonc\n{ "alias": "readings", "label": "Readings", "shape": "custom", "entity": "reading",\n "roles": { "subject": "identity", "reading": "measure" } }\n```\n\n## Applying packages\n\n`apply` copies published packages into the workspace AFTER the model\'s own\ntables exist \u2014 apps over the tables you just described, and any tables of their\nown they still need. Ordered, and run by `lotics setup` and `lotics scaffold\napply` alike.\n\n```jsonc\n"apply": [\n {\n "package": "apg_k3nf82ldpq",\n "bind": { // optional \u2014 which of YOUR tables each entity is\n "company": { "label": "Customers", "fields": { "name": "Company name" } }\n },\n "no_sample_data": true // optional\n }\n]\n```\n\n`bind` is keyed by the package\'s entity alias and holds the LABELS this\nworkspace uses: scaffold adopts by label, so binding points the package at the\ntables the model created instead of a second set beside them. Only naming\nmoves \u2014 a bound field must be the TYPE the package declares, or the copy is\nrefused. `lotics library list` is the shelf, and `lotics library show <apg_id>`\nlists the aliases to bind.\n\nEntries run in the order they are written, because a later one may bind onto a\ntable an earlier one created. **A refused entry stops the run and the entries\nbefore it stay** \u2014 they are separate copies, committed as they land, so the\nrefusal names them rather than leaving a caller to re-run the file and copy them\ntwice.\n\n### 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 verb binds through it: `apply`, `diff`, `app create --from`,\n`app regenerate --from` and `workspace build` take the remembered id first and\nfall back 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: `diff` prints one line under the alias\n(`order.state: label: model "Stage" \xB7 workspace "Giai \u0111o\u1EA1n"`), and `apply` binds\nthe thing it has always meant, reports the move and names the command that makes\nthe two agree \u2014 which side is right is yours to decide, not a reason to refuse\nthe 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, and\n`lotics scaffold unbind <kind> <alias>` forgets the binding \u2014 after which the\nnext apply creates a new one and nothing joins it to what was there.\n\n## Presets\n\nA preset is a trade\'s model, published to be READ. An assistant reads it, asks\nat most two questions, picks a variant and writes a `model.json` from it \u2014\nnothing is copied, and a preset is a file rather than anything a workspace\ninstalls.\n\n```jsonc\n"preset": {\n "name": "Field service",\n "description": "Jobs, the crew that runs them, and what each one billed.",\n "questions": ["Do you dispatch crews, or one person per job?"], // at most 2\n "variants": {\n "crews": {\n "when": "work is dispatched to crews rather than to one person",\n "entities": [ /* tables this branch ADDS */ ],\n "fields": { "job": [ /* fields this branch ADDS to `job` */ ] }\n }\n }\n}\n```\n\nVariants are **additive only**: a branch adds entities and fields and never\nremoves them, so the base is a model in its own right rather than a draft.\n`lotics scaffold check` proves the base AND every variant merged onto it, so a\npreset ships with every branch already proven \u2014 the branch nobody took is the\none that fails in the workspace of whoever takes it.\n\n`preset` is not scaffolded. `lotics setup` and `lotics scaffold apply` ignore\nit and create the base model\'s tables.\n\n`lotics scaffold export` prints a workspace that already works as one of these\nfiles \u2014 the starting point for a preset or for another business\'s model, never a\nsource of truth: it carries one business\'s words and stops describing that\nworkspace the moment either changes.\n\n## Starting from a preset\n\n`lotics library list` is the shelf of them and `lotics library show <slug>`\nprints one whole: its questions, every table as `alias \xB7 label` with each field\nas `alias:type`, and each variant as `slug \xB7 when` followed by the tables and\nfields that branch adds. When one of them is the trade in front of you, do not\ntranscribe it \u2014 name it:\n\n```jsonc\n{\n "from": "field_service",\n "variants": ["crews"],\n "rename": { "job": { "label": "\u0110\u01A1n h\xE0ng", "fields": { "code": "M\xE3 \u0111\u01A1n" } } },\n "entities": [ /* a table this business has that the preset does not */ ],\n "rows": { "job": [ { "ref": "j1", "fields": { "code": "J-1" } } ] },\n "field_roles": { "job": { "code": "identity" } },\n "write_rules": { "customer": { "natural_key": ["email"] } },\n "apps": [ /* the screens this business\'s apps will have */ ]\n}\n```\n\n- **`from`** is the preset\'s SLUG \u2014 its own file name, a lowercase slug. Naming\n it is what makes `entities` optional; every other rule on this page is\n unchanged, because the file is resolved into the full form and then checked and\n applied exactly as one. A slug nothing serves is refused with the ones there\n are, never resolved against something else.\n- **`variants`** names the branches to merge onto the base, in order. Pick the\n one whose `when` describes what the person said; a slug the preset does not\n declare is refused rather than ignored.\n- **`rename`** is keyed by the preset\'s entity alias and holds the labels this\n business uses \u2014 the same shape `apply[].bind` takes, and the same rule: only\n naming moves. An alias the preset does not declare, and a label that is\n already another table\'s, are both refused.\n- **`entities`** are added after the rename, already in this business\'s own\n words.\n- **`rows`**, **`field_roles`**, **`write_rules`**, **`apps`** and **`apply`**\n mean exactly what they mean in the full form \u2014 `"rows"` are this business\'s\n real first records,\n `"field_roles"` may name the preset\'s fields as well as its own (a role the\n preset declares itself is kept unless this file names the same field, or\n clears it with `null`), `"write_rules"` the create-time clauses on either\n (an entity this file names replaces the preset\'s whole entry for it),\n `"apps"` the screens it will have (a preset carries none), `"apply"` the\n packages copied in once its tables exist.\n\nThis is the ONE thing on this page that needs the network: `check` reads the\npreset it names, once. Everything after that read is the same offline check.\n\nWrite the full form when no preset is the trade.\n\n## A complete model\n\n```json\n{\n "entities": [\n {\n "alias": "customer",\n "label": "Customers",\n "singular": "Customer",\n "fields": [\n { "alias": "name", "label": "Name", "type": "text", "required": true },\n {\n "alias": "tier",\n "label": "Tier",\n "type": "select",\n "options": [\n { "alias": "standard", "label": "Standard", "color": "slate" },\n { "alias": "gold", "label": "Gold", "color": "amber" }\n ],\n "default": ["standard"]\n },\n {\n "alias": "orders",\n "label": "Orders",\n "type": "select_record_link",\n "target_entity": "order",\n "cardinality": "many",\n "sync_both_ways": true,\n "paired_field_alias": "customer",\n "display_field_aliases": ["code"]\n },\n {\n "alias": "total_ordered",\n "label": "Total ordered",\n "type": "rollup",\n "source_field_alias": "orders",\n "aggregate_option": { "operation": "sum", "field_key": "amount" }\n }\n ],\n "views": [\n {\n "alias": "gold",\n "label": "Gold customers",\n "filters": {\n "node_type": "condition",\n "type": "select",\n "field_key": "tier",\n "operator": "has_any_of",\n "value": ["gold"]\n },\n "sort": [{ "field_key": "name", "order": "asc" }]\n }\n ]\n },\n {\n "alias": "order",\n "label": "Orders",\n "singular": "Order",\n "fields": [\n { "alias": "code", "label": "Order no.", "type": "text", "unique": true },\n { "alias": "placed_on", "label": "Placed on", "type": "date", "format": "date" },\n {\n "alias": "amount",\n "label": "Amount",\n "type": "number",\n "format": "currency",\n "currency": "VND"\n },\n {\n "alias": "total",\n "label": "Total with VAT",\n "type": "formula",\n "formula": { "expression": "{amount} * 1.1", "format": "currency", "currency": "VND" }\n },\n {\n "alias": "customer",\n "label": "Customer",\n "type": "select_record_link",\n "target_entity": "customer",\n "cardinality": "one",\n "sync_both_ways": true,\n "paired_field_alias": "orders",\n "display_field_aliases": ["name"]\n }\n ]\n }\n ],\n "roles": [{ "alias": "sales", "label": "Sales" }],\n "field_roles": {\n "customer": { "name": "identity" },\n "order": { "code": "identity", "placed_on": "when", "amount": "amount", "customer": "party" }\n },\n "apps": [\n {\n "alias": "customers",\n "name": "Customers",\n "screen": { "alias": "customers", "label": "Customers", "shape": "party_register", "entity": "customer" }\n },\n {\n "alias": "orders",\n "name": "Orders",\n "screen": { "alias": "orders", "label": "Orders", "shape": "transaction_ledger", "entity": "order" }\n }\n ],\n "rows": {\n "customer": [\n { "ref": "acme", "fields": { "name": "Acme Trading", "tier": "gold" } },\n { "ref": "bluebird", "fields": { "name": "Bluebird Foods", "tier": "standard" } }\n ],\n "order": [\n {\n "ref": "so_1001",\n "fields": {\n "code": "SO-1001",\n "placed_on": "@month-start+2",\n "amount": 4200000,\n "customer": "customer:acme"\n }\n },\n {\n "ref": "so_1002",\n "fields": {\n "code": "SO-1002",\n "placed_on": "@today-3",\n "amount": 1150000,\n "customer": "customer:bluebird"\n }\n }\n ]\n }\n}\n```\n\n`lotics scaffold check` on this file reports\n`2 tables, 9 fields, 2 links, 1 view, 1 role, 4 rows, 2 apps`, then\nthe plan:\n\n```\nCustomers\n Customers \u2014 party register over Customers (2 rows) \xB7 page (default) \xB7 tabs: none (default)\n identity Name \xB7 mark (none \u2014 no field declares "mark") \xB7 contact (none \u2014 no field declares "contact") \xB7 worth (none \u2014 no field declares "measure") \xB7 risk (none \u2014 no field declares "verdict")\n presentation: leads with nothing (default), roomy (default) \xB7 above: rows (default)\n record: header (Name) \xB7 history: Orders (latest 5 (default) of N by Placed on) (Order no. \xB7 Placed on \xB7 Amount (VND)) \xB7 facts (Tier \xB7 Total ordered (VND))\nOrders\n Orders \u2014 transaction ledger over Orders (2 rows) \xB7 drawer (default) \xB7 tabs: none (default)\n when Placed on \xB7 amount Amount (VND) \xB7 reference Order no. \xB7 party Customer \xB7 classification (none \u2014 no field declares "lifecycle") \xB7 document (none \u2014 no field declares "expected_set" or "verdict")\n order: Placed on, newest first (default) \xB7 presentation: leads with the figure (default), roomy (default) \xB7 above: Amount (VND) (default)\n record: header (Order no. \xB7 Placed on \xB7 Amount (VND)) \xB7 facts (Placed on \xB7 Total with VAT (VND) \xB7 Customer)\n party: Customers \u2014 opens from Customer (profile: header (Name) \xB7 history: Orders (latest 5 (default) of N by Placed on) (Order no. \xB7 Placed on \xB7 Amount (VND)) \u2014 foot: the whole register, narrowed to this record \xB7 facts (Tier \xB7 Total ordered (VND)))\nWho writes what:\n Customers (customer, one: Customer): Customers \xB7 written from the rows of Orders: facts\n Orders (order, one: Order): Orders\n```\n\nA `party:` line is the record a NAME on a row opens. A party is not a job, so it\ngets no app of its own \u2014 but a clerk reading the orders book presses the\ncustomer and corrects the phone number there, so the app whose rows name a party\ncarries that party\'s own record beside its register\'s. It is printed in the same\ngrammar the `record:` line is, because it is the same kind of surface: a profile,\non a page, its histories capped. Which cells are the door is what `opens from`\nsays \u2014 `the name` where the register\'s subject IS the party.\n\nThe last block is the WRITERS matrix \u2014 one line per record surface the plan\nleaves operable, the word one of its rows is called, and the app whose register\nopens it. Part of the verdict rather than a diagnostic beside it: which desk\nchanges a table is answerable from the plan, and two apps on one line is the\nsplit to state in each of their `package.json#lotics.writes`. A\n`written from the rows of \u2026` clause is the other reach: the party that register\nnames, and what the app can change on it \u2014 counted apart, because several\nregisters naming one account is the model working as designed rather than a\nsplit to declare.\n\nEvery `(none)` is a slot no field fills, and it says WHY: no field declares the\nrole, or several could and the plan named none of them (`(none \u2014 a, b; name\none)`). Read it as the screen a person will see. `above: \u2026 (default)` is the band\nnobody stated \u2014 the shape sums the amount its slot draws, and a plan naming its\nown figures loses the word. Neither\nentity carries a stage, a required set or files, so the rest is facts \u2014 except\nthe sections neither entity declares: the customer\'s `history:` is the orders\nthat name it as their party, and the order\'s `via Orders:` is the customers whose\n`Orders` link names it. **A link is a section, not a fact**, on the side it\npoints AT and on the side that holds it: `Customers.Orders` therefore leaves the\ncustomer\'s facts, because the section below already lists the same relation and\na link fact can only ever show the first of them. Both records state their name\nin the header and nowhere else \u2014 a ledger\'s columns are the date and the figure,\nso `Order no.` is on no column of that list and is still what the drawer behind\na line is called. `Total ordered (VND)` is the rollup inheriting the currency of\nthe column it sums: money is read off the model, never off the field\'s own line.\n';
|
|
66379
|
+
var model_reference_default = '# The Lotics workspace model (`model.json`)\n\nOne JSON file describing the tables, fields, options, views, roles and first rows\na workspace starts with. `lotics scaffold check model.json` proves it offline \u2014\nno account, no network. `lotics setup model.json --email you@company.com` creates\nthe account and applies it. `lotics scaffold apply model.json` applies it again,\ninto the workspace the credential names.\n\n**There are two forms of this file.** The full one, below, spells the model out.\nThe `from` one names a published preset and carries only what this business\ndiffers by \u2014 see \xA7 Starting from a preset, and prefer it whenever a preset fits\nthe trade.\n\nApps are PLANNED here and built afterwards: `apps` names each app\'s screens as a\nshape over an entity, checked against the roles `field_roles` gives its fields,\nso the plan is refused before anyone builds a screen (\xA7 Apps and screens). The\nbuilt app lives in the workspace; publishing that workspace as a package is how\nit ships.\n\n## The rules\n\n- **At least one entity, at most 50.** More tables than that is a data model\n being designed, not scaffolded \u2014 scaffold the rest in a second call.\n- **Adoption is explicit.** `lotics setup` REFUSES an entity whose `label`\n already names a table in the workspace, naming every colliding label at once.\n `lotics scaffold apply` adopts those tables and adds the fields, options and\n views they are missing. Nothing is ever modified or deleted, so applying the\n same model twice creates nothing the second time.\n- **Adoption is by LABEL, not alias.** Change an entity\'s `label` and the next\n run asks for a NEW table beside the old one. Renaming a FIELD is\n `lotics field rename <table> <field> "<new label>" --model <this file>`, which\n moves the platform, this file and every app bound to it together; deleting is\n `lotics run delete_table`. Neither goes through the file.\n- **The file\'s own majority is the language.** A model names no locale \u2014 which\n language it is in is what it mostly says, and `scaffold check` notes the label\n written the other way. The generated screens read the kit\'s pack, and a\n generated WRITE cannot: its refusals run on the server, so they are worded in\n that same majority. Mix the two and the workspace answers in two languages.\n- **`lotics scaffold diff model.json` says where the file and the workspace have\n come apart**, joined on label, entity then field \u2014 the join adoption itself\n makes. It exits 1 on any difference, so a model that is about to be published\n as a preset carries the labels in use rather than the ones it was written with.\n- **Rows land only where every bound table is empty.** One table already holding\n records and no rows are written anywhere, and the result says\n `rows_skipped: true`: sample rows landing among a customer\'s real ones cannot\n be told apart from them.\n- **After the first run the WORKSPACE is the source of truth.** The file is an\n authoring input, not a mirror \u2014 scaffold never deletes what the file stopped\n naming.\n- **`lotics scaffold check` decides all of it offline**, and reports every\n problem in one run rather than the first: an alias that resolves to nothing, a\n link whose pair is not symmetric, and the rows themselves \u2014 a field the entity\n does not declare, an option alias the field does not declare, a link naming no\n row in the file, a `ref` used twice, a date that is not one, a value on a\n platform-computed field, and a files cell that is neither a relative path\n beside this file nor a `fil_` id.\n\n## Top level\n\n```jsonc\n{\n "entities": [ /* the tables */ ],\n "roles": [ /* workspace groups to create */ ], // optional\n "templates":[ /* inline html / email templates */ ], // optional\n "rows": { /* first records, keyed by entity alias */ }, // optional\n "field_roles": { /* the reporting role each field plays, keyed by entity then field */ }, // optional\n "write_rules": { /* what a CREATE finds, defaults and refuses, keyed by entity */ }, // optional\n "apps": [ /* the screens each app will have, as shapes over entities */ ], // optional\n "apply": [ /* published packages to copy in afterwards */ ], // optional\n "preset": { /* a trade\'s branches, for a PUBLISHED model */ } // optional\n}\n```\n\nThe other form names a preset instead of restating one:\n\n```jsonc\n{\n "from": "field_service", // the preset this model starts from, by slug\n "variants": ["crews"], // optional \u2014 its branches to merge in, in order\n "rename": { // optional \u2014 what THIS business calls each table\n "job": { "label": "\u0110\u01A1n h\xE0ng", "fields": { "code": "M\xE3 \u0111\u01A1n" } }\n },\n "entities": [ /* tables the preset does not declare */ ], // optional\n "rows": { /* first records, keyed by entity alias */ }, // optional\n "field_roles": { /* roles on the preset\'s fields and this business\'s own */ }, // optional\n "write_rules": { /* create-time clauses on the preset\'s entities and its own */ }, // optional\n "apps": [ /* the screens each app will have */ ], // optional\n "apply": [ /* published packages to copy in afterwards */ ] // optional\n}\n```\n\n**A model may not carry** `fixtures`, `knowledge` or `knowledge_expects`, and no\n`excel` / `word` / `pdf-form` template: each of those is content that lives in a\npublished bundle, which a model has none of. `apps` here is a plan of screens,\nnever built code. An unknown top-level key is an error, never ignored.\n\n### Aliases\n\nEvery `alias` is a lowercase slug \u2014 a letter, then letters, digits and\nunderscores (`unit_price`, `so_1001`). Aliases are how the file cross-references\nitself; they are never shown to anyone. `label` is what a person sees.\n\nLabels must be unique within their namespace \u2014 two entities, two fields on one\nentity, two options on one field, two views on one entity, two roles or two\ntemplates cannot share a label, because scaffold matches by label.\n\n## Entity\n\n```jsonc\n{\n "alias": "order",\n "label": "Orders", // the table\'s name, which names the SET it holds\n "singular": "Order", // optional \u2014 ONE of them, for the act that opens one\n "description": "\u2026", // optional\n "fields": [ /* at least one */ ],\n "views": [ /* optional; an entity with none still gets the default grid */ ],\n "read_scope": { /* optional; absent, everyone with access to the table reads every row */\n "any": [\n { "member_of": "sales" }, // a role alias this model declares\n { "field": "scope", "is": ["shared"] }, // a single select on this entity, by option alias\n { "field": "assignee", "is": "self" } // a select_member on this entity \u2014 the members it names\n ]\n }\n}\n```\n\n**`singular` is what a create says.** A register\'s own act and the panel it\nopens name the row being made \u2014 `New Order`, `H\u1ED3 s\u01A1 m\u1EDBi` \u2014 while `label` names\nthe table, so without it the button reads `New Orders`. Nothing derives it\n(English plurals are irregular), and most models need none: a Vietnamese noun is\nthe same word either way. `scaffold check` prints it beside the table under *Who\nwrites what* and NOTES a table that will name the set. An act on a child section\nkeeps the label: it adds a line to the register under the record the reader is\nalready standing in.\n\n**`read_scope` is a ROW rule, enforced by the platform.** A row is readable when\nANY clause holds: the reader is in that role\'s group, the named select carries\none of those options, or the named `select_member` column names the reader\n(`"is": "self"`, on a column of one member or several). It is resolved at\nscaffold 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. `any` may not be empty (a rule\nnobody satisfies hides the table), and every role alias and option alias in it\nmust be one this model declares. Scaffold 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**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 `alias`, `label`, an optional `description`, and an optional\n`required` \u2014 advisory only, read by app forms and workflows; the table itself has\nno required constraint. `label` may not contain `{` or `}` (formulas reference\nfields by label at the platform level).\n\n`default` is the value pre-filled into a NEW record. It applies on create only;\nexisting records are never backfilled. Only the types listed below accept one.\n\n### `text`\n\n```jsonc\n{ "alias": "name", "label": "Name", "type": "text",\n "unique": false, // optional \u2014 require distinct values\n "format": "text", // optional \u2014 "text" | "link" | "markdown"\n "default": "" } // optional\n```\n\n### `number`\n\n```jsonc\n{ "alias": "amount", "label": "Amount", "type": "number",\n "format": "currency", // optional \u2014 "number" | "currency" | "percentage"\n "currency": "VND", // optional \u2014 ISO 4217\n "default": 0 } // optional\n```\n\n`format` is what the number IS, and every surface reads it: `currency` prints as\nmoney in the code the row or the field states, `percentage` as a whole percent\nwith its sign. An ABSENT number is drawn absent \u2014 the one exception is a\n`sum` or count rollup the plan reads as a **`measure`**: that is the thing\naccumulated toward a bound, so nothing accumulated yet is zero and the meter\ndraws it. The same rollup read as an `amount` keeps its blank, and so does every\nother role: nothing added to what a row is WORTH means unpriced, not free. A\n`min`, an `avg`, a percentage of nothing and every FORMULA stay blank in any\nrole, because none of them has an answer to give. This is why the pair on one\nscreen reads two ways \u2014 what has come in against what is owed \u2014 and why a\nmeasure\'s own LIMIT, an amount, leaves an unquoted row out of the count rather\nthan reporting it as nothing collected. **AND WHERE THAT LIMIT IS ABSENT THERE IS\nNO LEVEL AT ALL**: a level is a reading AGAINST a bound, so a row that states no\nbound draws nothing \u2014 cell, fact and all \u2014 rather than a numerator whose whole\nmeaning was the comparison. "Collected 0" beside a blank total reads as money\nagainst a job worth nothing. A measure the model gives no limit is a plain figure\nand is unaffected. A share is stored in percent units \u2014 68.1 is 68.1 % \u2014 and the\ncolumn, the fact behind it, the meter it is judged by and the figure over the\nregister all say so.\n\n### `date`\n\n```jsonc\n{ "alias": "placed_on", "label": "Placed on", "type": "date",\n "format": "date", // optional \u2014 "date" | "datetime" | "date_range" | "datetime_range"\n "timezone": "Asia/Ho_Chi_Minh", // optional \u2014 IANA name\n "derive_from": "created_at", // optional \u2014 "created_at" | "updated_at"; makes the field read-only\n "default": "2026-01-01" } // optional; refused together with derive_from\n```\n\n### `boolean`\n\n```jsonc\n{ "alias": "paid", "label": "Paid", "type": "boolean", "default": false }\n```\n\n### `select`\n\n```jsonc\n{ "alias": "tier", "label": "Tier", "type": "select",\n "options": [ // at least one\n { "alias": "standard", "label": "Standard", "color": "slate" },\n { "alias": "gold", "label": "Gold", "color": "amber" }\n ],\n "multi": false, // optional\n "default": ["standard"] } // optional \u2014 option ALIASES; one unless multi\n```\n\n`color` is one of: `red`, `orange`, `amber`, `yellow`, `lime`, `green`,\n`emerald`, `teal`, `cyan`, `sky`, `blue`, `indigo`, `violet`, `purple`,\n`fuchsia`, `pink`, `rose`, `slate`, `gray`, `zinc`, `neutral`, `stone`.\n\n### `select_member`\n\nA person picker over the workspace\'s members. No default: a model cannot name\nmembers of a workspace that does not exist yet.\n\n```jsonc\n{ "alias": "owner", "label": "Owner", "type": "select_member", "multi": false }\n```\n\n### `select_record_link`\n\n```jsonc\n{ "alias": "customer", "label": "Customer", "type": "select_record_link",\n "target_entity": "customer", // an entity alias this model declares\n "cardinality": "one", // optional \u2014 "one" | "many" (default "many")\n "sync_both_ways": true, // optional \u2014 keep a paired field on the target\n "paired_field_alias": "orders", // the partner field ON THE TARGET entity\n "display_field_aliases": ["name"] } // optional \u2014 what the link shows / the picker\'s columns\n```\n\nA two-way link is declared on BOTH sides, each naming the other as its\n`paired_field_alias`; the pair must be symmetric or the model is refused.\n\n**A single-valued link needs no partner.** `"cardinality": "one"` on its own is a\nlink that holds one row \u2014 one customer on an invoice, one project on a device \u2014\nand nothing is created on the target. The mirror invariant belongs to a PAIRED\nlink: pair a link when the target\'s own record should list what points at it, and\nleave it unpaired when it should not. Either way the record plan draws the\nrelation as a section on the side it points at, so an unpaired link costs the\ntarget nothing.\n\n### `files`\n\n```jsonc\n{ "alias": "attachments", "label": "Attachments", "type": "files" }\n```\n\n### `formula`\n\n```jsonc\n{ "alias": "total", "label": "Total", "type": "formula",\n "formula": {\n "expression": "{amount} * 1.1", // fields on THIS entity, by alias, in braces\n "output_type": "number", // optional \u2014 what it YIELDS: "number" | "text" | "date" | "datetime" | "boolean"\n "format": "currency", // optional \u2014 how that result is DRAWN: "number" | "currency" | "percentage" | "link"\n "currency": "VND" // optional\n } }\n```\n\n`output_type` is what the expression answers WITH; `format` is how it is printed.\nThe platform infers the result at write time and ignores what you declare, so\nthis is a statement the offline checks read \u2014 which is what lets a role or a\nscreen clause accept a computed value: a caption over a derived name\n(`output_type: "text"`), a period over a settled date (`"date"`). A formula\ndeclaring neither says nothing about its result, and every rule that needs one\nrefuses it by name. `lotics scaffold export` writes the result a live workspace\ncomputed, so exporting a workspace fills these in.\n\n### `rollup`\n\nAggregates the records reached through a link on this entity.\n\n```jsonc\n{ "alias": "total_ordered", "label": "Total ordered", "type": "rollup",\n "source_field_alias": "orders", // a select_record_link field on THIS entity\n "aggregate_option": {\n "operation": "sum", // count | sum | avg | median | min | max | range |\n // empty | filled | percent_empty | percent_filled |\n // unique | percent_unique |\n // earliest | latest | date_range |\n // checked | unchecked | percent_checked |\n // percent_unchecked\n "field_key": "amount" // a field ALIAS on the linked entity ("count" may omit it)\n },\n "filter": { /* optional \u2014 see Views; every field_key is an alias on the LINKED entity */ } }\n```\n\nThe operation must be one the aggregated field\'s type allows \u2014 `sum` over a\nnumber, `earliest` over a date, `filled` over anything.\n\n### `lookup`\n\nDisplays a field from the linked records.\n\n```jsonc\n{ "alias": "customer_tier", "label": "Customer tier", "type": "lookup",\n "source_field_alias": "customer", // a select_record_link field on THIS entity\n "lookup_field_alias": "tier", // a field alias on the linked entity\n "order_by": { "field_key": "placed_on", "direction": "desc" } } // optional \u2014 pick the single extreme row\n```\n\n### `autonumber`\n\n```jsonc\n{ "alias": "seq", "label": "No.", "type": "autonumber",\n "prefix": "SO-", // optional \u2014 ignored when template is set\n "padding": 4, // optional \u2014 1..20, zero-pads the integer\n "template": "SO-{YEAR}-{N:4}" } // optional \u2014 {N}, {N:W}, {YEAR}, {YEAR:2}, {MONTH}, {DAY}\n```\n\n## Views\n\nSaved views live under the entity they belong to. Every field reference is a\nfield ALIAS on that entity.\n\n```jsonc\n{\n "alias": "gold",\n "label": "Gold customers",\n "description": "\u2026", // optional\n "columns": [ // optional \u2014 omit to show every field\n { "field_alias": "name", "visibility": "visible", "width": 240 },\n { "field_alias": "tier", "visibility": "hidden" }\n ],\n "filters": { // optional\n "node_type": "group",\n "logic": "and", // "and" | "or"\n "children": [\n { "node_type": "condition", "type": "select", "field_key": "tier",\n "operator": "has_any_of", "value": ["gold"] }\n ]\n },\n "sort": [ { "field_key": "name", "order": "asc" } ], // optional; order is "asc" | "desc" | null\n "summary": { "amount": "sum" }, // optional \u2014 field alias \u2192 footer operation\n "frozen_columns": 1 // optional\n}\n```\n\nA condition\'s `type` is the field\'s type and its `operator` is one that type\nadmits \u2014 `has_any_of` / `has_none_of` / `has_all_of` / `is_empty` /\n`is_not_empty` for a select, `equals` / `greater_than` / `less_than` for a\nnumber, `on` / `before` / `after` / `between` for a date, `contains` /\n`is_any_of` for text. A select condition\'s `value` names option ALIASES.\n\n`columns`, when present, is exhaustive and must not be empty: a view renders\nexactly the entries it holds. Omit the key to show every field.\n\n## Roles\n\nA role becomes a workspace group. Members are added afterwards, in the app.\n\n```jsonc\n{ "alias": "sales", "label": "Sales" }\n```\n\n## Templates\n\nOnly inline `html` and `email` templates \u2014 the rest are file-backed and a model\nhas no bytes. An `html` template renders to a PDF when a workflow generates\nfrom it; `{{name}}` is filled from the workflow\'s data.\n\n```jsonc\n{ "alias": "order_ack", "label": "Order acknowledgement", "type": "email",\n "content": "<p>Hello {{customer}}\u2026</p>" }\n```\n\nA paper that has to look like a counterparty produced it \u2014 an official letter,\nan acceptance minute, a supplier\'s bill \u2014 is the same `html` template with a\nshell around the body: a letterhead, a reference line, a seal and a signature\nblock, and paper grain over everything. One shell, many bodies; the data is the\nonly thing that changes, so a workflow can re-issue it over any record.\n\n```jsonc\n{ "alias": "cong_van", "label": "C\xF4ng v\u0103n", "type": "html",\n "content": "\u2026the page below, as one JSON string\u2026" }\n```\n\n```html\n<style>\n .sheet{position:relative;width:718px;padding:44px 58px 30px;background:#fbfaf6;color:#111;font:14.2px/1.5 \'Liberation Serif\',serif}\n .grain{position:absolute;inset:0;opacity:.34;mix-blend-mode:multiply;background:url("data:image/svg+xml;utf8,<svg xmlns=\'http://www.w3.org/2000/svg\' width=\'140\' height=\'140\'><filter id=\'f\'><feTurbulence baseFrequency=\'.9\' numOctaves=\'2\'/><feColorMatrix values=\'0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 .35 0\'/></filter><rect width=\'140\' height=\'140\' filter=\'url(%23f)\'/></svg>")}\n .top{display:flex;text-align:center;font-size:13.4px} .top>div{flex:1} .u{display:inline-block;border-bottom:1px solid #111;font-weight:700}\n .ref{display:flex;text-align:center;font-size:13.4px;margin-top:6px} .ref>div{flex:1} .ref .r{font-style:italic}\n h1{text-align:center;font-size:15.6px;margin:26px 0 18px} p{text-align:justify;text-indent:26px;margin:0 0 9px}\n .sig{display:flex;margin-top:20px} .sig .l{flex:1} .sig .r{width:290px;text-align:center;position:relative}\n .sig .nm{font-weight:700;margin-top:96px} .seal{position:absolute;left:4px;top:8px;width:166px;height:166px;opacity:.66;mix-blend-mode:multiply;transform:rotate(-17deg)}\n </style>\n <div class=\'sheet\'><div class=\'grain\'></div>\n <div class=\'top\'><div><b>{{issuer_parent}}</b><br><span class=\'u\'>{{issuer}}</span></div>\n <div><b>C\u1ED8NG H\xD2A X\xC3 H\u1ED8I CH\u1EE6 NGH\u0128A VI\u1EC6T NAM</b><br><span class=\'u\'>\u0110\u1ED9c l\u1EADp - T\u1EF1 do - H\u1EA1nh ph\xFAc</span></div></div>\n <div class=\'ref\'><div>S\u1ED1: {{number}}</div><div class=\'r\'>{{place}}, ng\xE0y {{day}} th\xE1ng {{month}} n\u0103m {{year}}</div></div>\n <h1>{{title}}</h1>\n <p>K\xEDnh g\u1EEDi: {{recipient}}.</p>\n {{{body}}}\n <div class=\'sig\'><div class=\'l\'><b>N\u01A1i nh\u1EADn:</b><br>- Nh\u01B0 tr\xEAn;<br>- L\u01B0u VT.</div>\n <div class=\'r\'><img class=\'seal\' src=\'{{seal_url}}\'><b>{{signer_title}}</b><div class=\'nm\'>{{signer}}</div></div></div>\n </div>\n```\n\n`lotics preview <file.html>` renders any such page to a PNG the way a demo\'s\nprops are made, sized to its content, so a paper can be looked at before it is\nput in a template.\n\n## Rows\n\nFirst records, keyed by entity alias. Up to 200 rows per entity and 2000 across\nthe model, attaching at most 2000 documents between them \u2014 a real data set\nbelongs in an import, not a model.\n\n```jsonc\n"rows": {\n "customer": [\n { "ref": "acme", "fields": { "name": "Acme Trading", "tier": "gold" } }\n ]\n}\n```\n\n`ref` is a local handle (lowercase letters, digits, underscores) that other rows\'\nlink fields address. It is never persisted.\n\nA `files` cell attaches documents: paths relative to this file (no `..`, never\nabsolute), which `check` proves exist and `apply` uploads into the workspace\nbefore any row is written \u2014 a paperwork business seeds its papers with its\nrows. The server accepts only `fil_` ids of files this workspace owns, which is\nwhat the upload leaves behind. After a run that wrote rows, 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 |\n| `select_member` | `"self"` only \u2014 the person applying the model |\n| `files` | paths beside this file \u2014 `["scans/pccc_letter.png"]` \u2014 uploaded by `apply`/`setup` before the rows are posted; or `fil_` ids of files already in this workspace |\n| `formula`, `rollup`, `lookup`, `autonumber` | not allowed \u2014 the platform writes these |\n\n**Documents can be attached on their own, afterwards.** Rows land only into empty\ntables, so a model whose binaries were added after the first apply has no second\napply to carry them: `lotics scaffold apply model.json --documents` writes ONLY\nthe `files` cells, onto the records the first run created, found by each row\'s\nown `ref` in the binding the workspace holds \u2014 so a row added to or removed from\nthe file since changes nothing about the rest. Running it twice attaches nothing\nthe second time: the same path keeps the same file, and a cell is a set.\n\n### Relative dates\n\nA date cell holds a literal `YYYY-MM-DD`, or an expression relative to the day\nthe model is applied, so a screen that opens on "this month" is not empty a month\nlater:\n\n- `@today` \u2014 the day of the run, in the workspace\'s timezone\n- `@month-start` \u2014 the 1st of that month\n- either with a whole-day offset: `@today-14`, `@month-start+9`\n\n`@month-start` exists because `@today-N` cannot promise a month: applied on the\n2nd, `@today-3` lands in the previous one.\n\n## Field roles\n\n`field_roles` names the reporting role a field plays on its entity \u2014 keyed by\nentity alias, then field alias \u2014 so every screen over the entity agrees on\nwhich column names the row and which select is the stage. A shape\'s slot binds\nto it (\xA7 Apps and screens). Like `rows` and `apps`, it is this file\'s: `check`\nproves it and the workspace never sees it. Each role sits on the types that can\nanswer it:\n\n| Role | On | Meaning |\n|---|---|---|\n| `identity` | `text`, `select_record_link`, `formula` | what a PERSON calls the row \u2014 the register\'s first column; a link where the row is "the product, at this branch"; a formula where the name is computed, and then it may not be `format`ted as a figure. NEVER an autonumber: a minted code is a `reference`. One per entity |\n| `reference` | `text`, `autonumber`, `formula` | the key the SYSTEM files the row under \u2014 an order number, a container code \u2014 drawn as the supporting line under the name rather than as a column of its own, and leading where the entity has no `identity` at all. One per entity |\n| `mark` | `files`, or a `lookup`/`rollup` that resolves to one | the row\'s picture \u2014 its own, or the linked record\'s. NEVER A COLUMN: a register draws words, so a mark is projected only where a surface leads with one \u2014 the subject rung of a picture-led grid, and a record\'s itinerary. A child entity\'s mark reaches a record\'s run and nothing else. One per entity |\n| `lifecycle` | single `select` | the ordered stages a row walks; option order is the order. Which of them END the flow is `outcomes`, which parts of the flow they group into is `phases`, and `history` keeps the day each one was reached. One per entity |\n| `category` | single `select`, or a `lookup` that resolves to one | what the row IS \u2014 a kind, a service, a book. One badge in the field\'s own colour, with no ladder behind it and no flow to advance. Often a fact about the thing the row books rather than about the row, and then it is the lookup that reads it. Repeats: a row can be of two kinds of thing |\n| `measure` | `number`, `formula`, `rollup` | a level read against a limit \u2014 see `against` and `alert`. Money where the model says so, and then it prints as money |\n| `expected_set` | `select` | its OPTIONS are the required set (documents, checks, services); an option no row has is a gap to show, not nothing. A multi-select draws as a ring and a fraction wherever it is drawn \u2014 absence is the information, and a list of what IS there says nothing about what is missing |\n| `amount` | `number`, `formula`, `rollup` | THE money of a ledger row; `signed_by` says which way it moved, `against` names the reference it is quoted off. One per entity |\n| `when` | `date`, or a `formula`/`rollup`/`lookup` that yields one | the ledger or timeline date \u2014 stated, or derived: the day money MOVED is a formula over the two columns that could hold it. `due` makes it a DEADLINE; without it the date is the plain day it is. `until` names the stage of this entity\'s own `lifecycle` at which the countdown is spent, and says `due` by saying so. One per entity |\n| `slot` | single `select`, or a `datetime` `date` | where in the DAY a row sits \u2014 the run it is read in under its day\'s heading on an itinerary. A select\'s option order IS the run\'s order; a time is ordered by the clock. A plain `date` is refused: that is the day itself. One per entity |\n| `party` | `select_record_link`, `select_member` | WHO THE ROW IS FOR \u2014 the counterparty it was transacted with, or the workspace MEMBER whose work it is (a shift, an assignment, a timesheet period). A member is drawn as the person and opens nothing: they have no record in the app. One per entity \u2014 and left off where the `identity` IS the link to them: the row is then NAMED by that record, and a strip under the title would state them twice |\n| `parent` | `select_record_link` | the record this row belongs to \u2014 a line\'s order, a paper\'s case. The parent\'s record shows these rows; the row shows the parent as a fact. One per entity, and it links to an entity this model declares |\n| `contact` | `text` | a way to reach a party \u2014 an address, a number, the town it is in. The record draws them as ONE row of links under the name, so the role sits on every field that is one of them. Repeats |\n| `currency` | single `select` whose option LABELS are ISO 4217 codes | the money THIS ROW\'s figures are in. Every amount and every money measure on the entity is then printed per row rather than in one code for the whole column, and the select is not drawn as a fact of its own. One per entity |\n| `verdict` | `boolean`, `formula` | a settled pass/fail \u2014 ticked, or computed. TRUE is the PASSING side wherever it is drawn, a register\'s risk flag included, so a field whose true means trouble is the same fact asked the other way round. One per entity |\n| `obligation` | `date` | a day something is OWED by \u2014 a cut-off, a permit expiry, a payment due. It counts down by definition. `satisfied_by` names what CLOSES it (or `until`, the stage the record stops owing it at), `label` what is owed. Repeats: a row owes several things, and a shape that fans them draws one row each |\n| `origin` | `text`, single `select`, or a one-row `select_record_link` | where the row STARTS. Declared with a `destination` \u2014 a route has two ends, and one alone is refused \u2014 and then every surface over the entity draws the two as ONE reading, `Warehouse 4 \u2192 Site B`: on the register\'s supporting line after the key, and as one column on the child registers and party histories that list these rows. The two fields are then not facts of their own, and a `columns` clause naming either end is refused. One per entity, holding ONE place |\n| `destination` | `text`, single `select`, or a one-row `select_record_link` | where the row ENDS \u2014 the other half of the `origin`\'s route, and read only with it. One per entity, holding ONE place |\n\n**`due` is what makes a `when` a deadline.** A date a row is merely filed by \u2014 the\nday a lead arrived, the day a movement happened \u2014 is neither early nor late,\nhowever long ago it was. Without the clause every desk drew every `when` as a\ncountdown and a two-month-old lead read "40 days overdue" on a record nobody was\nlate on. So a bare `when` is a plain date, and `{ "role": "when", "due": true }`\nis the one that counts down. An `obligation` needs no clause: a day something is\nowed by is a deadline already.\n\n**`until` stops a countdown where the work is DONE.** A deadline counts down so\nsomebody acts on it, and a row that has arrived needs nothing: without the clause\na delivered order is tinted for a date it met, and every settled row past its day\njoins the urgent ones in the reader\'s glance. It names one option of the entity\'s\n`lifecycle`; at that stage, at every stage past it and at every `outcome`, the\ndate is drawn as the plain day it is. On a `when` it also says the date is `due`.\n\n"One per entity" is one COLUMN, never one value on screen. A second counterparty\nor a second parent stays a ROLELESS `select_record_link`: which field fills a\nslot is the screen\'s answer (\xA7 Apps and screens), and the extra link is a fact on\nthe row with its own section on the record, headed by that link\'s own label. A\nrole naming two fields would move the ambiguity into every shape that reads it.\n\nA COMPUTED field is admitted where its RESULT is what the role needs, and\nrefused where it is not: `identity` and `reference` need `text`, `when` a `date`,\n`mark` `files`. A formula\'s result is its `output_type`; a rollup\'s and a\nlookup\'s is read through the walk. So a formula yielding a number is refused in a\nname\'s place, a lookup landing on anything but `files` is refused as a `mark`, and\na formula declaring NO result is refused by all four \u2014 naming `output_type` as\nthe remedy, because a value drawn as a type nobody stated is the wrong cell with\nnothing saying so. The result also decides the DEVICE every screen draws the value\nwith: a rollup taking the `latest` of a set of dates is drawn as a date, a lookup\nof a select as the words it holds.\n\nA bare role name is the common form. A role that is read against a SECOND field\ntakes the object form:\n\n- **`measure`** names its limit: `against` \u2014 a constant, or a field on the same\n entity the row states as a number (a `number`, or a formula or rollup whose\n result is one) \u2014 and `alert`, which side of it needs attention, `over` a\n capacity or `under` a minimum. The two come together.\n- **`measure`** names what its number COUNTS, where the unit changes how the row\n is DRAWN rather than how the figure reads: `counts: "days"` spans a stop across\n that many days of a run, so a three-night stay fills three of its days instead\n of only the one it starts on, and `counts: "hours"` is how long a stop takes\n INSIDE its day, stated beside it. Stated, never inferred \u2014 nothing about a `3`\n says whether it is nights, pallets or hours, and a quantity drawn across a week\n is a run nobody can read.\n- **`lifecycle`** names the stages that END the flow: `outcomes`, option aliases\n of that select. A row in one has ARRIVED \u2014 delivered, cancelled, written off \u2014\n so a desk draws them apart from the ladder instead of chaining them after each\n other. Not every option may be an outcome, and terminal-ness belongs here, never\n written into a stage\'s label.\n- **`lifecycle`** groups its stages into the parts of the flow they belong to:\n `phases`, each a `label` and the `stages` under it. Nine rungs down one column\n is a list nobody reads as a flow; the same rungs under three headings are three\n pieces of work, and which belong together is the business\'s answer. Every stage\n but an `outcome` falls in exactly one \u2014 a stage in two is drawn twice, a stage\n in none vanishes off the ladder \u2014 and the outcomes may be placed in a phase or\n left out, and are then read as a last part of their own.\n- **`lifecycle`** keeps every stage the row reached: `history: true`, or\n `history: "<alias>"` to name the table. A ladder with no history reports where\n a row IS and nothing about how it got there, which is the whole of what a\n reader asks a pipeline. It is declared BESIDE `phases`, and refused without\n them: the rungs a ladder draws are the live select\'s options, which nothing\n reading a file or an app\'s spec can see, so `phases` is the one statement of\n what the dates have to cover \u2014 and without it a rung the history cannot reach\n draws undated on every record with nothing anywhere saying so. The flag is the\n ONE statement: the model DERIVES a\n child table from it \u2014 the link back to this record, the stage, the moment it\n was reached and who moved it \u2014 and `check`, `apply`, `diff` and the app\n generator all see that table exactly as they see a written one. Nothing else\n declares it, and a file that also writes it by hand is refused. The generated\n create stamps the rung the flow opens at and the generated editor stamps every\n move after it; a stage changed anywhere ELSE is a rung with no date\n (`docs/platform_gaps.md`). **A first apply backfills nothing** \u2014 `apply` writes\n rows only into empty tables, and rows that existed before the flag walked their\n stages before that table existed. The honest backfill is ONE opening row per\n existing parent, at its FIRST stage, dated when that parent was created (true\n by construction); the stage it stands on NOW is not dated unless that is also\n the first. It is a create per parent \u2014 `lotics run create_records` \u2014 and\n `scaffold apply` prints it as the work it did not do.\n- **`amount`** names the REFERENCE it is read against: `against`, a field on the\n same entity the row states as a number. A price against the list it is quoted\n off, a rate against the going one \u2014 and the catalogue item\'s record draws the\n pair on one axis with the gap said in words. Never a constant (a price typed\n into the model ages with nothing to update it) and never an `alert`: a limit is\n a ceiling a row can pass, and beating a reference is the point.\n- **`amount`** names the select that SIGNS it: `signed_by`, a select on the same\n entity, and `outflow`, the options of that select under which the amount is\n money going out. Without it every shape over the ledger adds both directions\n together: a month whose net was small reads as the total of everything that\n moved, and the trend plots one rising line. The two come together, and\n `outflow` is some of that select\'s options, never all.\n- **`expected_set`** names what CONDITIONS the set: `required_by`, a field on the\n same entity, and `options`, which of that field\'s values require which entries.\n A row whose value is not named requires nothing. Without it the denominator is\n every option, so a set whose entries are mutually exclusive by kind reads\n `1 of 4` on every complete row \u2014 a denominator no row can reach is worse than\n no role. The field is a single `select`, keyed by its option aliases \u2014 or a\n YES/NO the row answers itself (a `boolean`, or a formula resolving to one),\n keyed by `"true"` and `"false"`: a check that FAILED owes its consequence \u2014 the\n photograph, the owner, the day it is due \u2014 and there is no select of the record\n that says so. `from: "parent"` reads that field off the record these rows hang\n under instead, through this entity\'s own `parent` link: which documents an order\n owes is the ORDER\'s type, and the papers carry no column that says it. Which of\n the two a set takes is the set\'s own shape and not a choice \u2014 a multi-select\n holds the whole set on one row and is narrowed by that row (`from` omitted), a\n single select is one entry per row and takes `from: "parent"` \u2014 and the other\n way round is refused rather than left conditioning nothing.\n- **`obligation`** names what ENDS it: `satisfied_by`, a `date` or a `files` field\n on the same entity \u2014 the day it was done, or the paper that proves it \u2014 or,\n where the business stamps nothing, `until`, the stage of this entity\'s\n `lifecycle` at which the record stops owing it: a deposit is owed until the\n order is paid, and nobody records the act of paying twice. One or the other,\n never neither \u2014 without one every obligation the business ever met stays on the\n desk. Optionally `label`, what is OWED as the reader says it ("G\u1EEDi SI"), which\n is not what the column holding the date is called ("SI cut-off").\n\n```jsonc\n"field_roles": {\n "product": { "name": "identity", "photo": "mark" },\n "stock": { "on_hand": { "role": "measure", "against": "minimum", "alert": "under" },\n "uptime": { "role": "measure", "against": 80, "alert": "under" } },\n "order": { "stage": { "role": "lifecycle", "outcomes": ["delivered", "cancelled"],\n // Two pieces of work, and every step dated.\n "phases": [{ "label": "Taking it on", "stages": ["placed", "confirmed"] },\n { "label": "Getting it out", "stages": ["picked", "shipped"] }],\n "history": true },\n // A DEADLINE COUNTS DOWN \u2014 `payment.paid_on` below is a plain day.\n "ship_by": { "role": "when", "due": true },\n // The day the papers are owed by, and the day they went.\n "papers_due": { "role": "obligation", "label": "File the papers", "satisfied_by": "papers_sent" },\n // Nothing stamps a deposit paid \u2014 the order\'s own stage ends it.\n "deposit_due": { "role": "obligation", "label": "Take the deposit", "until": "delivered" } },\n // WHICH PAPERS THIS ONE OWES IS THE ORDER\'S OWN TYPE, and no document says it.\n "document": { "order": "parent",\n "kind": { "role": "expected_set",\n "required_by": { "from": "parent", "field": "kind",\n "options": { "export": ["invoice", "customs"] } } } },\n // `kind` is the direction select \u2014 options `received` and `paid_out`, no role of its own.\n // Every figure on a quotation is in the currency that row names.\n "quote": { "unit": "currency", "total": "amount" },\n // What the price is quoted off, so the item\'s page reads one against the other.\n "service": { "price": { "role": "amount", "against": "list_price" },\n "kind": "category" },\n // The day the money moved is neither early nor late, so it stays a bare `when`.\n "payment": { "paid_on": "when",\n "value": { "role": "amount", "signed_by": "kind", "outflow": ["paid_out"] },\n // A receipt owes a receipt voucher; a payment owes an invoice and a payment voucher.\n "papers": { "role": "expected_set",\n "required_by": { "field": "kind",\n "options": { "received": ["receipt_voucher"],\n "paid_out": ["invoice", "payment_voucher"] } } } },\n // A CHECK THAT FAILED OWES ITS CONSEQUENCE \u2014 keyed by the row\'s own answer,\n // and the answer that owes nothing is omitted rather than named with an empty list.\n "check": { "passed": "verdict",\n "evidence": { "role": "expected_set",\n "required_by": { "field": "passed",\n "options": { "false": ["photo", "owner", "due"] } } } }\n}\n```\n\nIn a file that starts from a preset (\xA7 Starting from a preset), `field_roles`\nmay name the preset\'s fields as well as this business\'s own; a role the preset\ndeclares itself is kept unless this file names the same field, and `null`\nclears it.\n\n## Write rules\n\n`write_rules` is what only a CREATE meets \u2014 keyed by entity alias, like\n`field_roles`, and this file\'s in the same way: `check` proves it and the\nworkspace never sees it. An update names one field that moved; a create takes a\ndraft whole, so it has to know which row a name already belongs to, where a\nvalue comes from when nobody types it, and what a figure or a picker may hold.\nThe generator writes one `create_<entity>` workflow and one `New<Entity>Dialog`\nper entity an app can operate, from these clauses and the fields\' own\n`required`.\n\n```jsonc\n"write_rules": {\n // A customer is RECOGNISED by their address. Where an entity names a customer\n // as its `party`, the create takes the email instead of a picker, reuses the\n // row it matches and mints one only where nothing does \u2014 so the book never\n // grows a second Acme because somebody typed the name differently.\n "customer": { "natural_key": ["email"] },\n "order_line": {\n "fields": {\n // A line of nothing is not a line. Refused on create and on update, at\n // the control the figure was typed into.\n "quantity": { "min": 1, "max": 9999 },\n // The price is fixed at the moment of ordering \u2014 COPIED off the product,\n // not looked up for ever after, so the price list moving next week does\n // not silently reprice an order already placed.\n "unit_price": { "default_from": "product.price" },\n // And nothing is sold off an empty shelf. The picker reads only the rows\n // that answer this, and the write refuses the same rows again \u2014 a caller\n // who never opened the picker is bound by it too.\n "product": {\n "options_where": {\n "node_type": "group", "logic": "and",\n "children": [{ "node_type": "condition", "type": "number",\n "field_key": "in_stock", "operator": "greater_than", "value": 0 }]\n }\n }\n }\n }\n}\n```\n\n| Clause | On | Meaning |\n|---|---|---|\n| `natural_key` | the entity | the field aliases a row is recognised by. A `text` key declares `unique: true` on the field itself \u2014 two rows sharing it would make find-or-create pick whichever the read answered first \u2014 and a key is `text` or `number`, because a person types it back |\n| `default_from` | a field | `"<link alias>.<field alias>"` \u2014 the value is copied from the linked row when the row is created, never asked. The link is a one-row link on this entity and `required`, because there has to be a row to read, and the two field types must match |\n| `min` / `max` | a `number` | the figure is refused outside them, on create and on update |\n| `options_where` | a `select_record_link` | an `and` group of plain conditions over the TARGET\'s own fields, each `field_key` a field alias. It narrows the picker\'s read and is refused again where the write lands, so the link is `required` |\n\nA field\'s own `required` is not here: the contract carries it, every write path\nrefuses the row by field name from it, and the generated panel stands its commit\ndown until the same set is filled. `unique` is the text field\'s own clause\n(\xA7 `text`) \u2014 the create says so at the control before the column does.\n\nWhat a create asks is what a person STATES. A `default_from` field is not drawn,\na `lifecycle` opens at its first rung, and the stamp an obligation\'s\n`satisfied_by` names is not asked (the row is only now taking that on) \u2014 one\nfact, one place. A field the workspace writes (a formula, a rollup, a lookup, an\nautonumber, a date with `derive_from`) is never an input of a create or an\nupdate, a write in a generated body, or an entry in `lotics.writes`, and its\nrecord fact has no editor. Of the files columns it asks for exactly ONE, the\n`mark`: that pile IS the row, so a register leading with the picture would\notherwise open a faceless row, while every other pile is papers gathered onto a\nrow that exists and a panel asking for all of them is a filing cabinet with a\nSave button. The screen narrows that set where it states a `create` (\xA7 Apps and\nscreens). `scaffold check` prints every clause under its entity in **Who writes\nwhat**.\n\n**The `parent` is prefilled only in the door that mounts the act.** A row opened\nfrom the record it hangs under takes that record from the door it was pressed\nin, so the link is neither asked for nor drawn. The same entity\'s act on its own\nregister has no such row, so there the parent is an ordinary picker the draft\ncannot be committed without \u2014 a payment opened from a money app belongs to a\ncase either way.\n\n## Apps and screens\n\n`apps` is the plan: each app the reader will build, as ONE REGISTER \u2014 a SHAPE\nover an ENTITY \u2014 under `screen`. Nothing here is built by the scaffold: the plan\nis what `lotics scaffold check` prints back, register by register with the field\nin every slot, so it is read and corrected before a screen exists.\n\n**THE PRINTOUT EXPLAINS ITSELF.** Everything the shape and the roles answered by\nthemselves is marked `(default)` \u2014 the door, the tab strip, the band\'s figures,\nwhat the rows lead with, how much room they take, which end the register is read\nfrom, the set a member lens opens on, how many rows a capped history stands at \u2014\nso a clause nobody wrote is never read back as one somebody did. And\n`lotics scaffold check --why` prints the RULE behind every binding and every\nsection, indented under the line it explains: which field filled a slot and what\nit was chosen from, why the record opens where it does, why a child\'s rows are a\njournal rather than a register, why a history is capped and a relation counted.\nEvery sentence is the derivation\'s own, so what the printout explains is what\ngets built.\n\n**THREE THINGS DECIDE EVERY SCREEN AND EVERY RECORD**: the SHAPE, whose slots are\nthe table below; the ROLES the entities declare (\xA7 Field roles), which fill those\nslots and decide what each child\'s rows become; and the CLAUSES, which are where\nthe plan overrides what the two would answer. This index says which is which, so\na clause is reached for only where the first two have no answer.\n\n**What a child\'s rows become.** One section per LINK into the record, and what\nthe CHILD declares decides its kind \u2014 the first line it answers wins.\n\n| Printed as | The child declares | The reader gets |\n|---|---|---|\n| `documents:` | an `expected_set` select, one entry per row | the desk of what is owed, each row\'s files attached to it |\n| `timeline:` | an `obligation` closed by a DATE, on rows with no `lifecycle` of their own | the ordered stops, the promised day against the day it happened |\n| `thread:` | a `markdown` body, a `party` and a `when` | the correspondence, its answer, and whose move it is |\n| `journal:` | a `markdown` body and a day, on rows a record OWNS (`parent`) with no `lifecycle` | the log \u2014 each entry read in full under its day, its selects beside it, and a composer that writes the next one |\n| `itinerary:` | a `when` and a `lifecycle`, on rows a JOB\'s record OWNS | the day heads the run, one line per stop, the sum under it |\n| `lines:` | anything else the record owns (`parent`) | the register of the rows it is made of |\n| `history:` | anything else NAMED BY it \u2014 the link is their `party` or their `identity` | the same rows read as that record\'s own history \u2014 on a PROFILE, every one of them stands open at its latest 5, headed by how many there are in all, with the rest a press away |\n| `via <link>:` | a link carrying neither role | the register, headed by that link\'s own label |\n| `related:` | rows of an entity this app lists NOWHERE | one counted line, and a press that opens their register |\n\nA child carrying the BODY and the party who wrote it, and no day, is a plain\nregister, and `scaffold check` names the declaration it is one short of rather\nthan letting it fall through silently. A `party` beside a `when` and no body is\nnot a near miss \u2014 that is every ledger line there is.\n\n**A LOG\'S DAY is the child\'s `when`, unless that `when` counts down** (`due`,\n`until`): a deadline is the day an answer is OWED, so a log stacked on it would\nput the entries nobody has chased at the top. Then \u2014 and where the child declares\nno `when` at all \u2014 the day is the first `date` no role claims. A child carrying a\n`lifecycle` is never a log, on the same rule a run of stops reads: it is a thing\nbeing WORKED, and the prose on it is a note about the job.\n\n**A child\'s columns read, and are given up, in one order**: identity \xB7 when \xB7\nlifecycle \xB7 category \xB7 amount \xB7 measure \xB7 expected_set \xB7 party \xB7 contact \xB7\nverdict. The DAY outranks the counterparty because a list of rows belonging to\none record is scanned by when \u2014 the counterparty repeats on every row, and on a\nlog there is none. Ranked the other way, a narrow register shed the date and kept\na column that said the same thing on every line.\n\n**A section names its link where two of them would read alike.** One child can\nreach a record through several links and land under the same heading twice, which\nis the same sentence with nothing saying which rows either holds \u2014 so each of\nthose says the link it is read through (`timeline: Stops via Billed to`), and a\nchild reaching the record once is named by the rows it lists.\n\n**The clauses, by name.** On the APP: `scope`. On the `screen`: `record`, `tabs`,\n`slots` (`roles` on a `custom` one), `columns`, `writes` (`false`, or\n`"children"` for the rows a record owns and not the record), `period`, `filters`,\n`summary` (`above`, `totals`, `ageing`, `trend`), `facts`, `presentation`\n(`lead`, `density`, `layout`, `until`), `acts` (`row`, `record`, `selection`,\n`export`, `import`), `section_acts`, `sections`. Every one is optional, every one\nis printed back by `scaffold check`, and a screen that states none gets what its\nshape and its roles answer by themselves.\n\n**ONE APP IS ONE REGISTER AND THE RECORDS IT OPENS.** A second register beside\nit is a second job on one page: the reader arrives on whichever the nav listed\nfirst and decides, every time, which of the two they came for. So an app has one\ndestination and everything else about a row is DISCLOSED by opening it \u2014 the\nfacts by tier, the children as lists, the related as counts, one primary act, and\na filter or a group where a strip of destinations would have been. What used to\nbe a second screen is a section of the record, or another app; a model still\nsaying `screens` is refused by name.\n\n**The unit of an app is a JOB, not a person and not a table.** A job has its\nown outcome (something exists or is settled when it is done), its own subject\n(the record it advances), and an end that does not wait on the rest of the\nwork. Two tasks are ONE job when neither finishes without the other and both\nadvance the same record. One app per job, and that is what makes an app a\nwrite-ownership boundary: what the job settles is its app\'s alone to write,\nplus everything it must read to settle it well. A person holding several jobs\nopens several apps \u2014 one app for everything one person does is the dump. A step\nof the job is a section of the record, never a register beside it; a reference\nthe job needs at hand is read inside the record it is about.\n\n**Two people signing is two jobs**, because the outcome belongs to the signer:\nthe desk that prepares against the gate that releases. So is a different\ncadence \u2014 reference data edited monthly and read by the public site, beside a\ndaily desk \u2014 and a different reader, the owner\'s read-only questions. Device,\nplace and step never split a job. A super app holds more than one job; an\nover-split holds less than one, a job cut by device, place or step. The count\nper workspace falls out of the jobs, typically two to six, and is never the\ninput: review the plan app by app, naming who holds it, the job in one\nsentence, what it writes and what it reads.\n\n**A party is not a job.** The people and companies a job\'s rows name \u2014 the\ncustomers, the suppliers, the carriers \u2014 are not a job of their own, however\nmany rows name them: nobody\'s day is "the customer book". They live inside the\napp whose rows name them: a create takes the party by its natural key, the row\'s\nname opens the party\'s own record (a profile \u2014 how to reach them, what is live\nwith them, their history, their journal), and its facts are edited there. A\nparty earns an app of its own only where someone\'s job IS the relationship, with\nan outcome of its own \u2014 an account plan signed, a supplier qualified \u2014 never\n"keeping the book". Two apps for one seller, the deals and the customers, is the\ndump cut in two.\n\n*A two-van appliance repair shop.* The technician\'s job is the call-out: the\noutcome is a visit done, the subject is the call-out record, and quoting it and\nscheduling it are one job, because neither finishes without the other and both\nadvance that record. The owner holds two of his own \u2014 billing the month\n(outcome invoiced, subject the invoice, settled against visits already closed)\nand the price list the booking page quotes from, edited monthly. Three jobs,\nthree apps, and the owner opens two of them.\n\n**Inside a job the caps hold**: six flow stages on a desk, seven facts before a\nrecord\'s fold. Past them, look for the second job hiding inside. And a job\'s app\nis DENSE: every create the job needs lives in it, so its user never leaves it to\ncorrect a figure the screen in front of them is stating \u2014 three workflows is a\nscreen, not a desk.\n\n**A handoff is a stage change.** Where one job ends the record moves stage and\nthe next job\'s desk opens on it; a field two jobs must both write is declared\nshared at the split, naming the stage each may write it in.\n\n**Name an app for the JOB, in the trade\'s own words, never for who it is for.**\nA title on the door says nothing about what the person who opened it came to\ndo, and it is wrong the day the org chart moves.\n\n```jsonc\n"apps": [\n {\n "alias": "sales", "name": "Sales",\n "description": "\u2026", "icon": "briefcase", "theme": { "color": "blue" }, // optional\n "screen":\n { "alias": "orders", "label": "Orders", "shape": "lifecycle_desk", "entity": "order",\n "record": "drawer", // optional \u2014 "drawer" | "page"; absent, the shape decides\n "tabs": "stage", // optional \u2014 the entity\'s lifecycle, or null; absent, the shape decides. Any other select is a filter\n "writes": false, // optional \u2014 default TRUE; false is a reader, "children" operates only the rows the record owns\n\n "period": "due_date", // optional \u2014 any field whose value is a date, derived ones included\n "filters": ["kind", // optional \u2014 a single-select, or a lens the model states itself\n { "label": "Qu\xE1 h\u1EA1n l\u01B0u", "predicates": [\n { "label": "\u0110\xE3 qu\xE1", "tone": "red",\n "where": { "node_type": "group", "logic": "and", "children": [\n { "node_type": "condition", "field_key": "owed", "operator": "greater_than", "value": 0 }] } }] }],\n "summary": { "totals": ["total"], "above": ["total", { "field": "owed", "at": "end" }],\n "ageing": { "amount": "owed", "due": "due_date", "buckets": [30, 60, 90] },\n "trend": { "field": "total", "direction": "up" } }, // optional \u2014 see below\n "facts": { "groups": [{ "caption": "Pricing", "fields": ["rate", "surcharge"] }] }, // optional \u2014 the record\'s named bands\n "presentation": { "lead": "none", "density": "dense" }, // optional \u2014 how it is DRAWN; absent, what the rows are decides\n "acts": { "row": [{ "label": "Issue the note", "template": "debit_note", "place": "cta", "confirm": true },\n { "kind": "agent", "label": "Read the papers", "agent": "reader", "fills": ["ref", "due_date"] },\n { "kind": "workflow", "label": "Hand it to dispatch", "workflow": "hand_to_dispatch",\n "inputs": { "order_id": "record", "owed_by": "due_date" } }],\n "record": [{ "label": "Print the file", "template": "dossier" }], // optional \u2014 the record\'s own header menu\n "selection": [{ "label": "Statement", "template": "statement" }], // optional \u2014 the work it hands on\n "export": true, // optional \u2014 the rows in view, saved\n "import": { "kind": "import", "label": "Upload the sheet", // optional \u2014 a file turned into rows\n "entity": "order", "key": "code", "columns": ["rate"] } },\n "section_acts": { "line": [{ "label": "Chase the lines", "template": "chaser" }] }, // optional \u2014 a verb on one section\n "sections": { "line": { "draw": "worksheet", "cost": "buy_rate", "sell": "rate" }, // optional \u2014 priced lines, worked down in place\n "payment": { "draw": "ledger", "summary": ["total", "paid", "outstanding", "due_on"] }, // optional \u2014 a book headed by what it is read against\n "pinning": { "draw": "publish", \u2026 } }, // optional \u2014 the destinations this record is sent to (see below)\n "slots": { "identity": "code", // optional \u2014 slot \u2192 field, where the roles cannot decide alone\n "subject": ["customer", "project"], // \u2026or the TIERS a fold walks down, outermost first\n "stage": { "field": "state", "quick": true } }, // \u2026or the field AND the reader\'s own control for it\n "columns": ["source", "owner"], // optional \u2014 extra facts after the slots, shed first at narrow width\n "create": ["code", "customer", "due_date"] } // optional \u2014 what a NEW row is asked for; absent, every field a person states\n }\n]\n```\n\nA shape is a proven screen with named SLOTS, each filled by a field carrying a\nrole (\xA7 Field roles). A slot with exactly one candidate on the entity binds by itself;\ntwo candidates need naming in `slots`; a field fills one slot; a required slot\nwith none is refused \u2014 a lifecycle desk over an entity with no `lifecycle`\nselect cannot be built.\n\n**A LIST IS A HIERARCHY, not a set of columns.** A group register\'s `subject`\ntakes one \u2014 `"subject": ["customer", "project", "order"]`, outermost first \u2014 and\nthe register becomes one the reader walks DOWN: it folds by the first tier, a\nnode narrows to the tier inside it, the last tier opens the set behind the\nfigure, and a trail says where they are. Every tier is a field of this entity\ncarrying a role that slot accepts, and none of them fills a second slot. It is\nthe slot\'s own answer that decides: a list on any other is refused, because two\ncolumns in one cell is not a fold. Without it the same business needs a register\nper depth, and the same money is folded twice with nothing saying it is the same\nmoney.\n\n**AND ON A SLOT READ AGAINST A LIMIT A LIST IS SEVERAL MEASURES** \u2014\n`"level": ["received", "invoiced"]`, at most four, each drawn as a named meter\nline inside the ONE column and each read against its OWN `against`/`alert`. A\nrow is regularly scored against more than one target at once, and the second\nreading put in `columns` instead draws a figure bare with the comparison handed\nback to the reader. Every entry states a limit \u2014 a bare measure in the list is\nrefused, naming the clause \u2014 and `quick` is refused beside one, because a typed\nfigure is one cell. The FIRST leads: it is what `summary.above: "counts"` counts\npast-limit rows on and what a live board ranks its exceptions by, both of which\nname one set.\n\n**A QUICK SLOT IS FOR A DECISION THE READER MAKES FROM THE ROW ALONE.** A slot\'s\nvalue in `slots` is normally the field\'s alias; `{"field": \u2026, "quick": true}`\nsays the column is not a reading of that value but the CONTROL for it \u2014 pressed,\nit writes that one field as a diff through the record\'s own update, so the\nregister and the page behind it land the same write. THREE roles are such a\ndecision, and `quick` on any other is refused: a `lifecycle`, which rests as the\nstage\'s own dot and label, takes a rule under it on hover and focus, and opens\nthe field\'s stages with the ones that END the flow last \u2014 ONE control, which IS\nthe reading;\na `verdict`, which is the switch; and a `measure`, which is the figure typed in\nits own cell \u2014 a reading taken per row IS the row, which is what a timesheet\'s\nhours and a count sheet\'s tally are. Everything else is decided against what the\nrecord holds, and the record has to be open. All three carry the record\'s own\nblockers \u2014 a move the ladder holds is a move the cell holds, and it names the\nentries the row still owes \u2014 and an outcome asks before it commits. Add `"order": "sequence"`\nwhere the stages are a WALK rather than a set of destinations: the picker is then\nordered as the walk and the step the row takes next is MARKED in it. It never\nadds a second control \u2014 a column that answered with a reading in some rows and a\nverb in others is two columns about one value. A screen stating `writes: false`\nrefuses `quick`: a reader who may operate nothing has no decision to take.\n\n**THE SLOTS ARE THE ROW\'S ANSWER; `columns` IS ITS CONTEXT.** `"columns":\n["nguon", "phu_trach", "nhu_cau"]` names extra fields of the entity, drawn AFTER\nevery slot and in this order, each by what its column IS \u2014 a chip for a select,\nthe person\'s name for a member, the day for a date, the figure for a number, a\ntick for a boolean, the words for everything else. They are ranked BENEATH every\nslot, so a phone sheds all of them before it sheds any of the shape\'s own\ncolumns. **A COUNT IS NOT A COLUMN**: an entry whose field is a `rollup` that\nCOUNTS rows is read as the thing it counts \u2014 `3 b\xE1o gi\xE1 \u0111ang m\u1EDF`, the number then\nthe column\'s own label \u2014 on the name\'s supporting line at every width, so it\ntakes no seat and a count of nothing draws no run at all. Where the record LISTS\nthose rows, the reading is the press that opens it. At most four, and refused\nwhere one would say what a slot already says,\nover a files field (files are read, so they are a section of the record), over a\ncell holding several (a column of comma lists compares nothing), on a shape whose\nrows are not the entity\'s records in a table, and beside a `presentation.layout`\nthat draws the rows with a device of its own. A fifth fact worth the width is a\nslot the shape is missing \u2014 say so rather than widening this clause.\n\n**THE DRAFT ASKS WHAT FILES THE ROW; THE RECORD ASKS THE REST.** `"create":\n["ma_ho_so", "khach_hang", "han_xu_ly"]` names the fields a NEW row is asked for,\nin that order. Absent, the draft asks for every field a person states \u2014 which is\none panel for a row somebody has the answers to when they open it, and a wall in\nfront of one that is written over days: a job is named, dated and filed the\nmoment it is taken, and everything else about it is learned afterwards. Whatever\nthis clause leaves out is edited in place on the record, where it is read. It is\nthe SCREEN\'s, and a screen is one register over one entity, so it narrows that\nentity\'s own draft: the rows a record adds under itself are the section\'s, and\ntheir composer asks what the section draws. At most twelve, and refused where an\nalias is not a field of the entity, is named twice, or is one no draft ever asks\n\u2014 a computed value, a link holding many rows, a minted number, the `lifecycle`\n(the row opens at the flow\'s first rung), a field a `default_from` copies, the\nstamp an `obligation` is closed by, and every files column but the `mark`. A\nlink the row STANDS ON \u2014 its `parent`, its `party` \u2014 is asked whatever its\ncardinality, because the create writes one row into it and how many the cell\nholds afterwards is the platform\'s business. The\n`mark` itself may be left out, and then the row\'s face is attached on the record\nlike any other pile. Refused too where it omits a field the row cannot be written\nwithout \u2014 one that is `required`, the link it hangs under, or the key a party is\nrecognised by \u2014 because a draft that cannot save is worse than a long one.\n\n| Shape | Answers | Required | Also fills | Record | Tabs |\n|---|---|---|---|---|---|\n| `lifecycle_desk` | what is stuck, what do I move next | `lifecycle`, `identity` | `mark`, `party`, `amount`, `measure` (level, one or several), `when` | page, or drawer where the entity carries `parent` | the lifecycle\'s stages |\n| `party_register` | who is this, our history, is there a risk | `identity` | `mark`, `contact`, `measure` (worth), `verdict` (risk) | page | none |\n| `offering_register` | what do we offer, at what price, can I sell it | `identity` | `mark`, `amount` (price), `measure` (availability) | page | none |\n| `transaction_ledger` | does this period reconcile, what is unexplained | `when`, `amount` | `identity` (reference \u2014 the line\'s own number), `party`, `lifecycle` (classification), `expected_set` (document) | drawer | none |\n| `monitored_asset_set` | what needs attention, is that number normal | `identity`, `measure` (level, one or several) | `mark`, `lifecycle` | drawer | none |\n| `obligation_desk` | what is due next, and has it been done | `identity`, and at least one `obligation` on the entity | `when` (runway) | page | none |\n| `trend_deep_dive` | how did the period go, and why | `when` | `measure`, `amount` | drawer | the series, where the model names a select |\n| `reconciliation_desk` | what does not match, and by how much | `identity` (reference), and two figures \u2014 `ours` and `theirs`, each an `amount` or a `measure` | `category` (reason), `verdict`, `when` | drawer, on the PAIR | the dated runs, where the model names a select |\n| `entry_matrix` | one value per subject per period | `identity` (subject), `across` \u2014 a `when`, or a `category` for a fixed column set | `measure` (value), `lifecycle` (state), `category` (note) | drawer, on the cell\'s row | none |\n| `guided_run` | complete an ordered sequence, a step at a time | `identity` (step) | `slot` (sequence), `capture` \u2014 a `measure`, `verdict` or `expected_set` \u2014 `verdict` (gate), `mark` (media) | page: the run IS the record | none |\n| `live_board` | is everything OK, right now | `lifecycle` (state), `identity` | `verdict` (exception), `when` (since), `measure` (level, one or several), `category` (where) | drawer | none |\n| `group_register` | what does each group come to, and what is behind it | `subject` \u2014 a `category`, `party` or `when` | `amount`, `measure`, `identity` (member) | page: a row is an aggregate, and the door is the SET behind it | none |\n| `media_set` | scan a body of pictures by where they belong | `mark` (picture), `identity` | `category` or `party` (group), `when`, `verdict` (current against superseded) | drawer | none |\n| `day_sheet` | what happened on each day | `when` (the day) | `lifecycle` (state), `measure` (level, one or several), `amount`, `category` (note) | page: a day composes several child logs | none |\n| `resource_schedule` | what is on each resource, in what order, under what ceiling | `lane` \u2014 a `party` or a `parent` \u2014 `identity`, `when` (the start) | `measure` (duration), and `load` and `bulk`, each a `measure` read against a ceiling of the LANE\'s \u2014 a vehicle fills by weight and by room and stops at whichever runs out first; `lifecycle` (stage) | drawer, over the lanes | none |\n| `worksheet` | priced lines the reader edits, totalling to one figure | `identity` | `category` (group), `measure` (quantity), and `cost` and `sell`, each an `amount` or a `measure` | drawer, per line | the versions or the jobs, where the model names a select |\n\n**A record is one of five KINDS, and the shape plus the roles decide which.**\nEvery one of them is ONE READING COLUMN, and the ORDER of that column is the\nwhole of what the kind means.\n\n| Kind | The column, top to bottom |\n|---|---|\n| WORK RECORD \u2014 a `lifecycle_desk` over rows that belong to nothing and that nothing transacts with | the one act that moves it \xB7 what it IS \xB7 the papers it owes \xB7 the rows it is made of \xB7 the book of what it came to |\n| LINE \u2014 the same shape over rows carrying `parent` | its ladder \xB7 the arithmetic behind its amount \xB7 the rows and papers under it \xB7 its particulars, last (the key it is filed by is the line under its title) |\n| PROFILE \u2014 a `party_register`, and any shape over an entity whose rows other entities are NAMED by (their `party` or their `identity`) | where it stands \xB7 its history, OPEN, grouped by year \xB7 the rest of its registers \xB7 its facts (the ways to reach it are under its name, its settlement is the band, and a `when` it owes closes that band as a countdown) |\n| CATALOGUE ITEM \u2014 an `offering_register` | its own words (the picture leads the header at media scale, the price is read against its reference in the band) \xB7 what uses it \xB7 its facts |\n| EVIDENCE \u2014 a `transaction_ledger` | the PROOF \xB7 what the paper is, compactly \xB7 the rest \xB7 the links to the record it settles and the party it was with |\n\nNothing in the file states the kind: a clause that could say it could say the\nwrong one, and the model already says it \u2014 `parent` says whether a row stands\nalone, and a link NAMING this record (`party`, `identity`) says whether other\nrows are ABOUT it. So a desk over accounts\nwalking their own stages opens a PROFILE: the quotes and the invoices naming\neach account are what its record is read for, and a work record would have\ncounted them in one line at the foot. `record`\noverrides the DOOR where the shape\'s own answer is not the one wanted \u2014 and its\nthird value, `"expand"`, is not a door at all: the row REVEALS what it holds in\nplace, where there is nothing behind it worth navigating to. A row read that way\nreads as a LINE whatever the shape would have opened: its ladder, the arithmetic\nbehind its amount, then its particulars, because there is no header above it to\nhave stated the name first. It is REFUSED for two reasons, and they are not the\nsame refusal. On a shape whose record is a page by nature \u2014 a `guided_run`, a\n`group_register`, a `day_sheet`, an `obligation_desk` \u2014 because each of those\nstates the opposite where its door is declared: what its row leads to is read\nwhole, not as four more columns than the register had room for. And on a shape\nthat draws its rows with a device of its OWN \u2014 an `entry_matrix`, a `media_set` \u2014\nbecause a cell of a grid and a tile on a wall have no band under them for the row\nto reveal into. For the same reason a revealed register is drawn as a TABLE: a\n`presentation.layout` of anything else is refused, and the reader is not offered\nthe arrangement either, since it would take away the only door the register has.\n\n**`scope` \u2014 the one subject every read of the app narrows to.** Stated on the\nAPP rather than on its screen, because it is not a filter: a record opened under\nit stays under it, the pick survives closing the app, and every app declaring the\nsame `entity` shares one pick per viewer \u2014 which is the whole value, and what\nstops a construction workspace asking which project twelve times. `entity` is the\nmodel\'s entity whose one row the app is read inside, and `param` is what the\napp\'s reads take that row by.\n\n```jsonc\n{ "alias": "site_work", "name": "Site work",\n "scope": { "entity": "project", "param": "project_id" },\n "screen": { \u2026 } }\n```\n\nThe register\'s own entity has to REACH it \u2014 its own rows, or rows that name one\nthrough a link \u2014 or the switcher is a control that changes nothing. Until a\nsubject is picked the app reads NOTHING and says which one it is waiting for: an\nunscoped read over a scoped app is every row in the workspace drawn as one\nproject\'s work, which looks correct.\n\n**ONE RECORD SURFACE PER ENTITY, AND ITS KIND IS THE ENTITY\'S.** An app is one\nregister, so the record its rows open is the register\'s own and the kind comes\nfrom that register\'s shape. Another app over the same entity opens the same kind\nof record: a payment read from a cash book and one read from a trend are both\nmovements, and both read proof first. A shape with no kind of its own (a trend, a\nmonitored set, a `custom` screen) says only where the record opens.\n\n**A NAMED PARTY HAS A RECORD SURFACE IN THE APP.** One register, but the party\nits rows name \u2014 by `identity` or by `party` \u2014 opens a record of its own, a\nPROFILE, from the cell that names it and from the record\'s header: the ways to\nreach them, what is live with them, every register that names them at its latest\nfive, their journal, their facts. It is the app\'s to write as far as the job\nneeds \u2014 the party\'s facts and its journal \u2014 and the writers matrix says so; the\nregister\'s own record stays the register\'s. A party named through a link\ncarrying no role opens nothing, and neither does a party that is a MEMBER \u2014 a\nperson of this workspace has no row in any book the app carries, so the cell and\nthe header state them and stop.\n\n**Two things a register states about a row beyond which field fills which slot.**\nA `measure` is read AGAINST its limit (`against`/`alert`) where the shape\'s slot\ntakes one \u2014 `level`, on a lifecycle desk, a monitored set, a live board and a day\nsheet, a schedule\'s `load` and `bulk`, and every slot of a\n`custom` screen, which composes the frame\'s own column; `worth`, `availability`\nand a trend\'s headline are plain figures, and a limit there would read as a meter\nin the record over a number the row printed bare. And the name carries a\nSUPPORTING LINE where the entity states a key to file the row under: the `parent`\nit belongs to, its `reference`, an `autonumber`, a text formula \u2014 in that order,\nfirst hit wins, never a field a person types prose into. Every record heads with\nthat same key, so the register and the door it opens name one row one way.\n\n**The act that opens a row belongs to a register that LISTS the entity\'s rows**\n\u2014 a desk, a register, a ledger, a monitored set, or a `custom` screen. A shape\nwhose rows are not records has neither that act nor the door a counted line\nelsewhere leads to: an obligation desk\'s rows are deadlines and a trend\'s are\nperiods, so a create pressed there would open a row the screen cannot show. An\nentity whose only app is one of those has no create at all \u2014 give it an app whose\nregister its rows are read in.\n\n**An `obligation_desk`\'s rows are not its entity\'s rows.** The shape fans the\nentity\'s `obligation` fields out \u2014 one row per thing still owed, which is one\nwhose date is SET and whose `satisfied_by` is empty \u2014 so a l\xF4 with four cut-offs\nis four rows, and the three it has met are not there at all. The row\'s name is\nthe obligation\'s, its supporting line the record\'s `identity`, and the door is\nthat record. A `when` bound to `runway` is what the countdown\'s ring is measured\nfrom. Over an entity declaring no `obligation` the shape is refused: there is\nnothing to count down to.\n\n**What a register carries beyond its columns.** Six clauses, each emitted as\nthe prop the kit draws it with; every one is optional and a screen that states\nnone gets a register of columns and nothing else.\n\n- **`period`** \u2014 a field on the entity whose value is a DATE. The toolbar gains a\n date-range control (this month to start), and the rows it keeps are what the\n register AND every figure below are computed over, so a band can never be over a\n different window than the rows under it. Any such field, not only the `when`\n role, and however the date got there: one table is read two ways (a cost ledger\n by the day a line arose, the cash book over the same table by the day it\n settled), a role is one field\'s, and the day money MOVED is often a formula over\n the two columns that could hold it \u2014 which then declares `output_type: "date"`.\n A `trend_deep_dive` brings its own period and takes none here; a `live_board`\n is PERIODLESS and takes none either, for the opposite reason \u2014 it answers what\n is true NOW, and a date range over it draws what was true in the window in the\n live state\'s own colours.\n- **`filters`** \u2014 the chips beside the search. A single-`select` field\'s alias\n offers that field\'s own live options; it is the lens a register is read through\n over and above the one band its strip gives, so the strip\'s own field is\n refused here (one dimension, one control) and so is a multi-select (a row would\n answer the chip several ways at once). The rows the register and every figure\n below are computed over are what the chips AND the period kept.\n A `select_member` field\'s alias is the third form and asks one question \u2014 whose\n rows these are: two peers, MINE and ALL, read against the person looking rather\n than a directory of everybody. `{"field": \u2026, "default": "mine"}` opens the\n register on the reader\'s own; that form on any other type is refused. Alone of\n the chips it narrows the READ (a `member` param on the register\'s query), so a\n desk that opens on the reader\'s work opens on all of it rather than on\n whichever of their rows the first page happened to hold.\n A DERIVED LENS is the other form \u2014 `{"label": \u2026, "predicates": [{"label": \u2026,\n "tone": \u2026, "where": \u2026}]}` \u2014 where the sets a reader narrows by are ones the\n MODEL names and no column holds: rate validity, plan urgency, free-time\n overrun. Each `where` is an `and` group of plain conditions over the entity\'s\n own fields, each `field_key` a field alias, and it is read off the row, so the\n operators are the comparisons a value answers \u2014 `number` and `text` equality,\n `number` ordering, `boolean` equality, `select` `has_any_of`/`has_none_of`, and\n `is_empty`/`is_not_empty` on any of them. Nothing DATED: a relative point needs\n a clock, and a screen states one date control (`period`), so a second beside it\n would be two windows with nothing saying which a figure was computed over \u2014 a\n date question is asked as a column the model derives and read here as a number\n or a yes/no. `tone` is the option palette\'s, because the chip draws these\n exactly as it draws a select\'s options.\n- **`summary`** \u2014 what the rows in view come to. `above` is a band OVER the\n register: `"counts"` for how many rows are in view and, for the FIRST\n `measure` the model gives a limit, how many are past it (accented only when\n one is) \u2014 one such set, because a band naming three is the dashboard a\n register is not; a list of number-field aliases for what they add up to, and a\n `percentage` named there is refused, since a share summed over the rows in\n view is a figure of no kind (draw it as a column, whose own total reads the\n mean). A figure written `{"field": \u2026, "at": "end"}` is a STOCK instead: the\n reading standing at the END of the window rather than the sum of the readings\n in it, which is the only way opening + in \u2212 out = closing reconciles \u2014 a month\n of daily closing stocks added together is thirty warehouses. The band labels it\n as the snapshot it is, and it is refused on a screen that states no `period`,\n since there is then no window for it to stand at the end of.\n `ageing` stands in that same\n band: an `amount` split by how many days past its `due` date each row is,\n drawn as a `Breakdown` of the edges in `buckets` (30, 60 and 90 days unless the\n model states its own, ascending). The ladder is the kit\'s \u2014 its boundary, its\n words and its ramp \u2014 and it opens with what is NOT YET DUE, because a bar of\n overdue bands alone is full at every input: a book with one late invoice and a\n book that has gone entirely bad would paint the same solid width. Against the\n current money the bar\'s own shape is the reading. A row whose date is empty is\n in no band. `totals` is what the whole\n view adds up, and it is drawn in that SAME band above the rows: a register\n states one aggregate, over the rows the reader can see, in one place, and a\n closing line under them is a second place to look and a second question about\n which rows it covers. A closing line belongs to the one device that IS a\n statement \u2014 a record\'s `Ledger`, whose balance is what it is read for, labelled\n by its amount\'s own word. Where a shape has no band above (a trend, whose own\n band is the chart) the figure stands under the register instead. A\n `transaction_ledger` sums its amount column itself and takes `above` alone. An\n `obligation_desk`\'s rows are DEADLINES and not records, so its band states\n `"counts"` \u2014 how many are open \u2014 and a figure list, an `ageing` or a `trend`\n over it is\n refused: a l\xF4 with three cut-offs is three rows, and its freight added over\n them is three times the freight.\n `trend` is `{"field": \u2026, "direction": "up"|"down"}` and states WHICH WAY that\n figure moved across the window: the rows in view are cut into the window\'s own\n buckets \u2014 days over a month, weeks over half a year, months beyond \u2014 and the\n later half of the CLOSED ones is read against the earlier half, drawn beside\n the figures as one line, so a window the reader is still inside states the\n movement of the days that have run rather than a fall into the ones that have\n not. `direction` is which way is GOOD, which nothing about the number\n says: a book wants its takings rising and a backlog wants its days falling, and\n the same arrow is a win on one and an alarm on the other. It needs a `period`\n to be a window of, and it is refused on a shape that draws its own (a\n `trend_deep_dive` already draws the series) or that reads none at all (a\n `live_board` answers what is true now). A window whose earlier half took\n nothing draws no line: there is no base for a percentage, and 0 % would claim a\n steadiness nobody measured.\n- **`facts`** \u2014 how the RECORD\'s facts are banded. `groups` is a list of\n `{caption, fields}`: what a set of facts has in common is a sentence about the\n business and nothing derives it, so a plan that states one gets exactly those\n bands, in its own order, captioned. EACH BAND IS A SECTION of the record \u2014 its\n own heading, its own entry in the rail, its own place in the column: under one\n heading the reader is handed every particular at once, in a caption weight that\n cannot out-rank the heading above it, with nothing in the rail to reach any of\n them by. Everything the plan leaves unnamed is the LAST section and carries no\n heading, since there is no sentence to head "whatever no group claimed" with \u2014\n ONE section, even where the record\'s own recipe bands what was left in two (an\n evidence record reads its particulars before the record and the party it was\n with): both are groups inside it, because the word the kit heads "the rest"\n with cannot name two entries in the rail.\n A band of more than eight facts is NOTED: a section that long is two. A field the header, the band, the ladder, a required\n set, the files or a register below already draws is refused: it is not a fact,\n so a band naming it would either state it twice or draw nothing. Naming any\n band IS the answer to which facts fold \u2014 see the TIERS below.\n- **`presentation`** \u2014 how the screen and the record it opens are DRAWN, where\n what the rows are is not the answer wanted. `lead` is what each row leads\n with: `"mark"` the subject\'s own mark, always spent because a party\'s initials\n stand in for a picture nobody uploaded; `"picture"` the photograph, spent only\n on the rows that carry one and, on the record, at media scale above the name;\n `"paper"` the row\'s own files, drawn as the cards a pile of documents is read\n as; `"figure"` the amount, with no gutter at all; `"none"` neither. `density` is\n how many lines a row\'s subject may take \u2014 `"roomy"` two, `"dense"` one.\n Both default, and the DEFAULT is what the rows are: a party register leads\n with the mark, an offering register with the picture, a ledger with its\n figure, and a desk \u2014 rows that are work still to do, scanned \u2014 stands its rows\n one line each while a record\'s own rows are read roomy. So state it only to\n differ, and a plan that states nothing still gets screens that vary. A lead\n the rows cannot carry is refused: a mark, a picture or a paper where no column\n is drawn as the rows\' mark, a\n figure where no column is the amount, and the other subject\'s mark (which of\n the two a row wears is the shape\'s, so the register and the record it opens\n cannot call one row two kinds of thing).\n `layout` is how the rows are ARRANGED \u2014 `"table"`, `"list"`, `"cards"`,\n `"gallery"`, `"board"`, `"calendar"`, `"timeline"`, `"gantt"` \u2014 and it is\n AFFORDED by what the rows carry, not by the shape: every register affords a\n table and a list, a `mark` affords the two picture-led grids, a `lifecycle`\n affords a board, a `when` affords a calendar and a timeline, and a `when`\n beside a `measure` that `counts` DAYS affords a gantt, because a bar with no\n length is a calendar drawn sideways and a bar measured in money or litres is a\n length of another kind. `until` names the date field a bar ENDS on, where the\n rows state one, and the days measure is what the bar runs for without it. A\n layout the bound roles do not afford is refused, naming the ones they do.\n- **`acts`** \u2014 where this screen hands work on: `row` is the \u22EF menu on every row,\n `record` is the \u22EF on the RECORD\'s own page header \u2014 the same reach as a row\'s,\n standing where the work is open instead of while a list is scanned, and refused\n on a record that opens as a drawer, which has no header of its own and whose\n row already carries the register\'s menu \u2014 and `selection` is the bar over the\n TICKED rows. `export` is the third reach \u2014\n the WHOLE VIEW, saved in one press: `true` writes the rows as the register drew\n them, in the columns it drew, as an `.xlsx`, and\n `{"template": "\u2026"}` says the layout is the trade\'s and makes the paper with a\n workflow like any other. It is never a list of columns: which columns the\n export carries is what the screen already draws, and a second statement of it\n disagrees the moment a slot moves \u2014 the generator reads them off the drawn\n slots and writes them into the app, so the sheet\'s headings are the labels the\n register drew them under.\n `import` is the reach OPPOSITE it \u2014 a file turned into rows:\n `{"kind": "import", "label": \u2026, "entity": \u2026, "key": \u2026, "columns": [...]}`. The\n file is mapped column by column, each row is validated, what would change is\n previewed, and the commit UPSERTS by `key` \u2014 which is why a sheet sent twice\n moves no counts. That key is one of the entity\'s own `natural_key` fields\n (\xA7 Write rules), and an entity declaring none is refused: without a key the\n second run is a second set of the same rows. `entity` is the register\'s own \u2014\n an app is one register, and rows filed into another entity are a count nobody\n on this screen can check. `columns` is what the sheet may fill, in that order;\n absent, it is every column a person states IN WORDS. A computed one is refused\n because the workspace writes it, and a link, a member or a files column is\n refused because a cell of a sheet is words and those hold a row of another\n table or bytes \u2014 a file reaches another table through the party pattern (a\n `natural_key` on the target, named rather than picked), which is a create\'s.\n The generator writes the whole verb, as it does for a paper: an\n `import_<entity>` workflow that finds the row by `key` and then changes it or\n opens it, answering which of the two, and the staged run over it \u2014 drop, map,\n the per-row verdict, the commit. A line whose cell the column cannot hold is\n refused with the reason and the rest of the sheet still lands. A file MINTS\n rows of the register, which is the Add many times over, so a screen stating\n `writes: false` or `"children"` \u2014 which opens no row of its own \u2014 refuses the\n clause; the `export` beside it stays, because it only reads.\n Both spreadsheet reaches are read and written IN THE BROWSER, so the generated\n `src/main.tsx` imports `@lotics/app-runtime/sheets` wherever the plan states\n one of them and not otherwise; an app that has neither carries no spreadsheet\n engine at all. An act names ONE of three things, never two:\n - **`template`** \u2014 an `html` template this model declares (\xA7 Templates). The\n paper is filled from ONE row in `row`, and from the whole ticked set in\n `selection`. The generator writes the whole verb \u2014 a `generate_<template>`\n workflow that reads the row and hands the file back, and a menu item that\n opens it \u2014 so there is nothing left to bind. An `email` template is sent\n rather than opened and is refused here; a file-backed template (`excel`,\n `word`, `pdf-form`) is bytes a model has none of, so those stay the author\'s\n own `lotics app workflow set`. Two screens over DIFFERENT entities cannot\n name one template: one paper is filled from one kind of row.\n - **`{"kind": "agent", \u2026}`** \u2014 a run over ONE row. `agent` is an alias the APP\n declares (`package.json#lotics.agents`), because an agent is prose and tools\n rather than anything a workspace model can state \u2014 `lotics app check` refuses\n one nothing declares, exactly as it refuses a workflow nothing bound. `fills`\n is the fields the run may write: pressed, the reader watches the run, reviews\n what it proposes field by field against what the row holds now, and applies\n the ones they kept as ONE write through the record\'s own update. A run can\n reach nothing outside `fills`, a computed column there is refused (the\n workspace writes those), and a screen stating `writes: false` refuses an\n agent act outright. It belongs in `row`; the selection bar makes ONE paper\n from many rows, and a run per ticked row is the row\'s own act many times.\n - **`{"kind": "workflow", "workflow": "\u2026", "inputs": {\u2026}}`** \u2014 the work HANDED\n ON: a lead becomes an opportunity, an order is split into the orders placed\n on its suppliers, a request is closed. `workflow` is an alias the APP binds\n (`package.json#lotics.workflows`), because the rule behind it is the\n business\'s and no model states one \u2014 so the generator writes NO body and\n `lotics app check` refuses an alias nothing bound, exactly as it refuses an\n agent nothing declares. `inputs` is what the press hands that body: the\n workflow\'s own input name \u2192 `"record"` for the row it was pressed on, or a\n field of this entity for a value off that row. The value is what the\n workspace HOLDS \u2014 an option\'s key, a linked row\'s id, the figure \u2014 never the\n rendering. A files column is refused (the body reads the row\'s files off the\n row it is handed), a column that is not a field of the entity is refused by\n name, and one input short of the declaration is refused at the press, so the\n whole act is dropped rather than shipped half-bound. It runs ON the press:\n there is nothing to review first, which is the agent arm\'s law, and the\n body\'s own sentence is what the reader is told either way. It stands in\n `row`, in `record` and on a `section_acts` band \u2014 each reaches ONE row \u2014 and\n is refused over the ticked set, where one act makes one paper from many. The\n row it reaches is the RECORD\'s, so a screen stating `writes: false` or\n `"children"` refuses a hand-off on every one of those surfaces, exactly as it\n refuses an agent act.\n\n **`"when"` stands an act down until the work is ready.** `{ "field": "<the\n entity\'s lifecycle>", "in": ["<stage>", \u2026] }` offers the verb only while the\n row stands at one of those stages. A verb that cannot work yet is worse than\n no verb: the reader presses it, the body refuses, and nothing on the screen\n ever said the work was not ready. Readiness is what a lifecycle states, so the\n field named is this entity\'s `lifecycle` and nothing else \u2014 a category says\n what a row is and a verdict answers a question, and neither moves. Refused: a\n field that is not the lifecycle, a stage it does not declare, and every stage\n at once, which is the act with no condition.\n\n **`"place": "cta"` draws an act ON the row** instead of in its \u22EF \u2014 the one verb\n a reader presses without opening a menu first. At most one per screen and every\n other act stays in the menu, because the trailing gutter is paid for by every\n row: a second is refused with *one verb on the row, the rest in its menu*. The\n ticked set has no row of its own, so `cta` is refused there.\n\n **`"confirm": true` asks before the act runs**, with the act\'s own label as the\n commit word \u2014 for a press that cannot be taken back: something goes public,\n something leaves the building, a figure is filed with somebody else. Everything\n else runs on the press, because a dialog in front of a reversible act is one\n readers learn to dismiss, and then dismiss in front of the one that mattered.\n It reads the same wherever the verb stands \u2014 a row\'s \u22EF, a row\'s own button, a\n record\'s menu, a section\'s band \u2014 and `scaffold check` prints it as\n `[asks first]`.\n\n A hand-off\'s BODY is still the author\'s: `lotics app workflow set <alias>`\n binds it, and `scaffold check` prints the act as `"<label>" \u2192 <alias>(\u2026) [you\n write the body]` so it is read as work owed rather than as a verb already\n wired.\n\n A `selection` act is a `template` and nothing else: an act over many rows makes\n ONE paper from them. The generator turns the register\'s checkbox gutter\n on, emits a `generate_<template>` workflow taking the ticked ids, and the body\n reads each row and passes them as `rows` \u2014 which is what the template iterates\n (`{{#each rows}}`). One template is one paper AND one reach: naming the same\n one from a row\'s \u22EF and from the bar is refused, because those are two bodies.\n A shape whose rows are DERIVED rather than records \u2014 an `obligation_desk` \u2014\n has no set to tick and refuses the clause. The paper is handed BACK, never\n filed: a ticked set can hold rows of three different parents, so there is no\n record on which "this document belongs here" is true.\n\n**`section_acts` puts a verb WHERE ITS EFFECT LANDS.** "Chase the paperwork"\nbelongs on the papers, not in the register\'s \u22EF two surfaces away. It is keyed by\nthe alias each section is DERIVED from \u2014 a field\'s (its progress, its prose, its\nrequired set, its charge, one of its files) or a CHILD ENTITY\'s (its register,\nits desk, its run, its thread) \u2014 because the sections come off the roles and the\nkey the app addresses one by does not exist until it is built. An alias naming no\nsection is refused, listing the ones that do; the facts band is what every other\nsection left over, so nothing addresses it. Each act is a `template`, an `agent`\nor a `workflow`, exactly as a row\'s is \u2014 the reach is the same one row \u2014 and\n`place: "cta"` is refused: a section is a band with a heading and has no row to\ndraw a verb on. `scaffold check` prints\nwhich section each alias landed on, which is the half an author cannot see in\ntheir own file.\n\n**`sections` says HOW one of those sections is DRAWN**, keyed by the CHILD\nENTITY\'s alias \u2014 every one of its readings is over a child\'s ROWS, so a field\'s\nalias names no section this clause can draw and is refused. It carries four\nreadings, and a clause here OUTRANKS the table above:\nthe derivations there recognise a shape off columns that are also ordinary\ncolumns, so a markdown field holding a refusal reads as somebody\'s message. Where\nthis clause names the reading, it is the reading.\n\n`"draw": "ledger"` heads a book of movements with what the record it is against\nis read against: `summary`, number or DATE fields of THIS record, in the order\nthey read \u2014 what is owed, and by when. Each is drawn as the record\'s own band\ndraws it: a measure with a bound is a meter, a date is a countdown, every other\nfigure is an amount. A date the record works TO is the default; a date a right\nRUNS OUT at \u2014 a free cancellation, a quote\'s validity \u2014 is written\n`{ "field": "refund_until", "sense": "expires" }` and reads closed rather than\nlate once it has passed. A\nsection with nothing in it is otherwise a heading, a verb and a void \u2014 and the\nemptiness is exactly when the reader most needs to be told what the rows would\nbe for, so the statement is drawn at nought rows too. The figures are the\nRECORD\'s rather than the child\'s for the same reason: a sum of the rows under it\nis the one statement a book with no rows cannot make.\n\n`"draw": "worksheet"`: the child\'s rows\nare PRICED LINES the reader works down in place, each part of the job footed and\nthe sheet closing under them. `cost` and `sell` name the child\'s two figures,\nwhich is the one thing the roles cannot decide \u2014 an entity carries one `amount`,\nand a line that is costed and sold carries two. Everything else is read off the\nchild\'s own roles: what a line is FOR is its `identity`, how many it is for is\nthe `measure` the pair left, and the part it falls under is its `category`. The\nMARGIN is on none of them, because it is arithmetic over the pair.\n\n`"draw": "itinerary"` says WHICH FOUR STATEMENTS each stop of a run is read by:\n`line`, in the order they read, from `when`, `kind`, `provider`, `stage`,\n`duration`, `caption` and `reference`. A stop is one press and its supporting\nline holds to one row at every width, so four is the whole of it \u2014 a fifth is a\nfact the stop\'s own drawer answers. Absent, a stop is read by `when`, `kind`,\n`provider`, `stage`, which is how a travel row reads; a roster is read by its\nhours and a courier\'s run by who is at the door, and nothing derives which:\n\n```jsonc\n// A cleaning round: the hour it takes and the flat it is at say more than the kind.\n"sections": { "visit": { "draw": "itinerary", "line": ["when", "duration", "provider", "stage"] } }\n```\n\nEvery run named is one the stops already carry \u2014 `duration` is the child\'s\n`measure` that `counts` hours, `caption` the second `party` a row names, `kind`\nits `category` \u2014 so a word the child declares no role for is refused rather than\ndrawn as a gap in every line, and so is the same word twice.\n\nA sheet is the WRITE side of a price, so it is the one child section whose cells\nare editors: a figure typed into a cell is written as a diff through the child\'s\nown update, which is the same editor the line\'s own drawer saves through. Every\nother child section is read where it is drawn and edited in the row\'s drawer. A\nline is ADDED from the section\'s heading as every child\'s row is, with the record\nit hangs under as the act\'s own context.\n\n`"draw": "publish"`: the child\'s rows are ONE RECORD STANDING ON N\nDESTINATIONS, and the section is drawn as the strip of those destinations rather\nthan as a register of the rows. A register of them is the same record listed once\nper destination with a stage beside it; what a reader wants is every destination\nthey COULD reach, each wearing what has happened there, and the one press that\nsends the ones that are ready.\n\n```jsonc\n"sections": { "pinning": {\n "draw": "publish",\n // The child\'s own lifecycle, all four \u2014 each is a different thing the chip\n // says, and `queued` is the stage its flow OPENS at (a destination is queued\n // by opening a row).\n "states": { "queued": "waiting", "published": "up", "failed": "refused", "by_hand": "by_hand" },\n "text": "wording", // optional \u2014 a text on the child, what THIS destination goes out with\n "permalink": "address", // optional \u2014 a link-formatted text on the child, where it landed\n "error": "fault", // optional \u2014 a text on the child holding the platform\'s own refusal\n "link": "source", // optional \u2014 a link-formatted text of THIS record, the card it carries.\n // A network that takes a card OR a picture takes neither twice, so a plan\n // whose records always carry both leaves the desk nothing it can send there\n "destination": {\n // The destination\'s own select, and which of its options the platform can\n // reach as which network\'s feed. An option this map leaves out is by hand.\n "network": { "field": "surface", "options": { "page": "facebook", "feed": "instagram" } },\n "connected": "account", // optional \u2014 a text on the destination; empty means not connected\n // Which of THIS record\'s bodies each destination receives \u2014 the destination\'s\n // own select, mapping ITS options to text fields here. One alias\n // (`"bodies": "wording"`) is the model with a single body.\n "bodies": { "field": "tongue", "options": { "near": "wording_near", "far": "wording_far" } },\n "offered": { "field": "standing", "in": ["open"] } // optional \u2014 which rows the strip offers; absent, every row\n },\n "publish": "send_pinnings" // the workflow alias the APP binds \u2014 the generator writes no body\n} }\n```\n\nEverything else is read off the roles: the child\'s `parent` (the record), its\n`party` (the destination entity), its `lifecycle` and its `when`; the\ndestination\'s `identity`, `mark`, `contact` (where a post put up by hand is\npasted) and `category` (what the strip folds the chips under); and this record\'s\nown `mark` (the pile the post carries). The destinations are read WHOLE, through\na query of their own, because the strip\'s point is the ones this record has not\ngone to yet \u2014 which the child\'s own read structurally cannot answer with. The\nREGISTER\'s row wears one mark per destination its record has reached, read one\npage of rows at a time; the face and the network each mark wears are read across\nthe `party` link by that read itself, so the model declares no lookup for them.\n\nA network the INSTANCE is not configured for is refused when the send\'s body is\nbound (`tool_capability_unavailable`): a check over a file cannot know which\nproviders this instance holds keys for, so the map is proven against the kit\'s\nnetworks alone and the rest is answered where the body is.\n\nA chip IS the draft, which is why the section mounts no Add: pressing an empty\ndestination opens a row, pressing a queued one takes it back, and both are\nworkflows the generator writes and binds. The create takes the RECORD and the\nDESTINATION and nothing else \u2014 every other column of one of these rows is what\nthe send writes back \u2014 and it reads the desk first, refusing a destination this\nrecord already stands on: a surface guards a second press for the life of a\nmount, and a second tab is a second mount. The withdrawal is the ONE generated\nbody that deletes, and it reads the stage first and refuses anything past the\nqueue. A delete names no column, so `app create` declares the child\'s TABLE in\n`package.json#lotics.deletes` beside the columns in `lotics.writes`, and `lotics\napp check` refuses a body that deletes from a table nothing there names. The SEND\nis the author\'s, like every other hand-off: `lotics app check`\nrefuses an alias nothing bound, and the press hands it the record and the rows it\nis sending.\n\nRefused: an alias naming no register of this record, rows the record does not\nOWN (a line added to a history belongs to the job it is about, not to what is\nreading it), a figure that is not a field of the child or whose role is neither\n`amount` nor `measure`, a sheet naming neither figure, one figure priced as both,\na book headed by anything but a number or date field of the record, and a line on\na section this record draws as a register rather than as a run. A desk is refused\nfor rows that name no destination or carry no lifecycle, a state naming an option\nthat lifecycle has not got, a `queued` stage that is not the one the flow opens\nat, a destination that names no row of itself, a network the kit cannot preview,\nan option of the destination\'s own selects it has not got, and a body, an\noverride, a permalink, a refusal, a card address or a connected account that is\nnot a text field of the entity it is named on. A SECOND desk on one record is\nrefused too: the strip is every destination that record can reach, so two are two\nanswers to one question, and a row of the register wears the marks of one.\n\n**A CHILD NOBODY REGISTERS IS A NOTE, not a refusal.** Where a record lists a\nchild entity no app is a register of, `scaffold check` says `"<entity>" has no\nregister \u2014 its rows are read here and created nowhere` and the plan printout\nmarks that entity `(no app)`. Rows can arrive from an import or a workflow, so\nit is a reading rather than a rule; what it catches is the register somebody\nmeant to plan and did not. A section the record ADDS to is exempt, because its\nheading is where those rows are typed: the rows a record owns on a PAGE, a\nthread on any door \u2014 a reply is written where it is read \u2014 and a sheet, which\nopens its own lines. A record opening in a DRAWER adds none of them, so its\nowned rows are noted like anybody else\'s.\n\n`scaffold check` prints every clause a screen states, on its own line under the\nslots, spelling the screen\'s `writes` clause `operable` \u2014 the app manifest\'s\n`lotics.writes` keeps that word, and the two are different facts: `app create`\nSEEDS the manifest from the fields the screens\' own editors write, and from then\non the app owns the declaration `lotics app check` holds its bodies to.\n\nEach shape is a `@lotics/ui` component of the same name (`LifecycleDesk`,\n`PartyRegister`, \u2026) whose props are these slots, so once the tables exist\n`lotics app create <name> --from <this file>#<app alias>` scaffolds the app with\none screen per entry, each slot reading the field the plan bound.\n\n`"shape": "custom"` is the screen no registry row covers: it declares its own\n`roles` (slot name \u2192 role), and it is emitted on `ShapeFrame` \u2014 the one anatomy\nthe six shapes above are each a configuration of \u2014 so its strip, search,\nordinal, fit budget, empty and waiting states and record door are the same ones\nthey have. It opens the record its `record` says, drawer or page, like any other.\nA bound `lifecycle` slot IS its strip, the stages in the field\'s order as on a\ndesk, so such a screen names no `tabs`. Only a lifecycle earns a strip \u2014 each\ntab a queue of work at one stage \u2014 and a screen naming any other select as\n`tabs` is refused: a kind, a book, a channel is a `filters` entry beside the\nsearch. The three shapes whose strip means something of their own take any\nselect: a reconciliation\'s dated runs, a trend\'s series, a worksheet\'s versions.\n\n**An app IS its `app.json`.** `create --from` writes the bound plan \u2014 every\nscreen with its shape and slots, the record each row opens with its sections in\nthe archetype\'s order, the create panels, the acts \u2014 as one JSON document, plus\na five-line `src/main.tsx` that mounts `@lotics/app-runtime` over it and one\n`src/workflows/<alias>.ts` per write. There is no screen source: the runtime\ndraws the spec, so a kit correction reaches every app with its next install.\nWhere the plan has no word for what a screen or a section IS \u2014 or for what a\npaper act should ASK before it is made \u2014 the spec names one of the app\'s own\ncomponents and `src/components/index.ts` registers it, the one file under `src/`\na regeneration never rewrites. `lotics app eject <screen|<section key>|<act\nlabel>>` writes each of those, starting from what the runtime already drew.\n\n**How a spec reads a row.** Each query projects its columns under the alias the\nworkspace mints from the field\'s LABEL \u2014 never the alias this file keys it\nunder, which is the model\'s own namespace and which the workspace never saw. So\na field this file calls `partner` and labels `\u0110\u1ED1i t\xE1c` is referenced as\n`doi_tac` throughout the spec. Every id in it is live: the `tbl_` a screen is\nover, the `fld_` of each column, and the `opt_` of every option a role names.\n\n**Every query is described, in the model\'s own language.** The line an agent\nchooses between aliases by is written from this file\'s nouns \u2014 the entity\'s\nlabel, the screen\'s, the link\'s \u2014 and the sentence around them is the language\nthe file is mostly written in, decided the same way a generated write\'s refusals\nare. Hand-editing one is erased by the next regeneration; `lotics app check`\nrefuses an alias that carries none.\n\nA screen is that list and the RECORD it opens, and the record comes off the same\nroles \u2014 nothing to declare for it. It opens with a **header**: the entity\'s\n`identity` as the record\'s name \u2014 whether or not the list has a column for it, so\na ledger\'s and a trend\'s records are named too \u2014 the `mark` beside it on a PAGE,\nwhich is what the subject is recognised by (so the pile below is the papers that\nare left, and a `presentation.lead` spending that gutter on something else leaves\nthe picture in the pile), the `when` this screen reads\nunder it, and ONE headline\nfigure (the `amount` the screen reads, else its `measure`). BOTH DOORS state the\nname: which door a screen uses is a layout answer, and what the record is ABOUT\nis not.\n\n**A JOB\'S HEADER ALSO STATES WHO IT IS FOR.** Where the record is the work itself\n\u2014 a desk\'s rows that belong to nothing, opened on a page \u2014 its `party` is a link\nunder the name, beside the ways to reach the subject, or, where that party is a\nworkspace MEMBER, the person themselves with no door behind them, and it is then\nnot a fact\nas well: a page headed by a code alone said nothing about whose work it is, and\nthe one thing a reader arriving from the desk already knows the row by sat in the\ngrid below as a row among forty. A record opening in a DRAWER has no row of links\nto hang it on, so its party stays a fact; so does a LINE\'s, whose register files\nit under the thing it belongs to and whose party is that parent\'s, restated per\nrow.\n\n**A record whose figures are a SENTENCE states them under the header, in a\nband**, and the header then states neither of them: a job reads what it comes to,\nhow far through it is (the `measure` against its limit), what is left of it (a\nformula that is exactly `{amount} - {measure}`) and how long there is (an\n`obligation`, else its `when`, counted down); a party reads its settlement, the\n`amount` and `measure` roles it carries in the order the model declares them; a\ncatalogue item reads its `amount` against the reference that amount names. Two\nfigures are the floor and four the ceiling \u2014 under two, the header keeps its one.\nA band is a PAGE\'s strip, so a screen whose `record` is a drawer keeps every\nfigure where a drawer reads them. Each figure is drawn exactly once: the limit a\nmeter states is not a fact beside it, and the day a countdown counts to is not\nthe provenance line above it. The band draws them, so the band is where they are\nCHANGED \u2014 a date a person states is edited on the countdown and a limit a person\nstates on the meter, while the level between them is read. The `lifecycle` is\nnot badged there, because the progress section is the rung it stands on. Then\nits sections, in the order its KIND reads them: the\n**facts** (every field neither the header, the band nor another section owns, the\nrow\'s own `parent` among them \u2014 led by the ones THIS screen\'s slots bound, then\nthe key it is filed under, then the order the model declares them in \u2014 a\n`markdown` field among them, drawn across the grid\'s whole row rather than\nwrapped to a third of it), the record\'s own **body** (that same markdown, taken\nas a section instead, ONLY where the kind leads with it: a catalogue item IS its\ndescription, and a job is not its notes), the **charge** (where the `amount` is a formula over exactly one\nquantity and one money field, the line states `quantity \xD7 unit price = amount`\nand neither figure is typed twice \u2014 the section is headed by the amount\'s own\nlabel and the closing row says what the figure IS, so the word is said once),\nthe **progress** (the `lifecycle`\'s stages as a ladder the reader ticks a rung of\nto move the record on, grouped into its `phases` where the model states them, and\neach rung carrying the day it was reached where the lifecycle keeps a\n`history`), a\n**required set** (a multi-select `expected_set`, or a child entity whose rows\ncarry one entry of it each \u2014 that child\'s `files` field is what a paper attaches\nto), the record\'s **own rows** (any other child, its role-bound fields as\ncolumns), and its **files** (every `files` field the header does not lead with,\nthe `mark` first where it keeps one \u2014 a section of\nthe column like any other, straight after the facts it is the evidence for,\nexcept on an EVIDENCE record, which OPENS on the paper it exists for; the\nheading carries the Add verb, and stands it down while the pile is empty, where\nthe drop well is already the door; and a `verdict` whose FORMULA reads one of\nthose files fields is the pile\'s OWN state rather than a fact row \u2014 the file it\nis short, drawn as a ghost, and the mark the rail sends a reader to). A child is\nan entity that LINKS here, ONE SECTION PER LINK: the rows it owns (`parent`), the\nrows NAMED by it (their `party` or their `identity` \u2014 so this record\'s history),\nand the rows reaching it through a link carrying neither role, headed by that\nlink\'s own label. A child hanging off two records therefore declares one `parent` and\nleaves the second relationship a plain link, which still gets its section.\n\n**THE ROWS A RECORD OWNS ARE ADDED AND OPENED FROM THEIR SECTION.** A `parent`\nsection\'s heading carries the Add for its rows, handing the record as the\nparent \u2014 filled, never asked. Every row opens its own record \u2014 a line of a book,\na paper on a desk, a stop on a timeline alike: where this app already routes a\npage for those rows (the register\'s own, under one of them) it is navigated to;\notherwise it is a DRAWER mounted on the record it belongs to, stepped \u25C0 \u25B6 over\nthe section\'s rows as they are drawn, with the row\'s framed editors, its ladder\nand its files, saving through `update_<child>` \u2014 the one editor those rows have,\nwhich any control the section draws over a row writes through too. Never the\n`parent` link itself: it is the key the row is FILED under, stated by the record\nthe drawer was opened from, so it is neither a fact of the drawer nor an input\nof that editor. ONE LEVEL, and only from a PAGE: a child\'s own children draw as\nthe kit\'s panel of the row, with no drawer and no Add, and a register whose own\nrecord is a DRAWER is the same for the children it owns \u2014 a drawer mounts no\ndrawer. **A ROW IS ADDED WHERE IT CAN BE OPENED AGAIN**, so those sections lose\ntheir Add with their drawer; a row of the register\'s OWN kind keeps it, because\nthe register\'s list opens it. A correspondence\'s entries are the one owned row\nthat opens nothing anywhere: the composer under them is the section\'s Add\nwhatever door the record has, and the answer mark is one field of the reply\'s\nown editor. The rows a record is merely NAMED by, and those reaching it\nthrough a roleless link, are read only \u2014 a history, never a place to open or\nadd.\n\n**TWO CHILDREN ARE READ AS A RUN RATHER THAN AS A REGISTER**, and the model says\nwhich by the roles it declares on them. A child carrying an `obligation` whose\n`satisfied_by` is a DATE \u2014 and no `lifecycle`, because an entity with stages of\nits own is a JOB being worked and its rows under another record are that record\'s\nlines or its history \u2014 is a **timeline**: its rows are the ordered stops, each\nstating the day it was promised for and the day it happened, with the delta named\nwhere the rung was late \u2014 drawn as a register those are two date columns and the\nreader subtracts them by eye, which is exactly what "three days late at\ndischarge" costs to learn. An obligation closed by a FILE says nothing about\nwhen, so those rows stay the register they were. A child carrying a `markdown`\ntext field beside a `party` and a `when` \u2014 and naming no member of this\nworkspace, which would make it a log \u2014 is a **thread**: the entry is the body,\nthe author and the day lead it, and a `verdict` on the child is the yes/no one\nreply is marked the ANSWER with \u2014 a register of messages is a table whose one\nuseful column is the one it cannot draw. A SECOND `party` on those rows is who\nthe entry was left WITH, and the latest one names the ball in court: while\nnothing answers, the record says whose move it is and counts its own deadline\n(an `obligation`, else its `when`) against them. Both are read only under the\nrecord that OWNS the messages \u2014 under the party who wrote them the same rows are\nthat party\'s history, several questions at once, and the last of them says\nnothing about any. That record also REPLIES: the section\'s Add is a composer \u2014\nthe message is all a reply asks \u2014 and the answer mark is one field of the\nreply\'s own editor.\n\n**A body and a day, under a record that OWNS the rows, is a LOG** \u2014 what was\ndone, when, and the entry in the words somebody wrote. WHO WRITES THE ENTRY\nDECIDES WHICH IT IS: a `select_member` on the child makes it a log whatever else\nit names, because a member of this workspace keeps a log and a party writes a\nmessage. So a `party` on a log entry is who it was WITH, drawn on that entry\'s\nown line beside the `contact` it reached them at and the files that came with\nit \u2014 never its author, and never a move outstanding. The other selects on the\nrow are facts ABOUT the entry. A NAMED row is a log entry, not a message: a\nvisit or an inspection is a thing in its own right, which is what keeps it out\nof the thread. Its Add is a composer too, taking the entry, the day (opening on\ntoday) and the entry\'s own selects; the record it hangs under is not asked.\n\n**A PROFILE LEADS WITH ITS LADDER.** An account walks stages like any other work,\nso a profile carrying a `lifecycle` reads `progress` first \u2014 right under the\nband, the same place a job leads with the act that moves it \u2014 and its histories\nfollow. Under them the ladder was seven bands down a page nobody scrolls that\nfar.\n\n**NOTHING SITS BESIDE THE RECORD.** A page is ONE reading column \u2014 its fields,\nits files, the rows it owns, its notes, all sections of it in the archetype\'s\norder \u2014 over a reserved left gutter holding a floating NAVIGATION RAIL: the way\nback, then one line per section with that section\'s category mark, and nothing\nelse. No files, no counts, no group headings. Too narrow to seat the rail, it is\none pinned bar of the same list across the top. A section HEADING never wears\nthe mark; the rail\'s line for it does.\n\n**A RELATION THE RECORD NEVER ACTS ON CLOSES THE COLUMN.** Every register that\nmerely NAMES the record is one counted line of a single last section: its label,\nhow many rows name this record (a COUNT, never a page of rows measured), and a\npress that opens the register owning them narrowed to this record. An entity\nthis app lists nowhere is a count with no door, and where every count is zero\nthe section is not drawn at all. The one exception is a PROFILE, whose history\nIS its body and stands open in the column.\n\n**Rows the record owns whose `amount` is `signed_by` a direction are a\nBOOK**, drawn as a statement \u2014 each line dated and signed, the balance under\nthem where there is more than one line to add up, labelled by the amount\'s own\nword \u2014 where the same rows on the party they\nwere transacted with stay that party\'s history, which is scanned for the line\nshort of its paperwork rather than struck to a balance. **Rows that carry a\n`when` read as RUNS**: a party\'s history under the year \u2014 and only where the rows\nin hand span more than one, since a subhead over every line of a book names the\nyear and separates nothing \u2014 and a job\'s own rows under the day they fall on,\nwhich, where those rows also carry a `lifecycle`, is an ITINERARY rather than a\ngrouped table: the day heads the run, each row is one line (its `slot`, its name,\nits party, its stage, its `category` and `reference`, its amount), the figures end\non one edge and the run closes with their sum \u2014 unless the record\'s own BAND is a\nsum rollup over exactly these rows, which states it once already. A `category`\npaints the node the run is read down, in the option\'s own colour; a `reference`\ncloses the line with the code somebody quotes on the phone. **A `mark` LEADS the\nline with the stop\'s own picture** \u2014 a venue, a vehicle, a machine \u2014 and it is the one\nplace on a record where a child entity\'s mark is drawn at all, since no register\ncolumn is ever a picture; a line whose subject holds none is marked with its\n`category`\'s glyph instead, so the run keeps one left edge. All three are\nPROJECTED for the run whether or not the register\'s column budget would have\ndrawn them, and a mark is projected NOWHERE ELSE.\nA line\'s mark is most often the SUBJECT\'s rather than the line\'s own \u2014 the\nservice, the product, the asset it is a line of \u2014 which is a `lookup` through\nthat link onto the subject\'s own `files`, given the role on the line.\n**EACH DAY\'S HEAD CARRIES THE SECTION\'S OWN ADD** as well, handing the panel that\nday, so a line made from Tuesday opens with Tuesday answered; the heading keeps\nthe verb too, for the first line and for a day nothing is planned on yet. **THE\nKEY LEAVES THE COLUMNS where the head states the whole of it** \u2014 a day head does,\na year head states four digits of the date and the day is what the column is\nstill there to carry.\n**A run is READ FROM THE END THE READER WANTS**: a job\'s own rows and an itinerary\nascending, a book, a history and a log descending.\n**A REGISTER WHOSE `when` IS A DEADLINE IS READ FROM THE NEAR END** whatever its\nshape does \u2014 `due` (or an `until` that says where the countdown stops) makes the\ndate a promise, so the rows in breach of it lead and a row nobody dated falls\nlast. A plain `when` keeps the shape\'s own end. `scaffold check` prints the\npromise (`order: Theo d\xF5i ti\u1EBFp, overdue first`).\n\n**A DESK OVER AN ENTITY THAT CARRIES A `slot` IS ITSELF A DAY\'S RUN.** Its own\nregister is drawn under one subhead per day, sorted by that day and then by the\npart of it, and the date column goes \u2014 the subhead states it once for the whole\nrun, and a column repeating it down every line is the same value twice. Without\nit an itinerary was one flat list in whatever order the rows arrived.\nA document desk keeps its head either way \u2014 the entries it owes are its\ncontent, filed or not. The name and whatever figure is left to it are the header\'s\nalone \u2014 it states them in full, so a fact for either would be the same sentence\ntwice.\n\n**A FACT IS PRIMARY OR IT IS PROVENANCE.** An `autonumber`, a date the platform\nstamps (`derive_from`) and a date a formula works out that no role names are what\nthe SYSTEM wrote; they fold, with every optional field this record does not\nstate, behind the grid\'s ONE link. Everything a person types or picks is primary\nand is shown, empty or not, because absence is work somebody owes. **A screen\nthat named `facts.groups` has already said which:** a field in a group is\nprimary, and one in none folds \u2014 grouping says what the reader decides with, and\nby saying so says the rest are not. A `tier` on a `facts` entry exists only for\nthe case that derivation gets provably wrong.\n\n**A `contact` field whose kind the\nmodel decides** \u2014 an email, a number, a place, a `link`-formatted text, or a\nMESSAGING APP its label names (WhatsApp, Zalo, Telegram, Viber, WeChat, Line,\nMessenger) \u2014 leaves the facts too, for the row of links under the name on a\nrecord that opens as a page; one whose kind nothing decides stays a labelled\nfact, because a scheme nobody stated is a link that opens nothing. A handle is\ndrawn under its app\'s name \u2014 the app is what identifies the person there, and a\nbare number beside an envelope says the wrong thing \u2014 and it dials only where its\nvalue IS a number.\n`check` prints the record under each screen\'s slots:\n\n```\n Orders \u2014 lifecycle desk over Orders (12 rows) \xB7 page \xB7 tabs: Stage (New \u2192 Quoted \u2192 Confirmed \u2192 Shipped \u2192 Done)\n stage Stage \xB7 identity Order no. \xB7 mark Photo \xB7 party Customer \xB7 amount Total \xB7 level (none \u2014 no field declares "measure") \xB7 when Due\n record: header (Order no. \xB7 Customer) \xB7 band (Total \xB7 Shipped against Lines \xB7 Due counting down) \xB7 progress: Stage (5 stages) \xB7 facts (Ship to \xB7 \u2026 2 filed) \xB7 files: Photos \xB7 documents: Papers (Kind: 4 required) \xB7 itinerary: Order lines by Ship by at Window, kind Handling, ref Waybill (Product \xB7 Quantity \xB7 Line total) \xB7 related: Visits (count via Order)\n```\n\nThe ORDER of that line is the record\'s own, top to bottom, and it is the KIND\'s,\nand every entry of it is a section of the one column. `related:` is the counted\nline that closes it \u2014 a register the record never acts on, named and counted \u2014\nand `\u2026 N filed` is how many facts fold behind the grid\'s one link.\nA child register whose `amount` is `signed_by` a direction is a BOOK of movements\n\u2014 what the record came to rather than what it is made of \u2014 so it closes the\ncolumn rather than standing among the rows the job is made of.\n\n**A section over a child is named by the CHILD\'s own label**, because the reader\nalready knows which record they are on: "\u0110\u01A1n h\xE0ng", not "\u0110\u01A1n h\xE0ng \u2014 Kh\xE1ch h\xE0ng".\nWhere two links from one entity reach this record, that label names two sections\nand each takes its own link\'s label as a qualifier \u2014 `H\u1ED3 s\u01A1 (\u0110\u01A1n h\xE0ng)` and\n`H\u1ED3 s\u01A1 (\u0110\u01A1n g\u1ED1c)`.\n\nIn that line `documents:` is a `RecordExpectedSet`, and `lines:` a `RecordChildren` over the rows\nthis record owns \u2014 `history:` where they NAME it instead, as their `party` or their `identity`, capped at its latest 5\nwhere the record is a profile (`latest 5 of N by <day>`, the whole of them behind the foot); a required set\nwhose entries are a CHILD entity carrying files takes `kind="files"`, and the entity\'s own\nmulti-select takes `kind="items"`, since nothing attaches to an option.\n\n**A section that knows its size says so.** Where a `measure` on the record is a\n`count` rollup over the very link a rows section hangs on, the limit it is read\n`against` is how many rows that section is OWED: the printout adds `\u2014 expects\n<limit>` and the screen draws that many ghost rows until the first one lands,\ninstead of "nothing here" two bands under a count saying three are outstanding.\n\nA figure printed as `Total (USD)` is MONEY in the currency named \u2014 a `formula`\nstates it in `formula.currency`, and a `rollup` or a `lookup` inherits it from the\nfield it reads, so read those brackets: a rate that should be in dollars and is\nprinted bare will be drawn in the workspace\'s own money. Where the entity carries\na `currency` role, the ROW\'s code outranks the field\'s on every line that states\none, and a line stating none falls back to the field\'s.\n\n**A record surface can be OPERATED, and that is the default.** Every screen with\na record gets a workflow that writes its editable facts \u2014 every field a person\nstates \u2014 so the record\'s values are edited in place and its stage is advanced\nfrom the progress section. `"writes": false` makes one screen a reader: use it\nfor a screen over rows another desk owns, never as the default. A desk nobody\ncan act on is a viewer of state somebody must go and set somewhere else. A\nreader keeps the acts that only READ the row \u2014 its papers and its `export` \u2014\nand refuses every one that writes it: the quick column, the agent act, the\nhand-off on any surface, and the file an `import` would mint rows from.\n\n**`"writes": "children"` splits the record from the rows under it.** The record\nis drawn exactly as `false` draws it \u2014 the same refusals, and the register opens\nno row of its own \u2014 while every section listing the rows that record OWNS keeps\nits Add, its drawer and its own editor. Use it where one desk states the plan\nand another logs against it: the hours typed under a period the office set, the\nitems ticked under a job somebody else opened.\n\n**Which facts those are is the FIELD\'s answer.** Text, number, date, yes/no and\nselect are typed or picked. A `"cardinality": "one"` link is RE-POINTED, through\na picker over the target entity named by its `display_field_aliases` \u2014 else the\ntarget\'s `identity` \u2014 narrowed by what the reader types and carrying that\nentity\'s own `read_scope`, so a link the plan gives neither column is read\ninstead of offering an empty list. A files field is ATTACHED TO and DETACHED\nFROM, as the delta rather than the pile, so two readers filing at once each keep\ntheir file. A many-link, an `autonumber` and every computed field are read: the\nfirst is a list a fact cannot state, and the rest are the platform\'s to write.\n\n**A one-link is that fact WHATEVER its sync.** `sync_both_ways` is one relation\nwith a field on each side, and the sides are not the same surface: the record\nthat names ONE row states it as a fact, and the MANY side is the register on the\nother entity\'s record. Declaring both directions does not give this record a\nregister of the single row its own fact already names.\n\n**The write is the record SURFACE\'s.** Its inputs are the fields that surface\noffers to save \u2014 the facts with an editor, the lifecycle its ladder advances, a\nfiles section over exactly one stated field, and what the BAND states rather than\nreads: its countdown\'s date, and the limit under a meter where that limit is a\ncolumn a person types \u2014 never every field a person could in principle type. What\nthe header states, a limit a level folds into a meter among the FACTS, a required\nset with a section of its own and the pile a page\'s mark draws are read there, so\nno input is declared for them. A second screen over the same entity draws no\nsurface of its own and adds nothing.\n\nWhere two operable screens of one plan reach the same entity, `scaffold check`\nnames them and the apps they belong to: two desks writing one record is a\ndecision, and the split is stated on the FIELD in each app\'s\n`package.json#lotics.writes` rather than left to whoever edits second.\n\nThat workflow lands in the app as source, like every other: its body in\n`src/workflows/update_<entity>.ts` and its declaration in\n`package.json#lotics.workflows`. The two together are the binding \u2014 `lotics app\ndeploy` pushes whatever differs from what it last saw live \u2014 so the write is\nversion-controlled and travels with the app rather than being bound by hand\nafterwards.\n\nA `custom` screen declares its slots under `roles` (slot \u2192 role) and they bind\nthe same way:\n\n```jsonc\n{ "alias": "readings", "label": "Readings", "shape": "custom", "entity": "reading",\n "roles": { "subject": "identity", "reading": "measure" } }\n```\n\n## Applying packages\n\n`apply` copies published packages into the workspace AFTER the model\'s own\ntables exist \u2014 apps over the tables you just described, and any tables of their\nown they still need. Ordered, and run by `lotics setup` and `lotics scaffold\napply` alike.\n\n```jsonc\n"apply": [\n {\n "package": "apg_k3nf82ldpq",\n "bind": { // optional \u2014 which of YOUR tables each entity is\n "company": { "label": "Customers", "fields": { "name": "Company name" } }\n },\n "no_sample_data": true // optional\n }\n]\n```\n\n`bind` is keyed by the package\'s entity alias and holds the LABELS this\nworkspace uses: scaffold adopts by label, so binding points the package at the\ntables the model created instead of a second set beside them. Only naming\nmoves \u2014 a bound field must be the TYPE the package declares, or the copy is\nrefused. `lotics library list` is the shelf, and `lotics library show <apg_id>`\nlists the aliases to bind.\n\nEntries run in the order they are written, because a later one may bind onto a\ntable an earlier one created. **A refused entry stops the run and the entries\nbefore it stay** \u2014 they are separate copies, committed as they land, so the\nrefusal names them rather than leaving a caller to re-run the file and copy them\ntwice.\n\n### 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 verb binds through it: `apply`, `diff`, `app create --from`,\n`app regenerate --from` and `workspace build` take the remembered id first and\nfall back 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: `diff` prints one line under the alias\n(`order.state: label: model "Stage" \xB7 workspace "Giai \u0111o\u1EA1n"`), and `apply` binds\nthe thing it has always meant, reports the move and names the command that makes\nthe two agree \u2014 which side is right is yours to decide, not a reason to refuse\nthe 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, and\n`lotics scaffold unbind <kind> <alias>` forgets the binding \u2014 after which the\nnext apply creates a new one and nothing joins it to what was there.\n\n## Presets\n\nA preset is a trade\'s model, published to be READ. An assistant reads it, asks\nat most two questions, picks a variant and writes a `model.json` from it \u2014\nnothing is copied, and a preset is a file rather than anything a workspace\ninstalls.\n\n```jsonc\n"preset": {\n "name": "Field service",\n "description": "Jobs, the crew that runs them, and what each one billed.",\n "questions": ["Do you dispatch crews, or one person per job?"], // at most 2\n "variants": {\n "crews": {\n "when": "work is dispatched to crews rather than to one person",\n "entities": [ /* tables this branch ADDS */ ],\n "fields": { "job": [ /* fields this branch ADDS to `job` */ ] }\n }\n }\n}\n```\n\nVariants are **additive only**: a branch adds entities and fields and never\nremoves them, so the base is a model in its own right rather than a draft.\n`lotics scaffold check` proves the base AND every variant merged onto it, so a\npreset ships with every branch already proven \u2014 the branch nobody took is the\none that fails in the workspace of whoever takes it.\n\n`preset` is not scaffolded. `lotics setup` and `lotics scaffold apply` ignore\nit and create the base model\'s tables.\n\n`lotics scaffold export` prints a workspace that already works as one of these\nfiles \u2014 the starting point for a preset or for another business\'s model, never a\nsource of truth: it carries one business\'s words and stops describing that\nworkspace the moment either changes.\n\n## Starting from a preset\n\n`lotics library list` is the shelf of them and `lotics library show <slug>`\nprints one whole: its questions, every table as `alias \xB7 label` with each field\nas `alias:type`, and each variant as `slug \xB7 when` followed by the tables and\nfields that branch adds. When one of them is the trade in front of you, do not\ntranscribe it \u2014 name it:\n\n```jsonc\n{\n "from": "field_service",\n "variants": ["crews"],\n "rename": { "job": { "label": "\u0110\u01A1n h\xE0ng", "fields": { "code": "M\xE3 \u0111\u01A1n" } } },\n "entities": [ /* a table this business has that the preset does not */ ],\n "rows": { "job": [ { "ref": "j1", "fields": { "code": "J-1" } } ] },\n "field_roles": { "job": { "code": "identity" } },\n "write_rules": { "customer": { "natural_key": ["email"] } },\n "apps": [ /* the screens this business\'s apps will have */ ]\n}\n```\n\n- **`from`** is the preset\'s SLUG \u2014 its own file name, a lowercase slug. Naming\n it is what makes `entities` optional; every other rule on this page is\n unchanged, because the file is resolved into the full form and then checked and\n applied exactly as one. A slug nothing serves is refused with the ones there\n are, never resolved against something else.\n- **`variants`** names the branches to merge onto the base, in order. Pick the\n one whose `when` describes what the person said; a slug the preset does not\n declare is refused rather than ignored.\n- **`rename`** is keyed by the preset\'s entity alias and holds the labels this\n business uses \u2014 the same shape `apply[].bind` takes, and the same rule: only\n naming moves. An alias the preset does not declare, and a label that is\n already another table\'s, are both refused.\n- **`entities`** are added after the rename, already in this business\'s own\n words.\n- **`rows`**, **`field_roles`**, **`write_rules`**, **`apps`** and **`apply`**\n mean exactly what they mean in the full form \u2014 `"rows"` are this business\'s\n real first records,\n `"field_roles"` may name the preset\'s fields as well as its own (a role the\n preset declares itself is kept unless this file names the same field, or\n clears it with `null`), `"write_rules"` the create-time clauses on either\n (an entity this file names replaces the preset\'s whole entry for it),\n `"apps"` the screens it will have (a preset carries none), `"apply"` the\n packages copied in once its tables exist.\n\nThis is the ONE thing on this page that needs the network: `check` reads the\npreset it names, once. Everything after that read is the same offline check.\n\nWrite the full form when no preset is the trade.\n\n## A complete model\n\n```json\n{\n "entities": [\n {\n "alias": "customer",\n "label": "Customers",\n "singular": "Customer",\n "fields": [\n { "alias": "name", "label": "Name", "type": "text", "required": true },\n {\n "alias": "tier",\n "label": "Tier",\n "type": "select",\n "options": [\n { "alias": "standard", "label": "Standard", "color": "slate" },\n { "alias": "gold", "label": "Gold", "color": "amber" }\n ],\n "default": ["standard"]\n },\n {\n "alias": "orders",\n "label": "Orders",\n "type": "select_record_link",\n "target_entity": "order",\n "cardinality": "many",\n "sync_both_ways": true,\n "paired_field_alias": "customer",\n "display_field_aliases": ["code"]\n },\n {\n "alias": "total_ordered",\n "label": "Total ordered",\n "type": "rollup",\n "source_field_alias": "orders",\n "aggregate_option": { "operation": "sum", "field_key": "amount" }\n }\n ],\n "views": [\n {\n "alias": "gold",\n "label": "Gold customers",\n "filters": {\n "node_type": "condition",\n "type": "select",\n "field_key": "tier",\n "operator": "has_any_of",\n "value": ["gold"]\n },\n "sort": [{ "field_key": "name", "order": "asc" }]\n }\n ]\n },\n {\n "alias": "order",\n "label": "Orders",\n "singular": "Order",\n "fields": [\n { "alias": "code", "label": "Order no.", "type": "text", "unique": true },\n { "alias": "placed_on", "label": "Placed on", "type": "date", "format": "date" },\n {\n "alias": "amount",\n "label": "Amount",\n "type": "number",\n "format": "currency",\n "currency": "VND"\n },\n {\n "alias": "total",\n "label": "Total with VAT",\n "type": "formula",\n "formula": { "expression": "{amount} * 1.1", "format": "currency", "currency": "VND" }\n },\n {\n "alias": "customer",\n "label": "Customer",\n "type": "select_record_link",\n "target_entity": "customer",\n "cardinality": "one",\n "sync_both_ways": true,\n "paired_field_alias": "orders",\n "display_field_aliases": ["name"]\n }\n ]\n }\n ],\n "roles": [{ "alias": "sales", "label": "Sales" }],\n "field_roles": {\n "customer": { "name": "identity" },\n "order": { "code": "identity", "placed_on": "when", "amount": "amount", "customer": "party" }\n },\n "apps": [\n {\n "alias": "customers",\n "name": "Customers",\n "screen": { "alias": "customers", "label": "Customers", "shape": "party_register", "entity": "customer" }\n },\n {\n "alias": "orders",\n "name": "Orders",\n "screen": { "alias": "orders", "label": "Orders", "shape": "transaction_ledger", "entity": "order" }\n }\n ],\n "rows": {\n "customer": [\n { "ref": "acme", "fields": { "name": "Acme Trading", "tier": "gold" } },\n { "ref": "bluebird", "fields": { "name": "Bluebird Foods", "tier": "standard" } }\n ],\n "order": [\n {\n "ref": "so_1001",\n "fields": {\n "code": "SO-1001",\n "placed_on": "@month-start+2",\n "amount": 4200000,\n "customer": "customer:acme"\n }\n },\n {\n "ref": "so_1002",\n "fields": {\n "code": "SO-1002",\n "placed_on": "@today-3",\n "amount": 1150000,\n "customer": "customer:bluebird"\n }\n }\n ]\n }\n}\n```\n\n`lotics scaffold check` on this file reports\n`2 tables, 9 fields, 2 links, 1 view, 1 role, 4 rows, 2 apps`, then\nthe plan:\n\n```\nCustomers\n Customers \u2014 party register over Customers (2 rows) \xB7 page (default) \xB7 tabs: none (default)\n identity Name \xB7 mark (none \u2014 no field declares "mark") \xB7 contact (none \u2014 no field declares "contact") \xB7 worth (none \u2014 no field declares "measure") \xB7 risk (none \u2014 no field declares "verdict")\n presentation: leads with nothing (default), roomy (default) \xB7 above: rows (default)\n record: header (Name) \xB7 history: Orders (latest 5 (default) of N by Placed on) (Order no. \xB7 Placed on \xB7 Amount (VND)) \xB7 facts (Tier \xB7 Total ordered (VND))\nOrders\n Orders \u2014 transaction ledger over Orders (2 rows) \xB7 drawer (default) \xB7 tabs: none (default)\n when Placed on \xB7 amount Amount (VND) \xB7 reference Order no. \xB7 party Customer \xB7 classification (none \u2014 no field declares "lifecycle") \xB7 document (none \u2014 no field declares "expected_set" or "verdict")\n order: Placed on, newest first (default) \xB7 presentation: leads with the figure (default), roomy (default) \xB7 above: Amount (VND) (default)\n record: header (Order no. \xB7 Placed on \xB7 Amount (VND)) \xB7 facts (Placed on \xB7 Total with VAT (VND) \xB7 Customer)\n party: Customers \u2014 opens from Customer (profile: header (Name) \xB7 history: Orders (latest 5 (default) of N by Placed on) (Order no. \xB7 Placed on \xB7 Amount (VND)) \u2014 foot: the whole register, narrowed to this record \xB7 facts (Tier \xB7 Total ordered (VND)))\nWho writes what:\n Customers (customer, one: Customer): Customers \xB7 written from the rows of Orders: facts\n Name: required\n Orders (order, one: Order): Orders\n```\n\nA `party:` line is the record a NAME on a row opens. A party is not a job, so it\ngets no app of its own \u2014 but a clerk reading the orders book presses the\ncustomer and corrects the phone number there, so the app whose rows name a party\ncarries that party\'s own record beside its register\'s. It is printed in the same\ngrammar the `record:` line is, because it is the same kind of surface: a profile,\non a page, its histories capped. Which cells are the door is what `opens from`\nsays \u2014 `the name` where the register\'s subject IS the party.\n\nThe last block is the WRITERS matrix \u2014 one line per record surface the plan\nleaves operable, the word one of its rows is called, and the app whose register\nopens it. Part of the verdict rather than a diagnostic beside it: which desk\nchanges a table is answerable from the plan, and two apps on one line is the\nsplit to state in each of their `package.json#lotics.writes`. A\n`written from the rows of \u2026` clause is the other reach: the party that register\nnames, and what the app can change on it \u2014 counted apart, because several\nregisters naming one account is the model working as designed rather than a\nsplit to declare. A publish desk\'s rows get a clause of their own, because the\npress that withdraws one DELETES it \u2014 the app declares that table in\n`package.json#lotics.deletes`, and `lotics app check` refuses a body that deletes\nfrom a table it does not name. Indented under each entity are the clauses only a\ncreate meets: the cells a row cannot stand without, and what `write_rules` state.\n\nEvery `(none)` is a slot no field fills, and it says WHY: no field declares the\nrole, or several could and the plan named none of them (`(none \u2014 a, b; name\none)`). Read it as the screen a person will see. `above: \u2026 (default)` is the band\nnobody stated \u2014 the shape sums the amount its slot draws, and a plan naming its\nown figures loses the word. Neither\nentity carries a stage, a required set or files, so the rest is facts \u2014 except\nthe sections neither entity declares: the customer\'s `history:` is the orders\nthat name it as their party, and the order\'s `via Orders:` is the customers whose\n`Orders` link names it. **A link is a section, not a fact**, on the side it\npoints AT and on the side that holds it: `Customers.Orders` therefore leaves the\ncustomer\'s facts, because the section below already lists the same relation and\na link fact can only ever show the first of them. Both records state their name\nin the header and nowhere else \u2014 a ledger\'s columns are the date and the figure,\nso `Order no.` is on no column of that list and is still what the drawer behind\na line is called. `Total ordered (VND)` is the rollup inheriting the currency of\nthe column it sums: money is read off the model, never off the field\'s own line.\n';
|
|
65405
66380
|
|
|
65406
66381
|
// src/scaffold_commands.ts
|
|
65407
66382
|
function printModelReference() {
|
|
@@ -65564,6 +66539,34 @@ function fieldName(model, entity, field) {
|
|
|
65564
66539
|
if (unit.currencyField !== void 0) return `${field.label} (in ${unit.currencyField.label})`;
|
|
65565
66540
|
return unit.currency === void 0 ? field.label : `${field.label} (${unit.currency})`;
|
|
65566
66541
|
}
|
|
66542
|
+
function describePublish(model, resolved, section, clause) {
|
|
66543
|
+
const child = section.child;
|
|
66544
|
+
const party = child.fields.find(
|
|
66545
|
+
(field) => field.type === "select_record_link" && roleOf(model.field_roles, child.alias, field.alias)?.role === "party"
|
|
66546
|
+
);
|
|
66547
|
+
const target = party?.type === "select_record_link" ? model.contract.entities.find((one) => one.alias === party.target_entity) : void 0;
|
|
66548
|
+
if (target === void 0) return `publish: ${child.label} (no destination)`;
|
|
66549
|
+
const network = target.fields.find((field) => field.alias === clause.destination.network.field);
|
|
66550
|
+
const options = network?.type === "select" ? network.options : [];
|
|
66551
|
+
const reached = options.filter((option) => clause.destination.network.options[option.alias] !== void 0);
|
|
66552
|
+
const byHand = options.length - reached.length;
|
|
66553
|
+
const through = reached.length === 0 ? "none through the API" : `${reached.map((option) => option.label).join(" \xB7 ")} through the API${byHand === 0 ? "" : `; ${byHand} by hand`}`;
|
|
66554
|
+
const body = (alias) => {
|
|
66555
|
+
const field = resolved.entity.fields.find((one) => one.alias === alias);
|
|
66556
|
+
return field === void 0 ? alias : fieldName(model, resolved.entity, field);
|
|
66557
|
+
};
|
|
66558
|
+
const written = clause.destination.bodies;
|
|
66559
|
+
const bodies = (() => {
|
|
66560
|
+
if (typeof written === "string") return `one body (${body(written)})`;
|
|
66561
|
+
const picks = target.fields.find((field) => field.alias === written.field);
|
|
66562
|
+
const said = (picks?.type === "select" ? picks.options : []).flatMap((option) => {
|
|
66563
|
+
const onto = written.options[option.alias];
|
|
66564
|
+
return onto === void 0 ? [] : [`${option.label} \u2192 ${body(onto)}`];
|
|
66565
|
+
}).join(", ");
|
|
66566
|
+
return `bodies by ${picks === void 0 ? written.field : picks.label} (${said})`;
|
|
66567
|
+
})();
|
|
66568
|
+
return `publish: ${child.label} \u2192 ${target.label} (${through}) \xB7 ${bodies} \xB7 "Publish" \u2192 ${clause.publish} [you write the body]`;
|
|
66569
|
+
}
|
|
65567
66570
|
function describeRecord2(model, resolved, register = resolved.entity.alias) {
|
|
65568
66571
|
const labels = (owner, fields) => {
|
|
65569
66572
|
const route = routeOf(owner, model.field_roles);
|
|
@@ -65625,7 +66628,9 @@ function describeRecord2(model, resolved, register = resolved.entity.alias) {
|
|
|
65625
66628
|
const foot = section.child.alias === register ? " \u2014 foot: the whole register, narrowed to this record" : "";
|
|
65626
66629
|
return `history: ${rows(section.child, section.link)} (latest ${HISTORY_ROWS} (default) of N${by}) (${held})${foot}`;
|
|
65627
66630
|
}
|
|
65628
|
-
|
|
66631
|
+
const drawnAs = resolved.screen.sections?.[section.child.alias];
|
|
66632
|
+
if (drawnAs?.draw === "publish") return describePublish(model, resolved, section, drawnAs);
|
|
66633
|
+
if (drawnAs?.draw === "worksheet") {
|
|
65629
66634
|
return `sheet: ${rows(section.child, section.link)} (${labels(section.child, section.fields)})`;
|
|
65630
66635
|
}
|
|
65631
66636
|
const run = recordItinerary(archetype, recipeSlot, section.child, model.field_roles, resolved.screen.sections);
|
|
@@ -65812,7 +66817,7 @@ function describeCarried(model, resolved) {
|
|
|
65812
66817
|
const { acts, summary, period } = resolved;
|
|
65813
66818
|
const name = (field) => fieldName(model, resolved.entity, field);
|
|
65814
66819
|
const act = (entry) => {
|
|
65815
|
-
const where = entry.place === "cta" ? " [on the row]" : ""
|
|
66820
|
+
const where = `${entry.place === "cta" ? " [on the row]" : ""}${entry.confirm === true ? " [asks first]" : ""}`;
|
|
65816
66821
|
if (entry.kind === "agent") return `"${entry.label}" \u2192 ${entry.agent} fills ${entry.fills.map(name).join(", ")}${where}`;
|
|
65817
66822
|
if (entry.kind === "workflow") {
|
|
65818
66823
|
const taken = entry.inputs.map((input) => `${input.name}: ${input.field === null ? "the record" : name(input.field)}`).join(", ");
|
|
@@ -65914,10 +66919,12 @@ function describeCarried(model, resolved) {
|
|
|
65914
66919
|
// prices and which of the child's figures is the base and which the answer —
|
|
65915
66920
|
// and a BOOK says what the record it is against comes to, which is the line
|
|
65916
66921
|
// its reader sees before the first row exists.
|
|
65917
|
-
// A
|
|
65918
|
-
//
|
|
66922
|
+
// A PUBLISH DESK states everything it is in the record line itself — the
|
|
66923
|
+
// destinations, the bodies and the press — so a second clause here would be
|
|
66924
|
+
// the same sentence a line apart; and a RUN'S LINE is printed by the section
|
|
66925
|
+
// that draws it, beside the columns each stop is read off.
|
|
65919
66926
|
...Object.entries(resolved.screen.sections ?? {}).flatMap(([alias, drawn2]) => {
|
|
65920
|
-
if (drawn2.draw === "itinerary") return [];
|
|
66927
|
+
if (drawn2.draw === "publish" || drawn2.draw === "itinerary") return [];
|
|
65921
66928
|
return [
|
|
65922
66929
|
drawn2.draw === "ledger" ? `book on ${alias}: headed ${drawn2.summary.map((field) => {
|
|
65923
66930
|
const carried = resolved.entity.fields.find((one) => one.alias === field);
|
|
@@ -66010,6 +67017,15 @@ function describeWriters(model) {
|
|
|
66010
67017
|
if (!reached.through.includes(line)) reached.through.push(line);
|
|
66011
67018
|
appsByEntity.set(party.entity.alias, reached);
|
|
66012
67019
|
}
|
|
67020
|
+
for (const [alias, clause] of Object.entries(screen.screen.sections ?? {})) {
|
|
67021
|
+
if (clause.draw !== "publish") continue;
|
|
67022
|
+
const child = model.contract.entities.find((one) => one.alias === alias);
|
|
67023
|
+
if (child === void 0) continue;
|
|
67024
|
+
const reached = named3(child);
|
|
67025
|
+
const line = `queued and withdrawn from the desk in ${screen.app.name} \u2014 it DELETES these rows`;
|
|
67026
|
+
if (!reached.through.includes(line)) reached.through.push(line);
|
|
67027
|
+
appsByEntity.set(child.alias, reached);
|
|
67028
|
+
}
|
|
66013
67029
|
}
|
|
66014
67030
|
const registered = new Set(resolved.map((screen) => screen.entity.alias));
|
|
66015
67031
|
for (const screen of resolved) {
|
|
@@ -66028,7 +67044,7 @@ function describeWriters(model) {
|
|
|
66028
67044
|
lines.push(
|
|
66029
67045
|
` ${entry.label} (${alias}${entry.singular === void 0 ? "" : `, one: ${entry.singular}`}): ${writers.length === 0 ? "(no app)" : writers.join(" \xB7 ")}` + (entry.apps.length > 1 ? ` \u2014 ${entry.apps.length} desks write this record; state the split in each app's package.json#lotics.writes` : "")
|
|
66030
67046
|
);
|
|
66031
|
-
for (const clause of [...describeReadScope(model, alias), ...
|
|
67047
|
+
for (const clause of [...describeReadScope(model, alias), ...describeCreateClauses(model, alias, resolved)]) lines.push(` ${clause}`);
|
|
66032
67048
|
}
|
|
66033
67049
|
return lines;
|
|
66034
67050
|
}
|
|
@@ -66065,13 +67081,21 @@ function describeReadScope(model, alias) {
|
|
|
66065
67081
|
});
|
|
66066
67082
|
return [`read by ${said.join(", or ")} \u2014 every other row is hidden, on every door`];
|
|
66067
67083
|
}
|
|
66068
|
-
function
|
|
66069
|
-
const rules = model.write_rules[alias];
|
|
66070
|
-
if (rules === void 0) return [];
|
|
67084
|
+
function describeCreateClauses(model, alias, resolved) {
|
|
66071
67085
|
const entity = model.contract.entities.find((one) => one.alias === alias);
|
|
66072
67086
|
if (entity === void 0) return [];
|
|
67087
|
+
const required2 = entity.fields.flatMap((field) => field.required === true ? [`${field.label}: required`] : []);
|
|
67088
|
+
const drafts = resolved.filter((screen) => screen.entity.alias === alias && screen.create.length > 0);
|
|
67089
|
+
for (const screen of drafts) {
|
|
67090
|
+
const by = drafts.length > 1 ? `${screen.app.name} ` : "";
|
|
67091
|
+
required2.push(`${by}asks: ${screen.create.map((field) => field.label).join(" \xB7 ")}`);
|
|
67092
|
+
}
|
|
67093
|
+
const face = entity.fields.find((field) => field.type === "files" && roleOf(model.field_roles, alias, field.alias)?.role === "mark");
|
|
67094
|
+
if (face !== void 0 && drafts.length === 0) required2.push(`${face.label}: attached at create \u2014 the row's own face, and the only pile a draft asks for`);
|
|
67095
|
+
const rules = model.write_rules[alias];
|
|
67096
|
+
if (rules === void 0) return required2;
|
|
66073
67097
|
const label = (field) => entity.fields.find((one) => one.alias === field)?.label ?? field;
|
|
66074
|
-
const lines = [];
|
|
67098
|
+
const lines = [...required2];
|
|
66075
67099
|
if (rules.natural_key !== void 0) {
|
|
66076
67100
|
lines.push(`recognised by ${rules.natural_key.map(label).join(" + ")} \u2014 a create naming this party reuses the row it finds`);
|
|
66077
67101
|
}
|
|
@@ -66249,21 +67273,29 @@ async function checkModelFile(file2) {
|
|
|
66249
67273
|
if (model === null) return { kind: "error", findings: errors, notes };
|
|
66250
67274
|
return { kind: "ok", model, notes };
|
|
66251
67275
|
}
|
|
67276
|
+
function oneLineEach(findings) {
|
|
67277
|
+
const met = /* @__PURE__ */ new Map();
|
|
67278
|
+
for (const finding of findings) {
|
|
67279
|
+
const line = ` \u2022 ${finding.path}: ${finding.message}`;
|
|
67280
|
+
met.set(line, (met.get(line) ?? 0) + 1);
|
|
67281
|
+
}
|
|
67282
|
+
return [...met].map(([line, times]) => times === 1 ? line : `${line} (\xD7${times})`);
|
|
67283
|
+
}
|
|
66252
67284
|
function reportModelFindings(file2, findings) {
|
|
67285
|
+
const lines = oneLineEach(findings);
|
|
66253
67286
|
console.error(
|
|
66254
|
-
`${file2} \u2014 ${count(
|
|
66255
|
-
` +
|
|
67287
|
+
`${file2} \u2014 ${count(lines.length, "problem")}:
|
|
67288
|
+
` + lines.join("\n") + `
|
|
66256
67289
|
|
|
66257
67290
|
The model's shape, every field type and a worked example: lotics scaffold docs`
|
|
66258
67291
|
);
|
|
66259
67292
|
}
|
|
66260
67293
|
function reportModelNotes(notes) {
|
|
66261
67294
|
if (notes.length === 0) return;
|
|
66262
|
-
|
|
66263
|
-
|
|
66264
|
-
${count(
|
|
66265
|
-
` +
|
|
66266
|
-
);
|
|
67295
|
+
const lines = oneLineEach(notes);
|
|
67296
|
+
console.error(`
|
|
67297
|
+
${count(lines.length, "note")} \u2014 nothing here refuses the file:
|
|
67298
|
+
` + lines.join("\n"));
|
|
66267
67299
|
}
|
|
66268
67300
|
async function scaffoldExport(client, opts = {}) {
|
|
66269
67301
|
const { contract, findings, pulled_in } = await client.exportWorkspaceModel(opts);
|
|
@@ -66299,26 +67331,33 @@ async function scaffoldDiff(client, model) {
|
|
|
66299
67331
|
`The workspace's own model did not parse (${live.error.issues[0]?.message ?? "unknown"}) \u2014 that is ours to fix, not yours. \`lotics scaffold export\` prints what came back.`
|
|
66300
67332
|
);
|
|
66301
67333
|
}
|
|
66302
|
-
const
|
|
66303
|
-
if (
|
|
67334
|
+
const differences = diffModelAgainstWorkspace(model.contract.entities, live.data, binding);
|
|
67335
|
+
if (differences.length === 0) {
|
|
66304
67336
|
console.log(`No difference \u2014 ${count(model.contract.entities.length, "table")} match this workspace.`);
|
|
66305
67337
|
return;
|
|
66306
67338
|
}
|
|
66307
|
-
for (const line of
|
|
67339
|
+
for (const line of describeDiff(differences)) console.log(line);
|
|
66308
67340
|
console.error(
|
|
66309
67341
|
`
|
|
66310
|
-
${count(
|
|
67342
|
+
${count(differences.length, "difference")}. The workspace is the truth after the first apply: edit the file to match it, or \`lotics scaffold apply\` to add what is missing (it never renames or deletes).`
|
|
66311
67343
|
);
|
|
66312
67344
|
process.exitCode = 1;
|
|
66313
67345
|
}
|
|
67346
|
+
function describeDiff(differences) {
|
|
67347
|
+
const of = (side) => differences.filter((one) => one.side === side).length;
|
|
67348
|
+
return [
|
|
67349
|
+
`${of("create")} to create \xB7 ${of("standing")} already in the workspace and not in the model \xB7 ${of("disagreement")} the two disagree about`,
|
|
67350
|
+
...differences.map((one) => one.line)
|
|
67351
|
+
];
|
|
67352
|
+
}
|
|
66314
67353
|
function optionLabels(field) {
|
|
66315
67354
|
return field.type === "select" ? field.options.map((option) => option.label) : null;
|
|
66316
67355
|
}
|
|
66317
67356
|
function fieldDifferences(where, mine, theirs, unpairable, renamed) {
|
|
66318
67357
|
if (mine.type !== theirs.type) {
|
|
66319
|
-
return [` ${where}: model says ${mine.type}, workspace has ${theirs.type}`];
|
|
67358
|
+
return [{ side: "disagreement", line: ` ${where}: model says ${mine.type}, workspace has ${theirs.type}` }];
|
|
66320
67359
|
}
|
|
66321
|
-
const pairing = unpairable ? [` ${where}: model pairs it both ways, the workspace's link is one-way \u2014 ${PAIRING_ADOPTION_REMEDY}`] : [];
|
|
67360
|
+
const pairing = unpairable ? [{ side: "disagreement", line: ` ${where}: model pairs it both ways, the workspace's link is one-way \u2014 ${PAIRING_ADOPTION_REMEDY}` }] : [];
|
|
66322
67361
|
const ours = optionLabels(mine);
|
|
66323
67362
|
const yours = optionLabels(theirs);
|
|
66324
67363
|
if (ours === null || yours === null) return pairing;
|
|
@@ -66327,8 +67366,11 @@ function fieldDifferences(where, mine, theirs, unpairable, renamed) {
|
|
|
66327
67366
|
const extra = yours.filter((label) => !ours.includes(label) && !moved.has(label));
|
|
66328
67367
|
return [
|
|
66329
67368
|
...pairing,
|
|
66330
|
-
|
|
66331
|
-
|
|
67369
|
+
// AN OPTION IS ADDED LIKE A COLUMN IS. `apply` is additive down to the
|
|
67370
|
+
// option, so an option the workspace lacks is something it would write and
|
|
67371
|
+
// one the model lacks is something it would leave standing.
|
|
67372
|
+
...missing.length === 0 ? [] : [{ side: "create", line: ` ${where}: workspace lacks ${missing.map((l) => `"${l}"`).join(", ")}` }],
|
|
67373
|
+
...extra.length === 0 ? [] : [{ side: "standing", line: ` ${where}: model lacks ${extra.map((l) => `"${l}"`).join(", ")}` }]
|
|
66332
67374
|
];
|
|
66333
67375
|
}
|
|
66334
67376
|
function exportIndex(bound, read2, among, label) {
|
|
@@ -66388,51 +67430,51 @@ function diffModelAgainstWorkspace(declared, live, binding) {
|
|
|
66388
67430
|
const claimed = /* @__PURE__ */ new Set();
|
|
66389
67431
|
for (const { declared: entity, found, fields: boundFields } of resolved) {
|
|
66390
67432
|
if (found.how === "gone") {
|
|
66391
|
-
lines.push(` ${goneMessage("entity", entity.alias, found.id)}`);
|
|
67433
|
+
lines.push({ side: "disagreement", line: ` ${goneMessage("entity", entity.alias, found.id)}` });
|
|
66392
67434
|
continue;
|
|
66393
67435
|
}
|
|
66394
67436
|
if (found.how === "absent") {
|
|
66395
|
-
lines.push(` ${entity.label}: in the model, not in the workspace`);
|
|
67437
|
+
lines.push({ side: "create", line: ` ${entity.label}: in the model, not in the workspace` });
|
|
66396
67438
|
continue;
|
|
66397
67439
|
}
|
|
66398
67440
|
if (found.how === "ambiguous") {
|
|
66399
|
-
lines.push(` ${entity.label}: ${count(found.live.length, "table")} here carry this label \u2014 a label binds one`);
|
|
67441
|
+
lines.push({ side: "disagreement", line: ` ${entity.label}: ${count(found.live.length, "table")} here carry this label \u2014 a label binds one` });
|
|
66400
67442
|
continue;
|
|
66401
67443
|
}
|
|
66402
67444
|
const here = found.live;
|
|
66403
67445
|
claimed.add(here);
|
|
66404
67446
|
const renamed = renames.get(entity.alias);
|
|
66405
|
-
if (renamed !== void 0) lines.push(driftLine(renamed));
|
|
67447
|
+
if (renamed !== void 0) lines.push({ side: "disagreement", line: driftLine(renamed) });
|
|
66406
67448
|
const claimedFields = /* @__PURE__ */ new Set();
|
|
66407
67449
|
for (const field of entity.fields) {
|
|
66408
67450
|
const at2 = fieldKeyName(entity.alias, field.alias);
|
|
66409
67451
|
const counterpart = boundFields.get(field.alias) ?? { how: "absent" };
|
|
66410
67452
|
if (counterpart.how === "gone") {
|
|
66411
|
-
lines.push(` ${goneMessage("field", at2, counterpart.id)}`);
|
|
67453
|
+
lines.push({ side: "disagreement", line: ` ${goneMessage("field", at2, counterpart.id)}` });
|
|
66412
67454
|
continue;
|
|
66413
67455
|
}
|
|
66414
67456
|
if (counterpart.how === "absent") {
|
|
66415
|
-
lines.push(` ${entity.label}.${field.label}: in the model, not in the workspace`);
|
|
67457
|
+
lines.push({ side: "create", line: ` ${entity.label}.${field.label}: in the model, not in the workspace` });
|
|
66416
67458
|
continue;
|
|
66417
67459
|
}
|
|
66418
67460
|
if (counterpart.how === "ambiguous") {
|
|
66419
|
-
lines.push(` ${entity.label}.${field.label}: ${count(counterpart.live.length, "field")} here carry this label \u2014 a label binds one`);
|
|
67461
|
+
lines.push({ side: "disagreement", line: ` ${entity.label}.${field.label}: ${count(counterpart.live.length, "field")} here carry this label \u2014 a label binds one` });
|
|
66420
67462
|
continue;
|
|
66421
67463
|
}
|
|
66422
67464
|
const theirs = counterpart.live;
|
|
66423
67465
|
claimedFields.add(theirs);
|
|
66424
67466
|
const movedField = renames.get(at2);
|
|
66425
|
-
if (movedField !== void 0) lines.push(driftLine(movedField));
|
|
67467
|
+
if (movedField !== void 0) lines.push({ side: "disagreement", line: driftLine(movedField) });
|
|
66426
67468
|
const movedOptions = /* @__PURE__ */ new Map();
|
|
66427
67469
|
for (const option of field.type === "select" ? field.options : []) {
|
|
66428
67470
|
const moved = renames.get(optionKeyName(entity.alias, field.alias, option.alias));
|
|
66429
67471
|
if (moved === void 0) continue;
|
|
66430
67472
|
if (moved.workspace === null) {
|
|
66431
|
-
lines.push(` ${goneMessage("option", moved.at, moved.id)}`);
|
|
67473
|
+
lines.push({ side: "disagreement", line: ` ${goneMessage("option", moved.at, moved.id)}` });
|
|
66432
67474
|
continue;
|
|
66433
67475
|
}
|
|
66434
67476
|
movedOptions.set(option.label, moved.workspace);
|
|
66435
|
-
lines.push(driftLine(moved));
|
|
67477
|
+
lines.push({ side: "disagreement", line: driftLine(moved) });
|
|
66436
67478
|
}
|
|
66437
67479
|
lines.push(
|
|
66438
67480
|
...fieldDifferences(`${entity.label}.${field.label}`, field, theirs, unpairable.has(at2), movedOptions)
|
|
@@ -66440,12 +67482,12 @@ function diffModelAgainstWorkspace(declared, live, binding) {
|
|
|
66440
67482
|
}
|
|
66441
67483
|
for (const field of here.fields) {
|
|
66442
67484
|
if (claimedFields.has(field)) continue;
|
|
66443
|
-
lines.push(` ${entity.label}.${field.label}: in the workspace, not in the model`);
|
|
67485
|
+
lines.push({ side: "standing", line: ` ${entity.label}.${field.label}: in the workspace, not in the model` });
|
|
66444
67486
|
}
|
|
66445
67487
|
}
|
|
66446
67488
|
for (const entity of live) {
|
|
66447
67489
|
if (claimed.has(entity)) continue;
|
|
66448
|
-
lines.push(` ${entity.label}: in the workspace, not in the model`);
|
|
67490
|
+
lines.push({ side: "standing", line: ` ${entity.label}: in the workspace, not in the model` });
|
|
66449
67491
|
}
|
|
66450
67492
|
return lines;
|
|
66451
67493
|
}
|
|
@@ -66966,12 +68008,6 @@ function remapFilter(group, entity, binding, at2) {
|
|
|
66966
68008
|
return remapContractFilterGroup(group, entity, resolver);
|
|
66967
68009
|
}
|
|
66968
68010
|
|
|
66969
|
-
// ../shared/src/schemas/database_types.ts
|
|
66970
|
-
function extractRecordLinkIds(value) {
|
|
66971
|
-
if (!value || !Array.isArray(value)) return [];
|
|
66972
|
-
return value.map((item) => typeof item === "string" ? item : item?.id).filter((id) => Boolean(id));
|
|
66973
|
-
}
|
|
66974
|
-
|
|
66975
68011
|
// ../../node_modules/jexpr/lib/constants.js
|
|
66976
68012
|
var KEYWORDS = ["this"];
|
|
66977
68013
|
var UNARY_OPERATORS = ["+", "-", "!"];
|
|
@@ -70452,27 +71488,37 @@ function applyGeneration(projectDir, plan) {
|
|
|
70452
71488
|
fs16.writeFileSync(full, plan.droppedPatch);
|
|
70453
71489
|
}
|
|
70454
71490
|
}
|
|
70455
|
-
var NOTHING_DROPPED = { gone: [], unwritten: [] };
|
|
71491
|
+
var NOTHING_DROPPED = { gone: [], unwritten: [], undeleted: [] };
|
|
70456
71492
|
function foldWrites(existing, generated, live, bodies) {
|
|
70457
|
-
|
|
70458
|
-
|
|
70459
|
-
}
|
|
71493
|
+
const nothingDeclared = generated.writes === void 0 && existing.writes === void 0 && generated.deletes === void 0 && existing.deletes === void 0;
|
|
71494
|
+
if (nothingDeclared) return { writes: void 0, deletes: void 0, dropped: NOTHING_DROPPED };
|
|
70460
71495
|
const out = {};
|
|
70461
|
-
for (const [table, entries2] of Object.entries(existing ?? {})) out[table] = [...entries2];
|
|
70462
|
-
for (const [table, entries2] of Object.entries(generated ?? {})) {
|
|
71496
|
+
for (const [table, entries2] of Object.entries(existing.writes ?? {})) out[table] = [...entries2];
|
|
71497
|
+
for (const [table, entries2] of Object.entries(generated.writes ?? {})) {
|
|
70463
71498
|
const already2 = new Set((out[table] ?? []).map(writtenField));
|
|
70464
71499
|
out[table] = [...out[table] ?? [], ...entries2.filter((entry) => !already2.has(writtenField(entry)))];
|
|
70465
71500
|
}
|
|
71501
|
+
const removals = [.../* @__PURE__ */ new Set([...existing.deletes ?? [], ...generated.deletes ?? []])];
|
|
71502
|
+
const declared = existing.deletes !== void 0 || generated.deletes !== void 0;
|
|
70466
71503
|
const carried = carriedWrites(out, live);
|
|
70467
71504
|
const scanned = bodies.map((body) => body.source === null ? null : bodyWrites(body.source));
|
|
70468
71505
|
if (scanned.some((body) => body === null || !body.parsed || body.unknownTools.length > 0)) {
|
|
70469
|
-
return {
|
|
71506
|
+
return {
|
|
71507
|
+
writes: carried.writes,
|
|
71508
|
+
deletes: declared ? removals : void 0,
|
|
71509
|
+
dropped: { gone: carried.dropped, unwritten: [], undeleted: [] }
|
|
71510
|
+
};
|
|
70470
71511
|
}
|
|
70471
|
-
const written = stillWritten(carried.writes, live, {
|
|
71512
|
+
const written = stillWritten({ writes: carried.writes, deletes: removals }, live, {
|
|
70472
71513
|
bodies: scanned.flatMap((body) => body?.writes ?? []),
|
|
70473
|
-
|
|
71514
|
+
removed: scanned.flatMap((body) => body?.deletes ?? []),
|
|
71515
|
+
generated: { writes: generated.writes ?? {}, deletes: generated.deletes ?? [] }
|
|
70474
71516
|
});
|
|
70475
|
-
return {
|
|
71517
|
+
return {
|
|
71518
|
+
writes: written.writes,
|
|
71519
|
+
deletes: declared ? written.deletes : void 0,
|
|
71520
|
+
dropped: { gone: carried.dropped, unwritten: written.dropped, undeleted: written.undeleted }
|
|
71521
|
+
};
|
|
70476
71522
|
}
|
|
70477
71523
|
function rewrittenWorkflowBodies(plan) {
|
|
70478
71524
|
return new Set(
|
|
@@ -70522,8 +71568,9 @@ function renderSummary(summary, dryRun) {
|
|
|
70522
71568
|
}),
|
|
70523
71569
|
// A declaration losing an entry is not something to do quietly, and the two
|
|
70524
71570
|
// rules that can take one away are not the same fact.
|
|
70525
|
-
...summary.
|
|
70526
|
-
...summary.
|
|
71571
|
+
...summary.droppedDeclarations.gone.length === 0 ? [] : [` Dropped from writes (the table no longer carries them): ${list2(summary.droppedDeclarations.gone)}`],
|
|
71572
|
+
...summary.droppedDeclarations.unwritten.length === 0 ? [] : [` Dropped from writes (nothing here writes them any more): ${list2(summary.droppedDeclarations.unwritten)}`],
|
|
71573
|
+
...summary.droppedDeclarations.undeleted.length === 0 ? [] : [` Dropped from deletes (nothing here takes their rows any more): ${list2(summary.droppedDeclarations.undeleted)}`],
|
|
70527
71574
|
// WHAT A DEPLOY WILL CHANGE, never what this command changed: a binding is
|
|
70528
71575
|
// what the live app runs, and it moves at `lotics app deploy`.
|
|
70529
71576
|
...nothingToDeploy ? [" Bindings: the live app already runs what this tree declares."] : [
|
|
@@ -70592,8 +71639,8 @@ async function foldRegeneration(client, args = {}) {
|
|
|
70592
71639
|
}
|
|
70593
71640
|
);
|
|
70594
71641
|
const folded = foldWrites(
|
|
70595
|
-
lotics.writes,
|
|
70596
|
-
plan.writes,
|
|
71642
|
+
{ writes: lotics.writes, deletes: lotics.deletes },
|
|
71643
|
+
{ writes: plan.writes, deletes: plan.deletes },
|
|
70597
71644
|
plan.live,
|
|
70598
71645
|
plannedWorkflowBodies(projectDir, generation, Object.keys(workflows.entries))
|
|
70599
71646
|
);
|
|
@@ -70613,7 +71660,7 @@ async function foldRegeneration(client, args = {}) {
|
|
|
70613
71660
|
outcomes: generation.outcomes,
|
|
70614
71661
|
queries,
|
|
70615
71662
|
workflows,
|
|
70616
|
-
|
|
71663
|
+
droppedDeclarations: folded.dropped,
|
|
70617
71664
|
deploy: deployPreview(pending, live, { queries: queries.removed, workflows: workflows.removed }),
|
|
70618
71665
|
pushed: [],
|
|
70619
71666
|
refused: [],
|
|
@@ -70630,7 +71677,8 @@ async function foldRegeneration(client, args = {}) {
|
|
|
70630
71677
|
plan: rememberedPlanReference(projectDir, reference),
|
|
70631
71678
|
queries: queries.entries,
|
|
70632
71679
|
workflows: workflows.entries,
|
|
70633
|
-
...folded.writes === void 0 ? {} : { writes: folded.writes }
|
|
71680
|
+
...folded.writes === void 0 ? {} : { writes: folded.writes },
|
|
71681
|
+
...folded.deletes === void 0 ? {} : { deletes: folded.deletes }
|
|
70634
71682
|
};
|
|
70635
71683
|
fs16.writeFileSync(path19.join(projectDir, "package.json"), `${JSON.stringify(whole, null, 2)}
|
|
70636
71684
|
`);
|
|
@@ -70767,7 +71815,7 @@ async function workspaceBuild(client, args, actions = REAL_ACTIONS) {
|
|
|
70767
71815
|
);
|
|
70768
71816
|
}
|
|
70769
71817
|
const differences = diffModelAgainstWorkspace(model.contract.entities, live.data, binding);
|
|
70770
|
-
for (const line of differences) note(line);
|
|
71818
|
+
for (const line of describeDiff(differences)) note(line);
|
|
70771
71819
|
let scaffold = "unchanged";
|
|
70772
71820
|
if (differences.length > 0) {
|
|
70773
71821
|
if (dryRun) scaffold = "not run";
|