Skip to content

Latest commit

 

History

History
688 lines (573 loc) · 50.3 KB

File metadata and controls

688 lines (573 loc) · 50.3 KB

ObjectStack — AGENTS.md

Primary AI instruction file for this repo. Read natively by Claude Code, GitHub Copilot (coding agent + CLI, since Aug 2025), and other agents — no separate .github/copilot-instructions.md mirror needed.

v5.0 breaking rename: projectenvironment everywhere (CLI -e, /api/v1/environments/:id, header X-Environment-Id, OS_ENVIRONMENT_ID, DB column environment_id). No aliases. See ADR-0006. "Project" now only means the npm/monorepo sense.


Communication

  • 与维护者沟通时一律使用中文(对话回复、PR/issue 讨论中的解释性文字)。
  • 代码、标识符、提交信息(commit messages)、ADR/文档正文等仓库产物保持现有语言惯例(以英文为主),不要因本条而改写。

Build & Test

pnpm install          # deps
pnpm setup            # first-time: install + build spec
pnpm build            # turbo build (excludes docs)
pnpm test             # turbo test
pnpm typecheck        # turbo typecheck — per-package `tsc --noEmit`; tsup/vitest never type-check (#4311)
pnpm docs:dev         # docs site

Type-check coverage is ratcheted (pnpm check:type-check-coverage, CI-gated): every workspace package declares a typecheck script or carries a measured DEBT/EXEMPT entry in scripts/check-type-check-coverage.mjs. New packages must arrive covered; a package that graduates deletes its ledger entry in the same PR.

Do not exclude *.test.ts / *.spec.ts from a package's tsconfig.json. tsc --noEmit reads that config, so an exclusion there hides the tests from the check the typecheck script advertises — a green gate over source nothing read, which is the #4311 defect itself. The ratchet's TESTS_COVERED invariant fails on any new exclusion; the packages that already had one carry a measured TEST_DEBT entry and graduate by dropping the exclusion.

One trap worth knowing before you read any of these counts: under moduleResolution: NodeNext a relative import missing its .js extension does not resolve, every symbol it names becomes any, and the callbacks over those symbols then report TS7006 "implicitly any". A pile of TS7006 is usually one broken import upstream, not a package that needs type annotations — fix the extension first and re-measure.

Running the dev server

Scenario Command Notes
Frontend debug (UI in ../objectui calls backend) PORT=3000 pnpm dev pnpm dev = the showcase kitchen-sink app (default; best for exercising the platform). Port must be 3000 (UI hard-wired); persistent state; leave running. For the minimal CRM app instead: PORT=3000 pnpm dev:crm.
Backend-only debug pnpm dev -- --fresh -p <random> Random high port; ephemeral tempdir; you must kill it when done

--fresh: ephemeral tempdir (auto-deleted on exit) + --seed-admin (POSTs sign-up, prints creds — default admin@objectos.ai / admin123, override via --admin-email/--admin-password). The seeded admin is auto-promoted to platform admin (the system seed identity usr_system is skipped), so Setup/Studio are reachable on first login.

Rules: never run two backends on port 3000; for backend tasks pick a random port and tear it down; never kill a server you didn't start (other agents/the user may be using it — see Multi-agent discipline §8); always use a pnpm dev/dev:crm/dev:showcase script (flags after -- are forwarded), not raw pnpm --filter.

pnpm dev:crm -- --fresh -p 38421   # start; debug via curl
kill $(lsof -ti tcp:38421)         # tear down — tempdir auto-deletes

Frontend (Studio UI) — sibling repo ../objectui

This repo ships backend only. All Studio/Console UI work happens in ../objectui (separate repo, checked out next to framework/). Workflow: edit + commit + push in ../objectui, then in framework/ run pnpm objectui:refresh to pull its build into packages/console/.

Other scripts: objectui:bump (pull only), objectui:build, objectui:clean. ⚠️ Never hand-edit packages/console/dist/ or .cache/objectui-*/ — regenerated.

Fast iteration on ../objectui src (no commit/refresh loop): run objectui's own console dev server — cd ../objectui && pnpm --filter @object-ui/console dev (Vite on :5180, HMR). Its /api proxy targets DEV_PROXY_TARGET || http://localhost:3000, so run the backend you're testing on :3000 (PORT=3000 pnpm dev for showcase) and browse :5180. Note :3001/_console (or whatever the backend serves) is the published console, not your ../objectui src — only :5180 reflects local UI edits. See ../objectui/AGENTS.md for the app-id / localStorage / auth gotchas.


Prime Directives

  1. Zod First. All schemas start as Zod. Types via z.infer<typeof X>. JSON Schemas generated from Zod.

  2. No business logic in packages/spec. Spec = schemas/types/constants only. Runtime logic goes in core, runtime, or services/*.

  3. Naming:

    • TS config keys → camelCase (maxLength, defaultValue)
    • Machine names (data values) → snake_case (name: 'first_name')
    • Error codes → SCREAMING_SNAKE (PERMISSION_DENIED) — machine constants, not data values; scope and rationale in ADR-0112. Not a general license to deviate.
    • Metadata type names → singular ('agent', 'view', 'flow') — matches MetadataTypeSchema in packages/spec/src/kernel/metadata-plugin.zod.ts
    • REST endpoints → plural (/api/v1/ai/agents)
  4. Imports: Use @objectstack/spec namespaces or subpaths. Never relative ../../packages/spec.

  5. No workarounds. Adopt sustainable, well-architected solutions — not temporary patches.

  6. Object name = table name. The object name is the canonical id everywhere (API, ObjectQL, REST, SDK, DB table). Never set namespace (deprecated) or tableName (always equals name). For module prefixes, embed in the name (sys_user, ai_conversations).

  7. One Zod source per metadata type. Each type (view, flow, agent, …) has exactly one schema in packages/spec/src/{domain}/. Org overlay opt-in lives only in allowOrgOverride on DEFAULT_METADATA_TYPE_REGISTRY — no parallel whitelists. See ADR-0005.

  8. North Star alignment. Read content/docs/concepts/north-star.mdx before structural changes. If a change doesn't advance §7 Built, shrink Drift, or unlock Missing — it probably shouldn't ship.

  9. OS_ env-var prefix + structure. All ObjectStack-owned env vars MUST start with OS_, then follow OS_{DOMAIN}_{FEATURE}[_QUALIFIER] where DOMAIN is the subsystem (AUTH, SEARCH, CORS, CLOUD, DATABASE, CLUSTER, MCP, SSO, …) so related vars group together (cf. OS_AUTH_*, OS_CORS_*). Pick the shape by what the var is:

    • Boolean feature flag → suffix _ENABLED, default-off / opt-in: OS_{DOMAIN}_{FEATURE}_ENABLED (OS_SSO_ENABLED, OS_SCIM_ENABLED, OS_SEARCH_PINYIN_ENABLED). Never a bare OS_PINYIN_SEARCH — bare names read as config, not toggles.
    • Config value (URL / path / secret / level / count) → OS_{DOMAIN}_{NAME} (OS_CLOUD_URL, OS_DATABASE_URL, OS_LOG_LEVEL, OS_AUTH_SECRET).
    • Escape hatch / dangerous overrideOS_ALLOW_{X} — deliberately ungrouped and scary-looking (OS_ALLOW_MAIN_EDITS, OS_ALLOW_MEMORY_CLUSTER_MULTINODE).
    • Opt-outOS_SKIP_{X} / OS_DISABLE_{X}. Test/CI-onlyOS_TEST_* / OS_EXPECT_*.
    • Pre-existing vars that don't fit (OS_METADATA_WRITABLE, OS_EAGER_SCHEMAS, OS_SERVER_TIMING) are debt, not precedent — new vars follow this rule; rename old ones via the deprecation helper below when touched.

    When renaming a legacy var, use readEnvWithDeprecation('OS_NEW', 'LEGACY') from @objectstack/types (keeps legacy working one release). Third-party exceptions kept as-is: NODE_ENV, HOME, OPENAI_API_KEY, TURSO_*, OAuth *_CLIENT_ID/SECRET, RESEND_API_KEY, POSTMARK_TOKEN, AI_GATEWAY_*, SMTP_*. See #1382.

  10. File issues for out-of-scope findings — don't silently expand scope or leave them buried. When you hit a bug, gap, or unenforced capability that's unrelated to the current task, or too large to fix in scope, open a GitHub issue (gh issue create) with a clear repro/decision and link it from your PR. Corollary: never advertise or demo a capability the runtime doesn't actually deliver (declared ≠ enforced) — fix it, trim it, or file an issue, but don't fake coverage. Example: the spec once declared 9 validation-rule types while the write-path validator enforced only 3 (state_machine/script/cross_field); the gap was filed as #1475 rather than demoed in the showcase, then closed by trimming what could never be enforced (unique/async/custom) and implementing the rest — the spec now declares 6 and rule-validator.ts handles all 6. Note how narrow that claim stayed even so: the evaluator was wired into insert and single-id update only, so a bulk updateMany silently skipped every rule — a second declared ≠ enforced gap one layer down, at the call site rather than the switch; filed as #3106 and closed by evaluating the bulk match set per row. A case label is not enforcement; check the call site.

  11. Worktree-first — never edit on the shared main checkout. This repo is edited by multiple agents at once; the shared main tree has its HEAD switched and reset under you, silently clobbering uncommitted work. Before your first file edit, you MUST be in a dedicated worktree on a feature branch: git worktree add ../objectstack-<task> -b <branch> main && cd ../objectstack-<task> && pnpm install. A PreToolUse hook (.claude/hooks/guard-main-checkout.sh) enforces this — it blocks Edit/Write/NotebookEdit unless the edited file is in a dedicated worktree — a feature branch on the shared checkout is not enough (it still gets switched under you) — and it checks the edited file's own repo, so sibling repos (objectui/cloud) you touch are covered too (override for a deliberate non-task fix with OS_ALLOW_MAIN_EDITS=1). Full playbook below.

  12. Contract-first — fix the metadata, not the runtime. This is a metadata-driven framework: packages/spec is the one contract between metadata producers and the runtime/renderers that consume it. When a piece of metadata "doesn't work," ask first: is it spec-compliant? is this the long-term-correct direction? If the metadata is wrong, fix it at the producer and reject it at authoring/publish (validation / lint) so the error surfaces loudly — do not add a lenient alias or ?? fallback in the consumer (a node executor, the REST layer, a renderer) to tolerate off-spec input. A tolerant fallback fossilizes the wrong convention into a second de-facto contract, dilutes the spec, and hides the producer's bug — one strict contract beats N dialects. This is an internal contract (we own both ends), so "be liberal in what you accept" (Postel) does not apply — that's for untrusted boundaries. Change the spec only when the spec itself is genuinely wrong, and then deliberately (edit the Zod schema + migrate), never by accreting consumer-side fallbacks. The cfg.filter ?? cfg.filters / cfg.objectName ?? cfg.object fallbacks the flow executors once carried are debt to pay down, not a pattern to copy — and the way they are being paid down is the pattern to copy. filtersfilter has graduated into the ADR-0087 D2 conversion layer (flow-node-crud-filter-alias): rewritten to the canonical key at load, including the AutomationEngine.registerFlow rehydration seam, so the CRUD executors read cfg.filter directly and no consumer-side fallback survives. objectobjectName and the six open-coded stragglers #3796 tracked (notify to/subject/body/url, script functionName/input) graduated the same way at protocol 17 (flow-node-crud-object-alias, flow-node-notify-config-aliases, flow-node-script-config-aliases), emptying the readAliasedConfig executor shim — deleted with them. When you must tolerate an alias at all, declare it as a conversion-layer entry (never a bare ??, and no new executor shims) so it is declared, loud, tested, and removable on a schedule. Stored sys_metadata rows (data at rest) are covered from the other side: every rehydration seam replays the full conversion chain — retired entries included — via applyConversionsToStoredItem (#3903, ADR-0087 addendum), so a consumer never needs its own accommodation for a legacy stored shape either. Worked example: an AI-authored create_record used fieldValues / today() / {{trigger.record.id}} while the executor reads fields / {TODAY()} / {record.id} → the fix was correcting the authoring skill + a publish-gate lint that rejects the wrong shape (cloud#688), not a cfg.fields ?? cfg.fieldValues runtime alias (framework#2419, rejected). Strengthens #5.

  13. An accepted ADR binds until a superseding ADR says otherwise. Reversing a recorded decision is itself a decision: it needs a new ADR (or an amended status line on the old one), not a changeset that quietly does the opposite. Before changing behaviour in docs/adr/-governed territory, grep the ADRs for the surface you are touching — the decision is often older and broader than the code comment in front of you. Worked example: three accepted ADRs said sys_member.role must never carry RBAC authority (ADR-0057 D4 "never as the authority for RBAC", ADR-0090 D3's word ban "distribution = position", ADR-0095 D3 "no enforcement-time code path may consult the better-auth role"). A patch-level changeset made app-declared names storable there anyway; a follow-up made it automatic in every host; the reversal held for a day and the tracking issue was closed, reopened and rewritten three times while the cause moved (#3723 → ADR-0108). The mechanism was not carelessness — the file being edited never named the ADRs that governed it, so the author could not have known. Hence the corollary: when you implement an ADR's decision, leave its id in the code, and anchor load-bearing spots in scripts/adr-anchors.json (pnpm check:adr-anchors) so the next author is told which decision they are standing on. A decision nobody can find is a decision that will be reversed.


Multi-agent working discipline

This repo is worked on by multiple agents in parallel. Use one git worktree per agent/task (git worktree add ../objectstack-<task> -b <branch>; run pnpm install in the new tree) so file systems are physically isolated — this is mandatory, not a preference (Prime Directive #11), and a PreToolUse hook blocks edits made while on the shared main branch. Working in the shared main checkout is not a supported fallback: branches get switched and shared files — including ones you just wrote — get reset under you mid-task (a full session's work was silently reverted twice before this rule was enforced).

Claim the issue BEFORE you write any code. Assign it to yourself (gh issue edit <n> --add-assignee @me, or the issue_write MCP tool with assignees) as the first action of the task — before the worktree, before the first read. An unassigned issue reads as an open invitation, and several agents work this repo at once: two that both start on it burn the same hours twice and then race to land conflicting shapes for the same problem, which is worse than either one alone. If it is already assigned to someone else it is taken — pick another, or say so and ask; never reassign it to yourself.

Because every agent here shares one GitHub identity, the assignee field alone cannot answer "is this claim mine?" — seeing your own shared name on an issue is exactly what another session's claim looks like. So a claim is two acts, not one: assign, and leave a claim comment carrying your session ID and branch name (claude/issue-<n>-<slug>). Before writing code, re-read the issue's comments; an earlier claim comment with a different session ID or branch means the issue is taken no matter what the assignee field seems to say. Skipping this read is how #4551 got implemented twice in one morning (#4555 and #4559 — post-mortem in #4588), and misreading shared-identity state is also how a maintainer's manual ready-flip got reverted by an agent that assumed its own write had failed.

The claim is also what makes the finding rule (Prime Directive #10) safe to follow. Once out-of-scope discoveries become issues, the issue list is a real queue other agents read, and a claim is the only thing separating "someone is on this" from "nobody has looked yet". File it unassigned when you are merely recording a finding; assign it at the moment you actually start.

Even inside your own worktree, operate defensively:

  1. Only touch the files your task needs. Don't "fix" unrelated diffs, reverts, or other agents' in-flight edits, and don't try to manage the whole working tree. If a file you didn't change shows as modified, leave it.

  2. One feature branch + one PR per task. Branch off main. Never commit task work straight to main. Name the branch after the issue it fixes: claude/issue-<n>-<slug>. The issue number in the name is what makes in-flight work discoverablegit ls-remote --heads origin | grep issue-<n> is a one-command pre-check, and the Duplicate Fix Guard workflow warns on fix PRs whose branch names no declared issue. The #4555/#4559 duplicate (#4588) stayed invisible partly because one branch carried the issue number and the other didn't.

  3. Never git push --force / --force-with-lease, and never push main. A force-push can clobber a parallel agent's work; main is shared — land everything via PR.

  4. Verify the current branch before every commit/push (git rev-parse --abbrev-ref HEAD). HEAD may have been switched by another agent — if it isn't your feature branch, stop and re-checkout before pushing.

  5. Shared files (barrels/registries like builtin/index.ts): edit → git add → commit atomically, then confirm the commit really contains your lines (git show HEAD:<file> | grep <yourChange>). A concurrent edit can revert your working-tree change between the edit and the commit. On a real conflict, re-apply only your lines and let the PR merge integrate the rest.

  6. Don't rebase or force-update shared branches to tidy other agents' commits.

  7. Merge only after remote CI is fully green. Never gh pr merge --auto. Auto-merge can land a still-red PR onto shared main and break it for every parallel agent (see #1475). Merge serially; rebase other open branches before merging the next one. Once the repo's merge queue is enabled, "add to queue" IS the sanctioned path — it is the opposite of the auto-merge this rule bans: the queue builds your PR as merged onto the current main and lands it only if that speculative result is green, which is exactly the §10 re-verification, done by the platform, race-free. The manual serial protocol above is the fallback for when the queue is unavailable. (Why this matters: main can land a PR every few minutes at peak; a manual merge–reverify loop takes ~25 minutes, so under load it never wins the race — one PR went three full green cycles without managing to land. That is a livelock, not a discipline failure.)

  8. Testing needs a server? Start your own temporary one — never stop someone else's. A running dev server you didn't start probably belongs to another agent or the user; killing it (or its port) breaks their in-flight work. Spin up your own instance on a random high port (pnpm dev -- --fresh -p <random>) and shut it down yourself when the task is done (kill $(lsof -ti tcp:<port>)). Don't leave orphan servers behind.

  9. After pulling main into a long-lived worktree, refresh its build state before you trust a single test or gate. A worktree that has been open across several merges accumulates artefacts that are stale relative to the source, and every one of them fails as if your change broke something — naming other people's exports, other packages' files, or config you never touched:

    stale artefact how it presents why it lies
    packages/spec/dist check:api-surface reports other people's exports as "N breaking (removed/narrowed)"; check:i18n-coverage rejects an example config for a value the spec allows both read the built .d.ts, not src/
    node_modules a package fails to resolve a dependency it plainly declares (Cannot find package 'hono') the merge moved pnpm-lock.yaml
    packages/runtime/.objectstack/ datasource-autoconnect sees each row 6× gitignored fixture state accumulating across runs
    .cache/objectui-* pnpm lint reports dozens of errors in files you have never opened a full objectui checkout left by build-console.sh, linted as if it were ours

    So after any git merge origin/main: pnpm install --frozen-lockfile && pnpm build && rm -rf packages/runtime/.objectstack (add rm -rf .cache if you have run the console build). Note OS_SKIP_DTS=1 keeps a build fast but leaves no .d.ts, so gen:api-surface cannot run at all under it — that one needs a real build.

    None of this is CI-visible: CI checks out fresh and installs clean. It costs only your time, which is exactly why it is worth recognising in one step rather than re-diagnosing per gate.

  10. A clean merge is not a working merge — but scope the re-check to the overlap. Git conflicts on overlapping lines; nothing warns you when two changes are individually fine and jointly wrong. Real examples from one branch's lifetime: a test asserting a response body's exact shape landed while that shape was being changed elsewhere (merged clean, failed CI); a domain file was deleted while another agent's guard still declared it. Before opening a PR, pull main, refresh build state (§9), and run the full suite once. For the subsequent pre-merge merges of main — the ones you do only because main moved again while CI ran — the full suite is usually re-proving what three identical runs already proved, at ~15 minutes per lap while main lands a PR every few. Scope it instead:

  • Always: rebuild what the merge touched, and if packages/spec moved on either side, pnpm --filter @objectstack/spec build && pnpm --filter @objectstack/spec check:generated — generated snapshots (api-surface, baselines) are the classic jointly-wrong artifact, and only a rebuild of the merged source can validate them (never trust git's textual merge of a generated file). Then assert your branch's delta vs main is still exactly what your PR intends (e.g. "N removed / 0 added").
  • Full pnpm typecheck && pnpm test again only when the incoming commits touch the same packages or the same behavior your diff does, or a conflict occurred outside trivially-mechanical files.
  • CI on the PR (and the merge queue, once enabled) validates the merge commit itself — that second CI round is where joint breakage surfaces, and the guards in scripts/check-*.mjs exist largely because this class of breakage is invisible to git merge.
  1. Generated artifacts don't text-merge — a driver defers them and pre-commit collects the debt. §10's "never trust git's textual merge of a generated file" is now mechanical (#4675). .gitattributes routes the generator-owned artifacts (spec-changes.json, authorable-surface.json, api-surface*.json, json-schema.manifest.json, docs/protocol-upgrade-guide.md, content/docs/references/**) to merge=os-regen, so a merge that used to stop on conflicts across all of them now stops only on the hand-written files that actually need you.

The driver does not regenerate. Git runs merge drivers while it merges, in index order, so the worktree still holds pre-merge sources — a generator run there would describe a half-merged tree and write a confidently wrong artifact, which is strictly worse than the conflict it replaced. Instead it records each path in $GIT_DIR/os-regen-pending, and pre-commit refuses the commit until those artifacts check clean. So the sequence after a merge is unchanged from §9 — rebuild, then check:generated --fix — you just cannot forget it.

Two things worth knowing:

  • Registration is per clone. pnpm install does it (preparescripts/setup-git-hooks.mjs). A clone where that never ran falls back to git's default text merge — pre-#4675 behaviour, not breakage — so nothing depends on every machine being set up.
  • The ratchets are deliberately excluded (docs-import-surface.baseline.json, dual-source-exports.baseline.json, the hand-written migrations/conversions registries, variant-docs.json). Recomputing a shrink-only ratchet can widen it, which would launder a new exemption in as merge noise. Those conflicts are yours to read. See NOT_DRIVER_MANAGED in scripts/regen-artifacts.mjs for why, per path.

Related: check:generated --fix now refuses to run gen:api-surface on a stale dist rather than warning about it (§9's trap, made unsurvivable on the one path that writes).

pnpm check:merge-driver reconciles .gitattributes against that table in both directions and proves the driver end to end against real git.


Monorepo Layout

packages/
  spec/           # 🏛️ Protocol schemas, types, constants (Zod source of truth)
  core/           # ⚙️ ObjectKernel, DI, EventBus
  types/          # 📦 Shared TS utilities
  metadata/       # 📋 Metadata loading & persistence
  objectql/       # 🔍 Query engine
  runtime/        # 🏃 Bootstrap (Driver/App plugins)
  rest/           # 🌐 Auto-generated REST layer
  client/         # 📡 Framework-agnostic SDK
  client-react/   # ⚛️ React hooks
  cli/            # 🖥️ CLI
  create-objectstack/  # 🚀 Scaffolding
  vscode-objectstack/  # 🧩 VS Code extension
  adapters/       # 🔌 express/fastify/hono/nestjs/nextjs/nuxt/sveltekit
  plugins/        # 🧱 Official plugins & drivers
  services/       # 🔧 Kernel-managed services
apps/docs/        # 📖 Fumadocs site
examples/         # 📚 Reference implementations
skills/           # 🤖 Domain skill definitions
content/docs/     # 📝 Docs content

Studio UI: ../objectui (sibling repo).


Protocol Domains (packages/spec/src/)

Namespace Path Responsibility
Data data/ Object, Field, FieldType, Query, Filter, Sort
UI ui/ App, View (grid/kanban/calendar/gantt), Dashboard, Report, Action
System system/ Manifest, Datasource, API endpoints, Translation (i18n)
Automation automation/ Flow, Workflow, Trigger registry
AI ai/ Agent, Tool, Skill, RAG, Model registry
API api/ REST/GraphQL contract, Endpoint, Realtime
Identity identity/ User, Organization, Profile
Security security/ Permission, Role, Policy
Kernel kernel/ Plugin lifecycle (PluginContext)
Cloud cloud/ Multi-tenant, deployment, environment
QA qa/ Test, validation
Contracts contracts/ Cross-package interfaces
Integration integration/ External integrations
Studio studio/ Studio UI metadata
Shared shared/ Error maps, normalization utilities

Root also exports: defineStack, composeStacks, defineView, defineApp, defineFlow, defineAgent, defineTool, defineSkill.


Kernel

Kernel Use For
ObjectKernel Default production runtime. Full DI / EventBus / Plugin lifecycle.
LiteKernel Tests (vitest), serverless, edge (Workers).

EnhancedObjectKernel is deprecated — do not use.


Documentation Guardrails

Path Type Rule
content/docs/references/ AUTO-GEN ❌ Never hand-edit. Regenerated by packages/spec/scripts/build-docs.ts.
content/docs/releases/ RELEASE-OWNED ❌ Never edit in a code PR. Release notes are written centrally at release time, compiled from changesets + the ADR-0087 registries — not accreted a row per PR. Per-PR appends made releases/v<major>.mdx the repo's hottest conflict magnet (three PRs raced the same table inside one afternoon), and every manual resolution risks dropping someone else's row. Your PR's input is its changeset; for spec removals also the D2/D3 registry entries. Factual error on a releases page → dedicated docs-only PR or an issue, never a rider on code changes.
**/translations/*.generated.ts (nine packages — platform-objects, five plugins, three services) AUTO-GEN ❌ Never hand-edit the file structure. Run node scripts/check-i18n-bundles.mjs --write to regenerate all nine (merge mode — every existing translation is preserved); pnpm i18n:extract still covers platform-objects alone. Translation values are hand-written and expected to be: the gate compares against a merge-mode extract, so editing a string is fine, while adding or dropping keys is drift. pnpm check:i18n gates all nine in CI, and pnpm check:i18n-coverage ratchets untranslated declared labels.
content/docs/guides/ hand-written ✅ Update meta.json when adding pages.
content/docs/concepts/ hand-written
content/docs/getting-started/ hand-written
content/docs/protocol/ hand-written

Touched packages/spec? Regenerate its artifacts BEFORE pushing

packages/spec has eight checked-in generated artifacts, each with its own CI gate. All of them live in one job — TypeScript Type Check in lint.yml, which is required and has no paths filter, so no gate can go dormant on the PR that breaks it (#4291 retired the filtered Check Generated Artifacts job for exactly that reason). That job runs its gates sequentially, so the first stale artifact masks every one behind it, and you get one red build per artifact instead of one for all of them. Match the change to the gate and regenerate up front:

You changed Gate that fails Regenerate with pnpm --filter @objectstack/spec …
A .describe() / TSDoc on any schema check:docs gen:schema && gen:docs
A public export (added / removed / renamed) check:api-surface gen:api-surface
An authorable key on a metadata schema check:authorable-surface gen:schema
An ADR-0087 conversion / migration registry check:spec-changes, check:upgrade-guide gen:spec-changes, gen:upgrade-guide
A SKILL.md (frontmatter or body) check:skill-docs, check:skill-refs gen:skill-docs, gen:skill-refs
The react-blocks contract check:react-blocks gen:react-blocks

A .describe() string counts — it is not "just a comment", it lands in content/docs/references/. Adding one export counts — it lands in api-surface.json. Both were learned the hard way in #4040: two separate red builds, neither a logic error.

Don't match by hand — one command runs every gate and reports all stale artifacts at once, which is precisely what CI cannot do:

pnpm --filter @objectstack/spec build             # REQUIRED first — see the dist caveat
pnpm --filter @objectstack/spec check:generated   # every gate; the first failure does not stop the rest
pnpm --filter @objectstack/spec check:generated --fix   # regenerate ONLY the ones it proved stale

--fix is deliberately narrow. Regenerating the whole set on principle destroys the signal: it rewrites artifacts whose staleness you never saw, so a real semantic change lands silently inside a mechanical diff. Let the check tell you which are stale, then regenerate those.

The script carries its own ledger of gate → generator and reconciles it against package.json on every run, in both directions. A new check:/gen: script that nobody classified fails the run rather than quietly dropping out of coverage — the failure mode a hardcoded list here would have had. (It caught its own package.json entry on the very first run.) CI runs the same reconciliation on every PR (--reconcile-only, in lint.yml's required typecheck job), so an unclassified script fails its own PR instead of landing on main and turning this wrapper red for everyone else — which happened twice before the CI step existed (#4203, #4232).

⚠️ check:api-surface reads the built dist/*.d.ts, not src/. A stale dist makes it report exports as removed — "N breaking (removed/narrowed)" — when nothing was removed at all: the snapshot is simply newer than your build. Rebuild before you believe it, and before you file a bug about main being red. (Two phantom "breaking removals" this way while writing this section; check:generated now prints this caveat inline when that gate is the one failing.)

check:liveness, check:empty-state, check:skill-examples, check:react-declaration-parity, check:exported-any and check:dual-source-exports are pure checks with no generator — a failure there is a real finding to fix, not an artifact to regenerate. check:generated names them as deliberately not run, so its "all up to date" never reads as "everything passed". The last one asks the third question about the export surface (#4446): api-surface.json shows a name on two entries but not whether that is one declaration re-exported (fine) or two declarations sharing a name — the #4411 trap, judged by symbol identity against the built dist, with the accepted cases in the shrink-only dual-source-exports.baseline.json (hand-edited under review, never generated: a gen: would admit a new dual-source via "run the fix command").

⚠️ check:react-declaration-parity compares two DECLARATIONS, not a declaration against an implementation. Left: the props a block's spec zod schema declares. Right: the inputs the objectui registry config declares. Both are declarations — manifestFromConfigs copies config.inputs verbatim — so a prop both sides declare and no renderer reads is, to this gate, perfect agreement. It was named check:react-conformance and opened by claiming it confirmed the components "ACTUALLY implement" the spec props; it never could, and #4413 shipped four dead blocks straight through a green run of it. Renamed and re-scoped in #4472. The gate is still worth having (spec-only, registry-only and missing are real signals) — just don't read it as proof anything renders.

check:exported-any is the one of those that also reads the built dist/*.d.ts, so the stale-dist caveat above applies to it too. It asks the other half of the api-surface.json question: that snapshot records an export exists, never what it resolves to, which is how five exported symbols sat at any for a whole major with every gate green (#4171). A recursive Zod schema needs an annotation to break its circular inference, and z.ZodType<any> compiles, validates correctly, and silently throws the type away — annotate with the type instead (QueryAST in src/data/query.zod.ts is the pattern).

Two generators have no gate at all — gen:openapi and gen:sbom. Nothing verifies their output is current; the script reports that each run rather than staying silent about it.


Context Routing — apply the right role per path

Path Role Key Constraints
**/objectstack.config.ts Project Architect defineStack, driver/adapter selection
packages/spec/src/data/** Data Architect Zod-first, snake_case, TSDoc every prop
packages/spec/src/ui/** UI Protocol Designer View types, SDUI patterns
packages/spec/src/automation/** Automation Architect Flow/Workflow state machines
packages/spec/src/ai/** AI Protocol Designer Agent/Tool/Skill schemas
packages/spec/src/system/** System Architect Manifest, datasource, i18n
packages/spec/src/kernel/** Kernel Engineer Plugin lifecycle, PluginContext
packages/spec/src/security/** Security Architect RBAC, policies
packages/core/** Kernel Engineer Runtime logic OK here
packages/runtime/** Runtime Engineer Bootstrap, plugin registration
packages/rest/** API Engineer Route gen, middleware
packages/plugins/** Plugin Developer Implements spec contracts
packages/services/** Service Engineer Kernel-managed services
packages/adapters/** Integration Engineer Framework bindings, zero business logic
packages/client*/** SDK Engineer Public API, DX, type safety
apps/docs/** Docs Engineer Fumadocs + Next.js, MDX
examples/** Example Author Minimal, runnable, uses defineStack
content/docs/** Technical Writer Respect auto-gen boundaries
../objectui/** (sibling repo) Studio UI Engineer React + Shadcn + Tailwind, dark mode default

Skills (skills/)

Consult the matching SKILL.md when working in its domain: objectstack-platform, objectstack-data, objectstack-query, objectstack-api, objectstack-ui, objectstack-automation, objectstack-ai, objectstack-i18n, objectstack-formula (CEL).

skills/ is the published catalog (it ships to customer projects). Repo-internal agent playbooks live in .claude/skills/ and must carry metadata.internal: true: dogfood-verification (boot and drive the real app in a browser) and spec-property-retirement (ADR-0049 enforce-or-remove — the full retirement kit).


Patterns

Zod schema:

export const FieldSchema = z.object({
  name: z.string().regex(/^[a-z_][a-z0-9_]*$/).describe('Machine name (snake_case)'),
  label: z.string().describe('Display label'),
  type: FieldTypeSchema,
  maxLength: z.number().optional(),
  defaultValue: z.any().optional(),
});
export type Field = z.infer<typeof FieldSchema>;

Plugin (the kernel contract is init/start/destroypackages/core/src/types.ts; the old onInstall/onEnable/onDisable example described hooks nothing ever called, retired in #4212):

export class MyPlugin implements Plugin {
  name = 'plugin.my-feature';
  async init(ctx: PluginContext)  { /* register services, schemas, routes */ }
  async start(ctx: PluginContext) { /* begin work that needs every service up */ }
  async destroy()                 { /* cleanup */ }
}

Route & surface ownership

Four rules, each paid for by a real bug. They matter more than usual here because this repo is largely written by agents, and every one of them is a trap that reads as reasonable code.

1. One route, one owner. Never add a second implementation of a path that another package already serves, however convenient. A shadowed duplicate is code that grep finds and the runtime never runs — the exact input that makes an agent (or a human) reason confidently from dead code. It also silently forks every future invariant: the retired hono /data surface had to re-learn the anonymous-deny gate (#2567), honest batch capability reporting (#3298) and discovery accuracy (#4018), each after the fact, each because someone fixed the real owner and never knew about the copy. Retired in #4073.

2. Explicit composition over default magic. A capability that appears because of a default nobody wrote down is invisible at every call site — and call sites are the primary evidence an agent reasons from. #4073's own first analysis checked who passes the option and missed who relies on the default; the correction is in that issue's opening paragraph. If a host should get a surface, it should mount it.

3. Absence must be loud. A composition that legitimately serves nothing should say so once at boot, naming the remedy — never leave a bare 404 to be diagnosed. The same rule applies to tooling: a verifier that silently degrades (reusing a stale build, skipping a check it could not run) is worse than no verifier, because it reports success. Prefer failing to falling back.

4. Machine-readable surfaces must not lie. /discovery and friends are read by SDKs, codegen and AI clients. Advertise only what is actually mounted, and mount everything advertised (ADR-0076 D12) — a wrong answer here propagates into everything built on top of it.

Verifying any of this: "who serves this path" is a question about the composed, provisioned runtime — not about which plugin declares it, not about registration order, and not about a minimal harness that merely boots. #4073 was answered wrongly three times, once per each of those shortcuts. Boot the real composition with its real services, or do not claim an answer.


Degradation log levels — warn vs error

Nearly every catch in this repo is a best-effort degradation, and nearly every one of them logs warn. That default is wrong for a specific, recurring class, and the cost of getting it wrong is not noise — it is silent data loss. Decide the level with one question, not with an adjective:

After the degradation, does the system still look "normal" from the outside, while something it claims is persisted has not actually landed? Yes → error. No → warn/info is right.

  • Functional degradation → warn / info. A screen is missing, a trigger is not armed, a capability is not enabled, an optional service never showed up. The system is visibly smaller than it should be, and the next person to use the missing thing finds out. ScheduleTriggerPlugin: job service not available — scheduled flows will not run until one is registered is exactly right at warn.
  • Durability / data-consistency degradation → error. A write that claims to persist does not, DDL that was supposed to run did not, persisted state and runtime state disagree. Nothing looks broken; the loss surfaces a release later, to someone who cannot connect it to this line.

Why this is a rule and not a preference. #4420: the durable suspended-run store attached to a table that was never created, every write failed into a warn nobody read, and every restart dropped all in-flight approvals — the symptom surfaced a release after the cause. #4460 raised that one site to error; #4632 made it the rule, because the class is what recurs. It is the same failure Prime Directive #10 names — advertising a capability (here: durability) the runtime does not deliver — and the same instinct as "Absence must be loud" above: prefer failing to falling back, and when you must fall back, say what was lost.

An error here owes two things, both, in the first line it prints (packages/services/service-automation/src/plugin.ts start() is the reference text):

  1. the consequence, concretely — what is not durable, and that the system will keep looking healthy anyway;
  2. the fix — the composition/config change that restores durability, or the explicit opt-out that makes the degradation deliberate (suspendedRunStore: 'memory', OS_SKIP_SCHEMA_SYNC).

Say it once, at the first degradation, not once per failed write.

Do not over-apply it. Escalating a functional degradation to error is the mirror-image failure: it trains everyone to skim error, which is what made the #4420 warn unreadable in the first place. In particular, an if (!service) composition branch is usually functional and usually belongs at warn; a catch around a write, a DDL call, or a store initialization is where this rule bites.

It has teeth (a rule this repo only writes down is the very "declared ≠ enforced" shape it keeps paying to fix): pnpm check:durability-log-level walks the AST for catch blocks guarding a declared vocabulary of durability-critical operations and fails when one logs below error without rethrowing. It is deliberately narrow — it cannot discover a new durability seam, only stop the known ones from regressing. Found a new one? Add it to DURABILITY_CRITICAL_CALLEES in scripts/check-durability-degradation-log-level.mjs in the same PR that fixes it. Accepted exceptions live in scripts/durability-degradation.baseline.json, hand-edited with a reason and shrink-only.


Startup registry reads — never record a verdict the boot can still contradict

A boot fills its registries incrementally. Asking a registry "is X there?" while it is still filling is fine — the answer is simply not final yet. Turning that not-yet into a verdict and recording the verdict is the defect, because the provider registers a moment later and nothing goes back to undo the record.

Decide with one question, the counterpart of the degradation-log-level one:

At the moment this code concludes "X is not registered", can a provider still register X during this same boot? And is that conclusion RECORDED anywhere that outlives the moment? Yes and yes → defect.

Three parts, all three or it is not a finding:

  1. a read of a registry that is still filling — the service registry during init(), or a plugin-extensible capability registry before it is sealed;
  2. a terminal conclusion drawn from "absent";
  3. that conclusion recorded — cached in an instance field or module binding, asserted in a warn, or persisted.

Part 3 is what makes this a rule and not noise. A read-only probe is completely legal: AutomationEngine.getUnknownNodeTypeAudit() reads the executor registry on every call, records nothing, and is correct.

Why this is a rule and not a preference. One showcase cold start on 2026-08-03 produced three instances, in three unrelated subsystems, written by three people at three times: plugin-auth froze an undefined cache handle into its config for the life of the process, so rate-limit counters never reached the shared store and the printed warning sent operators to provision Redis for a problem they did not have (#4772); service-automation asserted that eight approval flows "will fail at execution time" 0.8s before the executor that runs them was registered, and a deployment that genuinely lacked the plugin emitted the identical eight, so the signal could not tell the two apart (#4771); objectql wrote an ADR-0104 attestation into sys_migration during the same boot that was still seeding rows contradicting it, so the next restart rejected its predecessor's data (#4769). Whether the kernel contract itself should be tightened further is #4776.

The three cures, in preference order:

  1. Resolve where it is used, not where you start. A lazy accessor or a kernel:ready hook sees a provider that registered later — createLazyCacheRateLimitStorage() in plugin-auth is the reference.
  2. Declare the ordering (ADR-0116). dependencies / optionalDependencies / requiresServices make the kernel hoist the provider ahead or assert it registered, which makes "absent" a fact. Tolerance belongs in the plugin's own declaration, where the kernel enforces it — not in a checker's ledger.
  3. Seal the vocabulary, then judge. For a registry that is open by contract (ADR-0018 flow node types), the host declares the moment it can no longer grow — AutomationEngine.sealNodeTypeVocabulary(), called at kernel:bootstrapped — and only then is an absence worth reporting.

It has teeth: pnpm check:startup-registry-verdict walks the AST for that three-part shape and fails on it; accepted exceptions live in the shrink-only, hand-edited scripts/startup-registry-verdict.baseline.json. Like check:durability-log-level it is deliberately narrow — it cannot discover a new seam, only stop known ones from regressing, and it under-matches on purpose rather than risk a false positive: getService('cache') is visible, a resolveCacheOrFallback() three layers down another package is not, and #4769's "registry" is a database table it can never see. Found a new open registry? Add it to OPEN_CAPABILITY_REGISTRIES in the same PR that fixes it.


Post-Task Checklist

  1. pnpm test — verify nothing broke. Touched a type-check-covered package? pnpm typecheck too.
  2. Land it — don't leave passing work in the working tree. Once tests pass, create a feature branch, commit, push, open a PR, and merge it after remote CI is fully green (see Multi-agent discipline: never straight to main, never gh pr merge --auto). A finished task = a merged PR, not a dirty working tree.
  3. Add a changeset for feature work. When the change is a feature or functional improvement, run pnpm changeset (or add a .changeset/*.md entry) describing it before committing. Pure bug fixes do not require a changeset. Breaking changesets must carry their migration. If the change removes or renames anything an author can write (a spec key, an export, a config field), the changeset body must state the FROM → TO mapping and the one-line fix — this text ships to consumers as CHANGELOG.md inside the npm package and is what an upgrading agent greps after the tombstone error. Removing an authorable spec key also requires a tombstone so the rejection itself carries the prescription — retiredKey() (packages/spec/src/shared/retired-key.ts) on a non-strict schema, or an entry in the relevant UNKNOWN_KEY_GUIDANCE / *_RETIRED_KEY_GUIDANCE map (see object.zod.ts, ai/tool.zod.ts) when the schema is .strict(). The changeset is one of fourteen surfaces a retirement touches — follow the spec-property-retirement skill (.claude/skills/) rather than reconstructing the kit, and note the two routes imply opposite liveness-ledger dispositions.
  4. Added or removed a packages/spec export? Run pnpm --filter @objectstack/spec gen:api-surface and commit the result. The TypeScript Type Check job diffs spec's built export surface against api-surface.json; a new export makes the snapshot stale and turns the job red. It reads the built dist declarations, so OS_SKIP_DTS=1 — the flag you reach for to make local builds fast — skips exactly the artifact the gate inspects, and the check passes locally while failing in CI. Same shape for the other generated-artifact gates in that job (check:docs, check:skill-refs, check:react-blocks), which read src/ and so do reproduce locally.
  5. Update CHANGELOG.md / ROADMAP.md if user-facing or architectural.
  6. Delete temporary artifacts — screenshots, traces, scratch logs, .playwright-mcp/, throwaway tmp*.ts, ad-hoc scripts. Repo must look identical to before, minus intended changes.

Edit Sizing

Keep single edit/create payloads under ~20KB. Split larger changes into multiple sequential edits.