Single source of truth for every AI coding agent working on HotCRM. Tool-specific files (CLAUDE.md, .github/copilot-instructions.md) only point here and restate nothing.
Maintainer rulings, verbatim and kept untranslated (2026-09-09, then 2026-08-31):
「首先要明确 hotcrm 是元数据应用,平台功能应该在 objectstack中开发。」
「基于以上决裁,对于hotcrm 元数据项目,需要补充哪些 agents.md ,比如纯元数据应用应该简化,只开发业务功能,平台能力全部在平台开发。」
HotCRM is a metadata application. Platform capability is developed in objectstack. This chapter governs every other chapter of this file; the ruling corpus behind the four rules is objectstack#13848.
Business capability authored as metadata under the platform spec, guided by the platform's published skills, checked for legality by
the os commands (pnpm validate, pnpm lint). Build business features here; build platform capability on the platform. A gap
you find in the platform — infrastructure, a service, dev tooling, a builder — is filed upstream: ⛔ never compensated for or re-invented here.
⛔ Do not route around it — no defensive coding, no shape tolerance, no hand-written predicate re-implementing a platform rule, no
landing "the half we can land now". File the blocked card with a Blocked-by: line to the platform card; when the fix lands, resume the
original ruling after confirming the pinned @objectstack/* version carries it (merged is not available in the pin) and
re-running the defect card's own fixture. Red means stop. (#549)
Lint, validation, gates and diagnostics belong to the platform, uniformly; a drift-class or validation-class gap goes upstream. Tests here pin this repo's own business facts only — a seed-row pin, a "doc wording equals the language-pack label" guard. ⛔ No gate farm. (#806)
Writing an emptyState on every tile, or hand-tuning tile placement to dodge a platform render defect, pays the same tax on every tile. Change the default upstream. (#1212 → objectui#7063)
始终使用中文与用户沟通。 所有面向用户的回复、解释、总结、提问都用中文;代码、标识符、提交信息、PR 标题/正文、代码注释保持英文不变。 Always communicate with the user in Chinese (中文); code, identifiers, commit messages, PR titles/bodies and code comments stay in English.
HotCRM is one ObjectStack artifact assembled from four packages (ADR-0130): one package.json, one tsconfig.json, one build, one
defineStack — on the @objectstack/runtime dependency, ⛔ never built, patched or worked around here.
A directory under src/ IS a package. There is no packages/ level and no shared/; the top-level directories under src/ are
exactly the packages, plus docs/ (see the note below the tree).
hotcrm/
├── objectstack.config.ts # The single defineStack() — app manifest, capabilities, composition knob
├── objectstack.composition.ts # The four packages collected into the arrays defineStack() takes
├── src/
│ ├── sales/ # The `type: app` package — account, contact, lead, opportunity, forecast, task, event, event_attendee
│ ├── service/ # Module — case, knowledge_article, article_feedback
│ ├── revenue/ # Module — product, opportunity_line_item, quote, quote_line_item, contract
│ ├── marketing/ # Module — campaign, campaign_member
│ └── docs/ # In-product package docs (ADR-0046) — see the note below
├── content/docs/ # Product documentation site (Fumadocs): en, zh-Hans, zh-Hant
└── docs/ # Internal maintainer documentation (docs/README.md is the full map)
Inside each package, the metadata-type subdirectories it uses, each with its own explicit file-by-file barrel:
src/sales/
├── objects/ # *.object.ts schemas AND the *.hook.ts beside each one, plus hooks.ts (the package's hook barrel)
├── views/ pages/ # List views (*.view.ts) and page layouts (*.page.ts)
├── flows/ # Automation (*.flow.ts)
├── actions/ # UI actions + AI-callable tools (*.actions.ts)
├── dashboards/ reports/ datasets/ # Analytics metadata
├── skills/ # AI skills (*.skill.ts) — the app's only AI surface
├── profiles/ sharing/ # Permission sets (*.profile.ts) and sharing rules (*.sharing.ts)
├── translations/ # Locale packs: en · zh-CN · es-ES · ja-JP
├── apps/ # The app and its navigation
├── mappings/ interfaces/
└── data/ # Seed data (defineDataset)
src/ itself is the roster of what this app authors — open it rather than trust a list. The four packages, and which objects each owns,
are recorded in docs/architecture/module-split-plan.md; ⛔ do not restate that table in prose.
Three rules the layout exists to hold.
- A
*.hook.tssits beside the*.object.tsit names. A hook may not attach to another package's object (ADR-0130 R4), and co-location is what enforces that — you cannot write the file in the wrong place without it being obvious. Registration is<pkg>/objects/hooks.ts. - Imports follow the dependency edges, and nothing else. A file may import from its own directory or from
src/sales/— ⛔ never sideways between modules, ⛔ never upward. Sales is the app package every module depends on, so a source more than one package needs has one deterministic home: the lowest package all its consumers depend on, which issrc/sales/. ⛔ Never copy such a file into a second package; one copy, imported along the edge. - An item lives with the object it is authored against — view, page, action, dataset, report, sharing rule, seed rows, import
mapping, skill, hook, and a flow by its trigger object. Items with no single object (the app,
home/app_launcher/utility_bar, the cross-domain dashboards and skills, every permission set, the positions, the locale packs) are the app package's.
⛔ Do NOT create packages/<x>/src/ paths: that was the per-package-package.json monorepo, retired and archived under
docs/archive/. This is not that — one package.json, one build — and the prohibition is about the retired shape, not about packages.
src/docs/ is the platform's path, not a package's. objectstack build compiles <config dir>/src/docs/*.md into the artifact's
docs[] (ADR-0046) and reads that path and no other, so these four files stay at the top of src/ rather than moving under
src/sales/. Moving them is silent — the build stays exit 0 and the artifact simply loses its docs.
- Metadata-first, TypeScript only. Every business object is a
*.object.tsbuilt withObjectSchema.create()from@objectstack/spec/data. ⛔ Never YAML or JSON metadata; ⛔ never raw SQL. - The
crm_prefix is written out, everywhere. Business object names aresnake_casewith an explicitcrm_prefix (crm_account); the runtime injects nothing, so the name in source = at runtime = in the DB = in the REST URL = in the docs, and every reference — lookup / master-detail targets, cubesql, view, hook, action, navigation, dashboard and translation keys — uses it. Platform objects keepsys_*; the file stays unprefixed (src/revenue/objects/contract.object.tsdeclaresname: 'crm_contract'); the roster issrc/*/objects/*.object.ts, ⛔ never restated in prose. Theos lintnaming/namespace-prefixwarning on non-object items is advisory (ADR-0048): ⛔ do not mass-rename to chase it. - Data access is ObjectQL through
ctx.api— there is nobroker. Shape:ctx.api.object('crm_opportunity').find({ where: { amount: { $gt: 50000 } } }); a*.hook.tscasts once (const api = ctx.api as HookApi | undefined, fromsrc/sales/objects/_hook-api.ts— every package's hooks import it along their edge into sales), an action body calls it directly.- The predicate key is
where.filteris a live alias the engine folds towhere, so the hazard is not silent loss but mixing: a query carrying both keys throwsConflicting options … 'where', 'filter', and an emptywhere: {}is a different value.HookQueryomitsfilterso the mix is a compile error;test/hook-query-predicate.test.tspins the engine per method. - The method contract is the
_hook-api.tstypes, ⛔ not memory of another stack:counttakeswhereonly, a read is capped withtop, andupdateis(doc carrying its id, { where })— each wrong spelling is a compile error there. - A hook handler and a
scriptaction body run body-only in a QuickJS sandbox, with no module scope. Every constant, table and helper a body reads is declared inside it (type-only imports are erased and fine) — a module-scope reference type-checks and then fails the lowering, whichtest/action-sandbox.test.tsruns over every registered hook. An action body reaches data only under thecapabilitiesit declares (api.read,api.write).ctx.useris absent on system and seed writes — that absence is this repo's system-write signal. - Other surfaces spell their own key, and their own schema decides. A
*.flow.tsnodeconfigtakesfilter:; a page component'sfilteris itsComponentPropsMapentry (@objectstack/spec/ui) —record:related_listtakes rule objects[{ field, operator, value }]; the AST array andop:are rejected byobjectstack buildand the list renders unfiltered (#1248).
- The predicate key is
- AI-native. AI-callable tools are
*.actions.ts. The AI surface is skills-only —src/*/skills/*.skill.tsviadefineSkill(), attached to a platform agent bysurface. ⛔ Never author a*.agent.ts(#512, ADR-0063 §2).
Every metadata file under src/ is validated — ⛔ not by a parse() call you write in the file; no file under src/ makes one.
The authoring form is yours to get right; the enforcement point is fixed.
Author the file the way its neighbours are authored. The form is not uniform across metadata types: open the file next to the
one you are creating and copy its shape — the directory is the template. ⛔ Do not generalise from one directory to its neighbour; the
define* names are the trap (defineView() / defineSkill() are the form for their directories; defineFlow(), exactly
FlowSchema.parse(), is ⛔ not the form for a flows/ directory). Three forms exist:
- A validating constructor, called in the file.
src/*/objects/*.object.tsusesObjectSchema.create({ … })(@objectstack/spec/data),src/*/views/*.view.tsusesdefineView({ … }),src/*/skills/*.skill.tsusesdefineSkill({ … }). These reject at author time and name the unknown key back (unknown key(s) — workflows). - A typed object literal, no runtime call in the file.
src/*/pages/*.page.tsannotate withPage,src/*/dashboards/*.dashboard.tswithDashboard(both@objectstack/spec/ui),src/*/flows/*.flow.tswithAutomation.Flow(import type * as Automation from '@objectstack/spec/automation'). - A plain object literal with no schema import.
src/sales/profiles/*.profile.ts— this app's permission sets; ⛔ not*.permission.ts, authored nowhere — andsrc/*/sharing/*.sharing.ts.
Where the validation actually happens. objectstack.config.ts hands every collection to defineStack(), which validates each against
its @objectstack/spec schema; pnpm validate and pnpm build run it, and the platform parses again on boot. An unknown key on a page or a permission set fails pnpm validate with exit 1 though the file imports nothing.
src/<pkg>/<type>/index.ts
re-exports its files by name, objectstack.composition.ts merges the four packages' barrels and objectstack.config.ts feeds the
result to defineStack() — no glob discovery. A valid file that
never reaches the barrel is silently ignored: pnpm validate stays at exit 0 and names it nowhere. Exporting it is part of authoring it.
XSchema.parse() is a real API — for tests, not for src/. ObjectSchema, PageSchema, ViewSchema, FlowSchema,
PermissionSetSchema and their siblings carry .parse(); call it in a test or when building metadata programmatically, ⛔ never as the authoring form of a file under src/.
There is no
workflowmetadata type (ADR-0019/0020):WorkflowRuleSchemais exported by no installed@objectstack/*package, andObjectSchemarejectsworkflows:/workflow:by name. Field updates belong in*.hook.ts; status flips and notifications in arecord_change/scheduleflow; approvals in anapprovalnode inside a flow. A record lifecycle constraint is avalidations[]entry withtype: 'state_machine'on the object, ⛔ not aStateMachineSchemafile; whether it wants an invariant or a transition gate at all is Metadata semantics rule 7.
Use the most specific Field type @objectstack/spec/data offers:
| Need | Field type |
|---|---|
| Optional association to another object | Field.lookup('crm_x', …) |
| Required parent-child with cascade delete | Field.masterDetail('crm_x', …) |
| Aggregate over child records (sum, count, min, max) | Field.summary() |
| Multi-select picklist | Field.select({ multiple: true }) |
| File · image · GPS · postal address | Field.file() · Field.image() · Field.location() · Field.address() |
Which construct carries which intent (verbatim source objectstack#13848). Prose in a description is not a construct.
7. Invariant, or transition gate — pick the construct by the intent. An invariant ("X may never exceed Y") is a validations[]
script; existing violations are frozen, not bricked. A transition gate ("by the time the record reaches state S, X must be filled")
is requiredWhen, or a bound on the field; records that predate the rule pass. ⛔ Never let prose describe a gate as an invariant. (#1069)
8. Interception stands on a person's judgement. A machine signal (suspected) warns and lets the write through; only a value a person wrote down (confirmed) may block one. ⛔ Do not build an override escape hatch. (#1288)
9. Elevate as little as possible. A screen flow stays runAs: 'user'; a write that genuinely needs elevation is a dedicated system sub-flow called through a subflow node. ⛔ Never elevate a whole flow to make readonly take effect. (#1434)
10. The organization dimension is the platform's. Every runAs: 'system' scan or rollup MUST pin an organization predicate — the
#1363 guard is the acceptance criterion. organization_id is injected by the platform: ⛔ never declare it, or "add a tenant dimension", object by object. (#1372)
11. A deliberate deviation is written down twice. A choice that departs from a repo-wide convention carries a comment beside the code and an entry in the roster of the guard that would otherwise flag it; without both, the next agent tidies it away. (#1328)
Escape hatches are for extreme cases — layout is derived by default. Maintainer ruling, 2026-08-31 (verbatim, untranslated):
「或者说 skills 应该说明,逃生仓是极端场景按照客户需求自定义的场景下才需要,应该尽量避免。」
Authoring record:details sections on a custom record page, or enumerating fields in a view's form.sections, is for a named,
customer-demanded customization only. The ladder: (1) fieldGroups on the object, each field opting in with group: '<key>' — the
norm, the layout is derived; (2) the group-reference form { group: '<key>' } (objectstack#13897) when partial arrangement is genuinely
needed; (3) per-field enumeration only in the extreme case, with a comment naming the customer need and why a group reference cannot express it — rule 11 applying itself.
- Object Naming:
crm_prefix on every business object, written out (Tech Stack rule 2). - i18n: every new object ships in all four locale packs,
src/sales/translations/{en,zh-CN,es-ES,ja-JP}.ts— label, pluralLabel, every field and option label, view labels, navigation labels. - Docs: every new object or feature gets a user-facing page under
content/docs/for business users and admins — business concepts, never a hand-copied machine roster; English first, then.zh-Hans.mdxand.zh-Hant.mdxbeside it, under Documentation discipline below. - Validation predicates must be TOTAL: every
record.xread in an authored CEL predicate (validations[].condition,requiredWhen,readonlyWhen,visibleWhen) carries ahas(record.x)guard. - Dependencies: the
@objectstack/*packages inpackage.jsonare version-locked and bumped together; keepspecVersioninobjectstack.manifest.jsonaligned with the installed@objectstack/spec.
A rule evaluates against {...previous, ...data}; on update, a driver that stores only the columns a row was written with hands back
the key absent, not null. Strict CEL aborts the predicate with No such key, and since 17.0.0-rc.2 an unevaluable rule rejects
the write (before rc.2 it was skipped in silence). Neither is the rule you wrote, so:
| intent | write this |
|---|---|
x holds no value |
(!has(record.x) || isBlank(record.x)) |
x holds a value |
has(record.x) && record.x <op> … |
!= null and coalesce(record.x, "") are ⛔ not substitutes — both abort on an absent key. test/object-validation-predicates.test.ts enforces the guard and carries the measurement; read it before adding a rule.
5. Docs explain business concepts. ⛔ They never hand-copy a machine list. The source of truth for a machine fact — a dataset's
dimensions, an object's fields, the roster of objects — is the metadata under src/; a table transcribed into prose drifts, and this
repo has measured it drifting repeatedly. The boundary is one question: can a reader see this directly in the product UI? A
navigation fact may be documented and guarded; a machine semantic layer may not. This rule binds this file too. (#1620)
6. The Chinese doc surface has three rules.
- List view names take the zh-CN language-pack wording — ⛔ never coin a fresh translation for a view the app already labels
(#1329). Every other pack-carried UI noun — dashboard and tile titles, gauges, dataset dimensions and measures, sharing-rule names,
the dashboard's own label — keeps its English spelling in every locale even though the pack carries a translation:
test/docs-dashboard-tiles.test.tsresolves each**Name** 磁贴to an English widget title and goes red on a converted one. The guard's ablation is the discriminator, not pack-carriage; measures are the same class and currently unguarded (#1618). - A Chinese heading carries an explicit English anchor id, and one anchor word is used across every language, so a link survives translation (#1359).
- zh-Hant conventions are stated by their real reason, not a style preference: the app
ships no Traditional locale —
src/sales/translations/and thesupportedLocalesinobjectstack.config.tscarry none, so open them rather than trust a list — the console therefore falls back to Simplified, and a Traditional page labels platform navigation in English rather than ship mixed Simplified/Traditional script (#1368). ⇒ With no Traditional pack to source from, a UI noun on a zh-Hant page takes, in order: (1) the zh-CN pack wording written in Traditional characters, for the classes the bullet above sources from the pack at all (list view names); (2) otherwise the English label exactly as shipped — which is what that bullet already requires of every other pack-carried noun in every locale. ⛔ Never coin a Traditional translation. Each zh-Hant page carries one identical standing sentence saying so; ⛔ no guard asserts free prose (#1646 refused prose guards, #1755 measured an idle one). (#1767)
- Read the official release notes first — https://docs.objectstack.ai/docs/releases (per-major pages carry the breaking-change list and a migration checklist). ⛔ Do not reverse-engineer breaking changes from changelogs.
- Per-package
node_modules/@objectstack/<pkg>/CHANGELOG.mdsupplements the release notes, never replaces them. - Bump all
@objectstack/*packages together (they are version-locked), updatespecVersioninobjectstack.manifest.json, then runpnpm verifyand browser-verify (see below). - Record the upgrade in the PR's changeset, ⛔ not in
CHANGELOG.md:changeset versionowns that file and splices each release in, so a hand-written entry is published by nothing. - Re-scope the claims that name the old pin — re-scope, never renumber. Search version-agnostically, across wrapped lines and comment leaders, for the shape of a pin claim, over identifiers and human-readable labels; every hit is a candidate to classify by hand — a claim is false only when it asserts the old version is the current pin (re-measure it on the new pin, or re-word it to date itself), while "measured on X" is dated truth and stays. (#1681) A grep finds only text carrying a version token; the same stale fact stated in other words, or by a label, is found by reading — say so in the PR rather than call the sweep complete.
12. Look for an existing ruling before you escalate. When a card already records a maintainer ruling, ⛔ do not re-escalate it and ⛔ do not re-decide it. The ledger of rulings is the director seat's pinned post (objectstack#13766, ruling B); the card's own comments are the detail notes. (#1198)
Verify before opening a PR. Run pnpm verify and make sure it is green — package.json is the single source of truth for what that
chain runs; pnpm validate inside it also enforces ADR-0021 widget binding (xAxis is a dataset dimension, yAxis[] a measure).
Every PR carries a changeset. A PR is not finished until it adds a .changeset/*.md entry — the Changeset Check workflow diffs
against the PR base, so files already in the directory prove nothing. Write it for the release-notes reader (what changed and why;
FROM → TO for a breaking change); it ships as CHANGELOG.md. The lone exception, for a PR that ships nothing to users, is the
skip-changeset label or an empty-frontmatter changeset — ⛔ never to turn a red check green.
A seat may take a PR out of draft and arm auto-merge once every check on it has finished and none has failed. Arming enqueues:
main's ruleset carries a merge queue that performs the squash merge within seconds — ⛔ never merge by hand, and do not re-derive a
wait from min_entries_to_merge_wait_minutes. A check that concluded skipped under a label, or never ran because a path filter
excluded it, is not a failure. Governed paths — AGENTS.md, CLAUDE.md, .claude/** — govern the whole
diff, however small that part of it is: the PR stays a draft and is the maintainer's own merge; ⛔ a seat never flips it ready and never arms auto-merge on it. (#1742)
Dashboard charts and other heavy widgets are React.lazy-loaded and hydrate a beat after navigation — an empty chart card right after navigating is the normal state, ⛔ never a verdict:
- After navigating, wait ~1–2 s (or poll) for the lazy bundle to hydrate.
- Confirm the chart drew with a DOM probe, not a picture — e.g.
document.querySelectorAll('.recharts-pie-sector, .recharts-rectangle, .recharts-funnel-trapezoid, .recharts-area-area, .recharts-line-curve').length > 0. - Cross-check the data path:
POST /api/v1/analytics/dataset/queryreturning200with rows means data and metadata are fine; an empty visual is then hydration timing or a renderer issue, ⛔ never a metadata bug.gaugerenders as a single number by ADR-0021 design.
Gotchas, same workflow: a NODE_MODULE_VERSION … requires … flood on boot is better-sqlite3 built for another Node ABI —
pnpm rebuild better-sqlite3, restart the dev server, unrelated to any app change · the Console dashboard route is
/_console/apps/<manifest.id>/dashboard/<dashboardName> (app.objectstack.hotcrm), ⛔ not /_console/a/<appName>, which bounces to
/_console/home · dev admin, seeded on an empty DB (dev only): admin@objectos.ai / admin123.