Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions docs/decisions/0032-a-rule-computes-what-it-concludes.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,9 @@ If it is ever wanted it needs its own record, answering what a completeness clai

**Units and datatypes have to be checked when the expression is written, and today nothing checks them.** `relation_types` carries `unit` and `datatype` and no code compares them. `revenue (USD) − cost (EUR)` must be refused by the picker, not silently subtracted; the result's type has to match the concluded predicate's. This is new work that the constant case never needed.

**Revision proposed 2026-09-21:** [0049](0049-expression-declarations-are-checked-when-a-rule-is-written.md) answers the missing declaration semantics and write-time locking question below. Missing units are not assumed unitless; exact `1`, the allowlist and first-cut operations remain proposals. The accepted expression semantics and metadata-only fallback are unchanged.


**A missing reading is not a zero, and neither is a division by zero.** If any attribute in the expression has no reading on the interval, the expression has no value and nothing is concluded — consistent with 0029. Division by zero is the same: no conclusion, **reported** the way `capped` is, because "not computed here" and "the criterion was not met" look identical in the result otherwise.

## Open
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
# 0049 · Expression declarations are checked when a rule is written

- **Status**: proposed; domain contract pending review. The opt-in web draft does not validate units or save rules.
- **Written**: 2026-09-21
- **Related**: [0032](0032-a-rule-computes-what-it-concludes.md); [PR #839](https://github.com/deeplethe/utopia/pull/839).

## Problem

0032 asks for datatype and unit checks but does not say whether absent declarations are dimensionless. A rule can otherwise subtract revenue declared in USD from cost declared in EUR and present a meaningless result. The existing expression API and metadata-only editor remain compatible; this proposal must not silently tighten their write contract.

## Decision requested

Approve a conservative write-time declaration subset: numeric `number` attributes,
exact known units, no conversion, and missing/ambiguous units as **unknown** rather
than dimensionless. Decide whether the exact string `1` is the explicit unitless
representation. Prefer enabling same-unit addition/subtraction and numeric factor
scaling first; ratios need the explicit-unitless decision for their target.

The historical policy model used USD/EUR/m/kg/s as experimental known declarations, not
an exhaustive units language. It treats $, ¥, %, basis points, Celsius and arbitrary
compound strings as unknown. The accepted allowlist must be agreed alongside `1`;
it must not infer aliases from labels, backfill empty units, or claim that matching
declarations normalize historic observations.

| Operation | Proposed accepted inputs | Result |
|---|---|---|
| add/subtract | equal known units, or both explicitly unitless | same unit |
| multiply | at least one unitless | other operand's unit |
| divide | unitless denominator, or identical known units | numerator, or unitless |
| constant | finite decimal number | unitless factor |

A bare `revenue(USD)-1` is rejected. A scalar legacy threshold remains governed by
its existing API contract; this policy applies to editing expressions, not a rewrite
of all stored conditions. Changing declarations later can invalidate assumptions:
this is a write-time check, not a new ontology lifecycle/revision system.

## Transaction boundary proposed for the API

Resolve references in the current KB, sort their UUIDs, read/lock the relevant
attribute declarations with `FOR SHARE`, validate, then write the rule in the same
short transaction. `FOR KEY SHARE` is insufficient for concurrent datatype/unit
updates. Metadata-only PATCH and existing enabled toggles do not rewrite/revalidate
legacy definitions. Deletion/foreign-base/permission checks remain server-owned.

The PostgreSQL experiment observes a real blocked declaration UPDATE via
`pg_blocking_pids`; the writer continues to read USD until commit, after which a
subsequent validator sees EUR. This establishes the proposed lock primitive, **not**
that production rule routes already perform it. Keep this separate from A0.

## Investigation and its limits

Historical evidence at `30a0da8ca06ce19325432cfc6be0a3cbfecb642d` on Linux, Node 22.23.2 and PostgreSQL 16.15: ten Node model tests and one isolated PostgreSQL lock experiment passed. The lock probe observed `pg_blocking_pids` for a concurrent **non-key** datatype/unit update: `FOR SHARE` blocked it until commit; `FOR KEY SHARE` did not. This establishes a lock primitive, not production route validation.

The historical standalone browser checked local model save/reopen, invalid constants and failed-save retention. It did not use Utopia APIs and supplies no evidence about a real picker at scale. Its scripts and page have been archived outside the repository, not translated into another executable policy. Model results are not tests of a production unit validator.

The unlisted `/kb/$kbId/expression-draft` route opts into the exploration in `web/`. It uses structured drafts and the existing UI controls with authenticated attributes and rules from the current knowledge base. Draft previews do not persist anything. Attribute declarations are displayed, not interpreted as an approved unit language. The depth question remains a usability decision: automated interaction checks can establish structure, search and focus behavior, but cannot supply a person's tolerance for nested editing.

## Alternatives and remaining decisions

Treating an empty unit as unitless would silently accept undeclared quantities. Inferring aliases or converting observations would introduce a separate normalization contract. A formula string would create a second representation beside the AST. Prefer the explicit three-state declaration model: known, explicitly unitless, unknown. The exact `1` spelling, known-unit allowlist, and whether to enable ratios in the first cut still need approval. USD/EUR/m/kg/s are investigation samples, not a shipped allowlist.

## Implementation after approval

Add server declaration validation in the short write transaction, then integrate
structured drafts into RulesPanel using the existing expression display and protected
metadata editor. Keep grouping, explicit scalar/expression modes, failed-save drafts,
KB switching, UUID selection and exact tree order. Reuse known/unknown shape checks;
never strip unknown keys to make a definition editable. Preview and save must use
the same validated AST. Add actual browser/API create-read-edit-read, concurrent
declaration updates and inference/premise/interval regressions before enabling it.

Rollback of UI retains B1. It does not delete existing rules. No new AST shapes,
relation paths, aggregate operators, formula runtime or unit conversion are proposed.

## Picker observations in this revision

Authenticated API-created bases with 30, 300 and 1000 attributes were read in full
(the ontology endpoint uses `fetch_all`, without pagination or a list cap). The
fixtures include duplicate labels, Chinese and English long labels, mixed declared
datatypes, and absent units. Browser checks built revenue minus cost and margin,
reopened real right-nested subtraction/division rules, edited an inner operand,
and retained invalid numeric text without manufacturing a value. Search reaches
the thousandth attribute by key, with keyboard selection and focus returning to
the picker. The four-edge bound keeps leaves editable; it does not flatten the tree.

At a 390 px viewport the editor remains within its container, including a depth-four
leaf. Nesting nevertheless makes a long vertical form: reaching an inner operand
requires scrolling. These are mechanical observations from automated Chrome, not a
human usability score or a decision that four levels are pleasant. Comparing with a
formula language remains outside this change. HTTP-read failure/retry and unknown
expression shape were checked using explicitly injected browser responses; they
are not claims that the backend accepted a future definition. No draft save route
is enabled, and the existing B1 metadata editor and dependency view are unchanged.
2 changes: 2 additions & 0 deletions docs/decisions/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ The test for writing one: if someone (including us) looks at a piece of code in
| 0046 | [The app surface is MCP](0046-the-app-surface-is-mcp.md) | Decided, with the refused design kept. Asked for an app center: applications built on this knowledge, mounted, run in a sandbox, handed to a team. The answer is that the surface already exists — a personal token carries identity and scope, ten read tools serve chat and MCP from one place, `as_of` reaches every graph read, and a read returns `structuredContent` with stable ledger identities — so a coding agent builds on this base today in its own platform, its own language and its own sandbox. Refused here because the layer an app would read is being replaced under it (typed facts now come only from alignment), because 0016 closes open seams before cutting new ones, and because a catalog, an execution boundary and quotas are three other products. The shape is kept with the four gates it would have to hold (runs as the caller, egress only through a declared action, a declared clock, the existing queue) and the dead ends: a container runtime (withdrawn the day it was written — WeKnora's skills are human-written and assume a shell, and they pay for it), a Wasm component runtime (better on every axis including the determinism re-parse needs, still not built because the reason is priority), a service identity per app, an app as a saved conversation. Reopened by a named customer who needs a button inside the product, by the type layer settling, or after 0034 |
| 0047 | [A rule may conclude a relation](0047-a-rule-may-conclude-a-relation.md) | Proposed 2026-09-20 · nothing built · A rule reads one entity and concludes about that same entity, so a threshold over a chain — a holding above 50% in a company that itself holds above 50% in another — cannot be written at all, and the query-time path walk that answers it produces no interval, no premises and nothing a queue can see. The conclusion becomes a **relation** between the subject and one entity reached across one declared relation, valid on the intersection of every premise interval including the join edge's. The concluded edge rejoins the pool `derive()` reads and the axiom pass runs once per round, coupling the two reasoners for the first time: 0021's cycle objection is answered with the **finiteness** argument [0030](0030-a-rule-may-read-what-a-rule-concluded.md) already put in place of acyclicity, rather than with a fixed ordering that would let a legitimate rule silently never fire. Reading a value across a hop is [0032](0032-a-rule-computes-what-it-concludes.md)'s decision, reused rather than re-decided. Negation, aggregation, a second hop and user-defined recursion stay out; the three caps in play are set by measurement in the PR that changes them |
| 0048 | [Provenance references stay inside the knowledge base](0048-provenance-references-stay-inside-the-knowledge-base.md) | Proposed 2026-09-20 · implemented in PR #832 (migration 0070), pending review · a column foreign key proves the target exists, not that it is the same KB's — every reference an export can resolve gets a schema-level same-KB invariant: composite `(kb_id, ref)` foreign keys on the 26 edges whose row carries its own `kb_id` (same-table self-references deferred to commit), row triggers on the 13 whose kb authority is a parent row, `kb_id` immutability on every owned table, and a precondition scan that fails the migration closed on an already-cross-KB ledger · measured populate cost within noise; mechanism question open as issue #842 |
| 0049 | [Expression declarations are checked when a rule is written](0049-expression-declarations-are-checked-when-a-rule-is-written.md) | Proposed · declaration policy pending; opt-in web draft only |
| 0050 | [An action attempt keeps its identity and uncertain outcome](0050-an-action-attempt-keeps-its-identity-and-uncertain-outcome.md) | Proposed · durable execution identity and uncertain outcomes; no sender |

| | Record | Domain | Status |
Expand Down Expand Up @@ -126,6 +127,7 @@ The test for writing one: if someone (including us) looks at a piece of code in
| 0046 | [The app surface is MCP](0046-the-app-surface-is-mcp.md) | chat-and-mcp | current |
| 0047 | [A rule may conclude a relation](0047-a-rule-may-conclude-a-relation.md) | rules | current |
| 0048 | [Provenance references stay inside the knowledge base](0048-provenance-references-stay-inside-the-knowledge-base.md) | ledger | current |
| 0049 | [Expression declarations are checked when a rule is written](0049-expression-declarations-are-checked-when-a-rule-is-written.md) | rules | proposed |
| 0050 | [An action attempt keeps its identity and uncertain outcome](0050-an-action-attempt-keeps-its-identity-and-uncertain-outcome.md) | lakehouse-and-actions | proposed |

The status word is whether a later record has overtaken this one; what is built is in the record's own status line. Domains are the files of [../design/](../design/README.md), where every record is dated and the status words are defined.
Expand Down
30 changes: 30 additions & 0 deletions web/src/i18n/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,36 @@
//
// 加新文案时先加在这里,再补其余语言包——顺序反了会得到一个类型错误,那正是本意。
export const en = {
expressionDraft: {
title: "Expression draft exploration",
unsaved: "Unsaved draft only. Nothing here is saved to the knowledge base. Unit compatibility is not checked.",
undeclared: "Undeclared",
attribute: "Attribute",
constant: "Number",
add: "Add (+)",
sub: "Subtract (−)",
mul: "Multiply (×)",
div: "Divide (÷)",
expression: "Expression",
left: "Left operand",
right: "Right operand",
kind: "Node type",
depthLimit: "Depth limit reached: choose an attribute or a number.",
choose: "Search and choose…",
missing: "Attribute no longer available",
loading: "Loading attributes and rules…",
loadError: "Could not load this knowledge base. Check your access and retry.",
retry: "Retry",
empty: "This knowledge base has no attributes yet.",
conclusion: "Conclusion",
condition: "Condition",
existing: "Explore an existing expression",
unsupported: "This expression has an unsupported shape. It has not been converted. Use the existing rule editor for metadata changes.",
preview: "Draft preview — not saved",
incomplete: "Complete every operand with an available attribute or a finite number to preview.",
reset: "Start a new draft",
count: (n: number) => `${n} attributes loaded from this knowledge base`,
},
app: {
name: "Utopia",
// 化用《乌托邦》全书最后一句(Burnet 1684 译本):
Expand Down
30 changes: 30 additions & 0 deletions web/src/i18n/zh.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,36 @@
import type { Strings } from "./en";

export const zh: Strings = {
expressionDraft: {
title: "表达式草稿探索",
unsaved: "仅为未保存的草稿,不会写入知识库,也不检查单位兼容性。",
undeclared: "未声明",
attribute: "属性",
constant: "数字",
add: "加 (+)",
sub: "减 (−)",
mul: "乘 (×)",
div: "除 (÷)",
expression: "表达式",
left: "左操作数",
right: "右操作数",
kind: "节点类型",
depthLimit: "已达嵌套深度上限,请选择属性或数字。",
choose: "搜索并选择…",
missing: "属性已不可用",
loading: "正在读取属性和规则…",
loadError: "无法读取此知识库,请检查访问权限后重试。",
retry: "重试",
empty: "此知识库尚无属性。",
conclusion: "结论",
condition: "条件",
existing: "探索已有表达式",
unsupported: "此表达式结构尚不支持,未对其进行转换。名称和说明仍可在原规则编辑器中修改。",
preview: "草稿预览(未保存)",
incomplete: "请为每个操作数选择可用属性或填写有限数字以预览。",
reset: "新建草稿",
count: (n: number) => `已读取此知识库的 ${n} 个属性`,
},
app: {
name: "Utopia",
/* 标语与出处都与 Utopia / Persona / Charter 同类:品牌的一部分,两种语言同值 */
Expand Down
52 changes: 52 additions & 0 deletions web/src/pages/ExpressionDraftEditor.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { useMemo } from "react";
import type { RelationTypeView } from "../api";
import { S } from "../i18n";
import { Dropdown, Field, Input, SearchSelect, type SearchSelectOption } from "../ui";
import type { ExpressionDraft } from "./expressionDraft";

/** Controlled tree editor: replacing a node never rewrites its siblings or grouping. */
export function ExpressionDraftEditor({ value, onChange, attributes }: {
value: ExpressionDraft;
onChange: (value: ExpressionDraft) => void;
attributes: RelationTypeView[];
}) {
const options = useMemo(() => attributes.map((a) => ({
value: a.id, label: a.label,
hint: `${a.key} · ${a.datatype ?? S.expressionDraft.undeclared} · ${a.unit ?? S.expressionDraft.undeclared} · ${a.id}`,
})), [attributes]);
return <DraftNode value={value} onChange={onChange} options={options} depth={0} path="root" />;
}

function DraftNode({ value, onChange, options, depth, path }: {
value: ExpressionDraft; onChange: (value: ExpressionDraft) => void;
options: SearchSelectOption[]; depth: number; path: string;
}) {
const selected = "attr" in value ? options.find((a) => a.value === value.attr) : undefined;
const kind = "attr" in value ? "attr" : "const" in value ? "const" : value.op;
const kinds = [
{ value: "attr", label: S.expressionDraft.attribute },
{ value: "const", label: S.expressionDraft.constant },
...(depth < 4 ? ["add", "sub", "mul", "div"].map((op) => ({ value: op, label: S.expressionDraft[op as "add" | "sub" | "mul" | "div"] })) : []),
];
const changeKind = (next: string) => {
if (next === kind) return;
if (next === "attr") onChange({ attr: "" });
else if (next === "const") onChange({ const: "" });
else onChange({ op: next as "add" | "sub" | "mul" | "div", l: "op" in value ? value.l : value, r: "op" in value ? value.r : { attr: "" } });
};
return <fieldset className="min-w-0 space-y-2 border-l border-line pl-2" data-node={path}>
<legend className="text-small text-ink-2">{depth === 0 ? S.expressionDraft.expression : path.endsWith("l") ? S.expressionDraft.left : S.expressionDraft.right}</legend>
<Dropdown value={kind} options={kinds} onChange={changeKind} menuLabel={S.expressionDraft.kind} className="w-full" />
{depth === 4 && <p className="text-fine text-ink-2">{S.expressionDraft.depthLimit}</p>}
{"attr" in value ? <Field label={S.expressionDraft.attribute}>
<SearchSelect value={value.attr} options={options} onChange={(attr) => onChange({ attr })} placeholder={S.expressionDraft.choose} className="w-full min-w-0" />
{selected && <p className="break-words text-small text-ink-2">{selected.label} · {selected.hint}</p>}
{value.attr && !selected && <p role="alert" className="break-all text-small text-warn">{S.expressionDraft.missing}: {value.attr}</p>}
</Field> : "const" in value ? <Field label={S.expressionDraft.constant}>
<Input aria-label={S.expressionDraft.constant} value={value.const} onChange={(e) => onChange({ const: e.target.value })} />
</Field> : <div className="space-y-3">
<DraftNode value={value.l} onChange={(l) => onChange({ ...value, l })} options={options} depth={depth + 1} path={`${path}.l`} />
<DraftNode value={value.r} onChange={(r) => onChange({ ...value, r })} options={options} depth={depth + 1} path={`${path}.r`} />
</div>}
</fieldset>;
}
Loading
Loading