Skip to content

Commit 240b287

Browse files
committed
docs: checkpoint the docs-accuracy audit pass over 18 hand-written docs
WIP checkpoint — the adversarial verification pass is still running and will add repairs on top of this commit. Recorded now so the work survives. An audit agent read each doc, located the backing implementation under packages/, and applied evidence-backed fixes in place (158 across 18 docs). Every fix carries file:line evidence in the run's fix log. The heaviest: - kernel/cluster.mdx (22): documented an `eventBus.emit(...)` API that exists nowhere in the codebase, a `ctx.cluster` PluginContext property that does not exist, `defineStack({ cluster })` that stack.zod.ts silently strips, fabricated `postgres`/`nats` drivers, the wrong env var (real one is OS_CLUSTER_DRIVER), and an at-least-once delivery promise neither shipped driver provides. - protocol/kernel/plugin-spec.mdx (20): `plugin.manifest.ts` as the manifest filename — grep-empty across the implementation, the real one is objectstack.plugin.json; `context.plugins.isInstalled()` which does not exist; compound semver ranges ('>=1.0.0 <2.0.0') that make the resolver's anchored parse throw; init described as registration order when bootstrap() runs the dependency-topological order. - data-modeling/external-datasources.mdx (3): credentialsRef samples used a `secret:` prefix the shipped SecretBinder cannot dereference (`sys_secret:`), so copying the sample fails closed at connect. - deployment/migration-from-objectql.mdx (2): a `^3.1.0` pin that can never resolve to the current 16.1.0 line. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HrRNgrWaRtggzmrHpbomyh
1 parent e5a4d26 commit 240b287

18 files changed

Lines changed: 924 additions & 524 deletions

content/docs/concepts/metadata-lifecycle.mdx

Lines changed: 50 additions & 29 deletions
Large diffs are not rendered by default.

content/docs/data-modeling/external-datasources.mdx

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -37,8 +37,8 @@ export const Warehouse = defineDatasource({
3737
schemaMode: 'external', // ObjectStack never runs DDL here
3838
config: { host: 'db.internal', port: 5432, database: 'analytics', user: 'readonly' },
3939
external: {
40-
allowWrites: false, // read-only (the default)
41-
credentialsRef: 'secret:warehouse/password', // resolved from the secret store
40+
allowWrites: false, // read-only (the default)
41+
credentialsRef: 'sys_secret:9f2c…', // opaque handle minted by the secret store
4242
validation: { onMismatch: 'fail', checkOnBoot: true },
4343
},
4444
active: true,
@@ -90,11 +90,12 @@ That's it. `GET /api/v1/data/ext_customer` now returns live rows from the remote
9090
`customers` table; filters (`?region=EU`) push down to the remote query.
9191

9292
<Callout type="warn">
93-
**Do not set `field.columnName` on an external object.** For federated objects the
94-
driver's query pipeline ignores `field.columnName`; `external.columnMap`
95-
(`remoteColumn → localField`) is the single, authoritative column mapping.
96-
`os build` / `os validate` rejects `field.columnName` on an external object with a
97-
clear error (ADR-0062 D7). Managed objects are unaffected.
93+
**`field.columnName` no longer exists.** It was removed from `FieldSchema` in the
94+
16.x line (#2377, ADR-0049) — the SQL driver always used the field key as the
95+
physical column, so a custom column name was silently ignored — and the
96+
ADR-0062 D7 lint that rejected it on external objects was removed with it.
97+
`external.columnMap` (`remoteColumn → localField`) is the single, authoritative
98+
column mapping for a federated object.
9899
</Callout>
99100

100101
## 3. Auto-connect — no `onEnable` needed
@@ -217,6 +218,12 @@ secret in the secret store (the same `SecretBinder` / `ICryptoProvider` the
217218
runtime-admin "Add Datasource" wizard uses). The credential is resolved to
218219
cleartext **at connect, before the driver is built**.
219220

221+
The shipped binder encrypts the cleartext into a `sys_secret` row and mints an
222+
**opaque** handle — `sys_secret:<id>` — as the `credentialsRef`; that is the only
223+
form it resolves, so a hand-written path like `secret:warehouse/password` will
224+
not dereference. A host that wants a different scheme (a vault path, say)
225+
injects its own resolver via `new DatasourceAdminServicePlugin({ secrets })`.
226+
220227
Resolution is **fail-closed**: if a `credentialsRef` is declared but no secret store
221228
is configured, or the secret cannot be resolved/decrypted, the datasource is left
222229
**unconnected with a clear error** — never connected without the credential. An

content/docs/data-modeling/formulas.mdx

Lines changed: 45 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ Every expression in metadata is persisted as the same envelope:
3939
type Expression = {
4040
dialect: 'cel' | 'cron' | 'template';
4141
source?: string; // surface syntax
42-
ast?: unknown; // parsed AST (filled by `objectstack compile`)
42+
ast?: unknown; // parsed AST (filled by `os compile`)
4343
meta?: { rationale?: string; generatedBy?: string };
4444
};
4545
```
@@ -66,11 +66,11 @@ to relearn the syntax across surfaces.
6666
most common mistake (and the root cause of issue #1491) is a condition like
6767
`{record.rating} >= 4`: in CEL, `{…}` is a **map literal**, so it is a parse
6868
error. Write bare CEL: `record.rating >= 4`. Braces belong only in `{{ … }}`
69-
text templates. As of 7.6 a malformed expression no longer silently does
70-
nothing — it fails `objectstack build` and throws at runtime.
69+
text templates. A malformed expression does not silently do nothing — it fails
70+
`os build` and throws at runtime.
7171
</Callout>
7272

73-
**Template formatting (7.6).** A `template` hole is a field path with an optional
73+
**Template formatting.** A `template` hole is a field path with an optional
7474
whitelisted formatter — `{{ path | formatter[:arg] }}` — with defined value→string
7575
semantics (no arbitrary logic; put logic in a CEL field):
7676

@@ -121,9 +121,12 @@ evaluates (`objectql` computes a formula virtual field from its `expression`
121121
only). Do **not** pass `type: 'currency' | 'number' | …` to `Field.formula` — it
122122
is rejected by the typed `FieldInput` and, untyped, would override `type:'formula'`
123123
so the field silently never computes. To declare what the formula returns, set
124-
**`returnType`** (`'number' | 'text' | 'boolean' | 'date'`); it is inferred and
125-
stamped automatically by the AI build path, and consumers (dashboard measures,
126-
formatting) read it instead of re-parsing the expression. The CEL source is the
124+
**`returnType`** (`'number' | 'text' | 'boolean' | 'date'`). You declare it
125+
yourself — nothing back-fills it — but the agent-callable `validate_expression`
126+
tool reports the type it infers from the expression (`inferredType`) so an AI
127+
author can stamp the matching value. Consumers read the declared `returnType`
128+
instead of re-parsing the expression (record-title eligibility, for one: only a
129+
`returnType: 'text'` formula may be a `nameField`). The CEL source is the
127130
**`expression`** key — it is the only key the schema and runtime read for a
128131
formula's source; there is no separate `formula` source key.
129132
</Callout>
@@ -161,7 +164,7 @@ Cheat-sheet of what you'll write daily:
161164

162165
Registered automatically into every `Environment` by `@objectstack/formula`.
163166
All functions are pure given a pinned `now`, which is what makes
164-
`objectstack build` artifacts byte-stable across runs.
167+
`os build` artifacts byte-stable across runs.
165168

166169
| Function | Returns | Description |
167170
|:---|:---|:---|
@@ -197,20 +200,21 @@ Keep them pure, dependency-free, and AI-readable.
197200
| `record` | the row being evaluated | formulas, validation, sharing, visibility |
198201
| `previous` | row before update | hooks, validation on update |
199202
| `input` | hook payload | hooks |
200-
| `os.user` | install / request user | seed, predicates with identity |
203+
| `current_user` | the authenticated subject — the canonical binding (ADR-0068). `user`, `ctx.user` and `os.user` are aliases of the **same** object | predicates with identity |
204+
| `os.user` | alias of `current_user` | seed, predicates with identity |
201205
| `os.org` | active organization | seed, predicates |
202206
| `os.env` | install env (`prod`, `dev`, `test`) | seed, predicates |
203207

204208
---
205209

206210
## Build-time validation
207211

208-
`objectstack build` type-checks every expression against the object schema, so
212+
`os build` type-checks every expression against the object schema, so
209213
a mistake that would silently evaluate to `null` at runtime is caught before it
210214
ships. The same shared validator also runs when a flow is registered
211215
(`registerFlow`), so a flow authored dynamically gets the same verdict. Findings
212216
come at two severities: **errors** fail the build; **warnings** are advisory
213-
(surfaced, not fatal — `objectstack validate --strict` promotes them to errors).
217+
(surfaced, not fatal — `os validate --strict` promotes them to errors).
214218

215219
| Check | Example | Severity |
216220
|:---|:---|:---|
@@ -330,10 +334,19 @@ invert the test (here `amount <= 0` rejects non-positive amounts).
330334
object: 'case',
331335
events: ['afterUpdate'],
332336
condition: P`previous.status != 'escalated' && record.status == 'escalated'`,
333-
body: { language: 'js', source: 'await ctx.notifyOpsTeam(record)' },
337+
body: {
338+
language: 'js',
339+
source: "ctx.log.warn('case escalated', { id: ctx.input.id });",
340+
capabilities: ['log'],
341+
},
334342
}
335343
```
336344

345+
The `condition` is the CEL part — it binds `record` / `previous` directly. The
346+
JS `body` is a different surface: it is wrapped as `new AsyncFunction('ctx', source)`,
347+
so inside it the record is `ctx.input` (there is no bare `record`), and every
348+
`ctx` API it touches must be covered by a declared capability.
349+
337350
---
338351

339352
## Formula patterns
@@ -375,24 +388,32 @@ Field.formula({
375388
Field.formula({
376389
label: 'Days to Close',
377390
returnType: 'number',
378-
expression: 'daysBetween(record.close_date, today())',
391+
expression: 'daysBetween(today(), record.close_date)',
379392
})
380393

381394
// Contract end in 30 days
382395
Field.formula({
383396
label: 'Expiring Soon',
384397
returnType: 'boolean',
385-
expression: 'record.status == "active" && daysBetween(record.end_date, today()) <= 30',
398+
expression: 'record.status == "active" && daysBetween(today(), record.end_date) <= 30',
386399
})
387400

388401
// Age in days
389402
Field.formula({
390403
label: 'Age (Days)',
391404
returnType: 'number',
392-
expression: 'daysBetween(today(), record.created_date)',
405+
expression: 'daysBetween(record.created_date, today())',
393406
})
394407
```
395408

409+
<Callout type="warn">
410+
**Argument order matters.** `daysBetween(a, b)` is `b − a`, so it counts
411+
*forward from `a` to `b`* and goes negative when `b` is earlier. "Days
412+
remaining" is `daysBetween(today(), record.due_date)`; "age so far" is
413+
`daysBetween(record.created_date, today())`. Swapping the two operands is the
414+
easiest way to ship a formula whose sign is inverted on every row.
415+
</Callout>
416+
396417
<Callout type="info">
397418
**`dateField == today()` now matches (#3183).** A `date` field reads back as a
398419
`YYYY-MM-DD` string, and CEL treats a string and a timestamp as unequal — so the
@@ -479,18 +500,20 @@ Field.formula({
479500
**DON'T:**
480501
- Create circular references
481502
- Use formulas for frequently changing data
482-
- Nest too many IF statements
503+
- Nest ternaries so deeply the expression stops being readable
483504
- Ignore null handling
484505

485506
---
486507

487508
## Determinism contract
488509

489-
Two consecutive `objectstack build` runs with no source changes must produce
510+
Two consecutive `os build` runs with no source changes must produce
490511
**byte-identical** `dist/objectstack.json`. CEL plus pinned `now` is what
491512
makes this possible — there are zero compile-time-evaluated `Date.now()`
492-
calls in seed metadata. CI enforces this via SHA-1 comparison; if you add a
493-
new dialect or stdlib helper, ensure it preserves determinism.
513+
calls in seed metadata: a CEL seed value travels into the artifact as
514+
unevaluated source and only resolves at install. This is a design contract
515+
held by review, not a gate — no CI job currently diffs two builds — so if you
516+
add a new dialect or stdlib helper, ensure it preserves determinism.
494517

495518
---
496519

@@ -524,7 +547,7 @@ Salesforce semantics rewrite their formulas in CEL.
524547
```ts
525548
import { ExpressionEngine } from '@objectstack/formula';
526549

527-
// Compile + cache
550+
// Compile — parse + type-check, returning the engine-native AST as `value`
528551
const compiled = ExpressionEngine.compile({ dialect: 'cel', source: 'record.x * 2' });
529552

530553
// Evaluate
@@ -536,11 +559,11 @@ const result = ExpressionEngine.evaluate(
536559
```
537560

538561
The low-level engine never throws — `evaluate()` returns `{ ok: false, error }`.
539-
But **call sites must not silently swallow that** (ADR-0032): `objectstack build`
562+
But **call sites must not silently swallow that** (ADR-0032): `os build`
540563
**fails** on an invalid expression (with a located, schema-aware message), and at
541564
runtime the flow/rule engines **throw** a loud, attributed error instead of
542565
treating a bad expression as `false`/`null`. The same `validateExpression`
543-
validator backs `objectstack build` and metadata registration (and the
566+
validator backs `os build` and metadata registration (and the
544567
agent-callable `validate_expression` tool), so an expression is checked before it ships.
545568

546569
---

content/docs/deployment/migration-from-objectql.mdx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ description: Step-by-step guide for migrating from @objectql/core to @objectstac
55

66
# Migrating from `@objectql/core` to `@objectstack/objectql`
77

8-
Starting with `@objectstack/objectql` v3.1, all core functionality previously provided by `@objectql/core` is now available upstream. This guide walks you through migrating your project so that `@objectql/core` can be removed.
8+
The core functionality previously provided by `@objectql/core` — the introspection types, the utility functions, and the kernel factory — was upstreamed into `@objectstack/objectql` in **v3.0.4** and has shipped in every release since (the current release line is **v16.x**). This guide walks you through migrating your project so that `@objectql/core` can be removed.
99

1010
## Why Migrate?
1111

@@ -26,7 +26,7 @@ Starting with `@objectstack/objectql` v3.1, all core functionality previously pr
2626
"dependencies": {
2727
- "@objectql/core": "^4.x",
2828
- "@objectql/types": "^4.x",
29-
+ "@objectstack/objectql": "^3.1.0"
29+
+ "@objectstack/objectql": "^16.1.0"
3030
}
3131
}
3232
```

content/docs/deployment/vercel.mdx

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ description: Deploy ObjectStack applications to Vercel with the Hono server adap
55

66
# Deploy to Vercel
77

8-
ObjectStack 11 deploys to Vercel in **server mode** — serverless functions running the **Hono** adapter (`@objectstack/hono`). The published ObjectStack Studio/console ships as a static Vite SPA that points at a separate ObjectStack server (via `VITE_SERVER_URL`).
8+
ObjectStack deploys to Vercel in **server mode** — serverless functions running the **Hono** adapter (`@objectstack/hono`). The Console SPA can also be deployed on its own as a static Vite build that points at a separate ObjectStack server (via `VITE_SERVER_URL`) — but that build comes from the Console source repo ([objectui](https://github.com/objectstack-ai/objectui)), not from the prebuilt `@objectstack/console` package.
99

1010
<Callout type="warn">
1111
**Removed in 11.** The in-browser MSW mode (`@objectstack/plugin-msw`) and the Next.js adapter (`@objectstack/nextjs`) are no longer published — use the Hono server path below.
@@ -43,7 +43,7 @@ In Server mode, ObjectStack runs inside Vercel Serverless Functions. API request
4343
This is a valid self-contained pattern when you want to ship a Vite SPA *and* its API from a single Vercel project: the SPA is served as static assets, and a Hono-based serverless function handles `/api/*` requests.
4444

4545
<Callout type="info">
46-
The published ObjectStack Studio/console (`@objectstack/console`) does **not** use this topology. It deploys as a pure static SPA with no `api/` function — it is meant to be embedded in, or pointed at, a separate ObjectStack server via `VITE_SERVER_URL`. For that separate server, prefer the CLI (`objectstack serve`) over a hand-rolled Hono function.
46+
The framework-vendored Console (`@objectstack/console`) does **not** use this topology. It is a **prebuilt, dist-only** package — no source and no build step, just static assets — that an ObjectStack server mounts at `/_console` (`os serve --ui`, on by default). Its bundle is baked same-origin, and `VITE_SERVER_URL` is a build-time Vite flag, so aiming a Console at a *different* origin means building it yourself from the Console source repo ([objectui](https://github.com/objectstack-ai/objectui)). For the server itself, prefer the CLI (`os serve`) over a hand-rolled Hono function.
4747
</Callout>
4848

4949
**1. Create the kernel singleton** (`api/_kernel.ts` — prefixed with `_` to prevent Vercel from creating a route):
@@ -82,7 +82,10 @@ export async function ensureApp(): Promise<Hono> {
8282
`InMemoryDriver` keeps this snippet self-contained, but on serverless **all data
8383
is lost on every cold start** — fine for a demo, wrong for anything real. Before
8484
going past a proof of concept, swap the driver for a real database (see
85-
[Database Drivers](/docs/data-modeling/drivers)) and point `OS_DATABASE_URL` at it.
85+
[Database Drivers](/docs/data-modeling/drivers)). A hand-built kernel does **not**
86+
resolve `OS_DATABASE_URL` on its own — that precedence lives in the CLI and in
87+
`createStandaloneStack()` — so read the connection string yourself, e.g.
88+
`new SqlDriver({ client: 'pg', connection: process.env.OS_DATABASE_URL })`.
8689
</Callout>
8790

8891
**2. Create the API entrypoint** (`api/index.ts`):
@@ -134,7 +137,7 @@ Setting `VITE_SERVER_URL` to empty string tells the client SDK to use same-origi
134137

135138
Configure these in Vercel Project Settings → Environment Variables:
136139

137-
### Studio / SPA build
140+
### Console / SPA build
138141

139142
| Variable | Description |
140143
| :--- | :--- |
@@ -179,7 +182,7 @@ storage for artifacts.
179182
- [ ] `api/_kernel.ts` boots the kernel with the correct driver
180183
- [ ] `vercel.json` sets `VITE_USE_MOCK_SERVER=false` and `VITE_SERVER_URL=` (empty)
181184
- [ ] Rewrite rule routes `/api/*` to `/api` and excludes `/api/` from SPA rewrite
182-
- [ ] `OS_DATABASE_URL` is configured in Vercel environment variables (for production drivers)
185+
- [ ] The production connection string is set in Vercel environment variables **and** read explicitly by the driver you construct in `api/_kernel.ts` (`OS_DATABASE_URL` is resolved by the CLI / `createStandaloneStack()`, not by a hand-built kernel)
183186
- [ ] CORS is configured if frontend and API are on different origins
184187

185188
---

0 commit comments

Comments
 (0)