Skip to content
Merged
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
10 changes: 10 additions & 0 deletions .changeset/28819-save-notice-discoverable.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
'@objectstack/skills': patch
---

docs(skills): the objectstack-data skill now leads a hook author to the save notice

Clause-②: no
Metadata-protocol: none

The `objectstack-data` rule index described `rules/lifecycle.md` as retention-only, and `references/data-hooks.md` listed no `notice` member, so an author writing a save hook from this skill never found `ctx.notice(message)`. The index entry now names the save notice, and the sandbox `ctx.*` table and the `HookContext` listing each carry a `notice` row that points at `rules/lifecycle.md` § Save Notice. Growth is paid by same-file deletions of restated prose; both token-ratchet rows are re-locked down at the landed counts.
14 changes: 12 additions & 2 deletions scripts/check-skills-token-ratchet.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -510,7 +510,10 @@ export const CEILINGS = new Map([
// shrank to 689 characters (card item 3). One pointer line per moved block.
// 6128 -> 6123: A2′ — the release-stamp line left the count (see
// STAMP_LINE_REPIN); the file's count fell by the same 5, headroom unchanged.
['skills/objectstack-data/SKILL.md', 6123],
// 6123 -> 6078: re-locked at the landed count. The rule index's `rules/lifecycle.md`
// entry names the save notice (+16); the `lifecycle` property row dropped the
// bounding-policy clause that entry and `rules/lifecycle.md` already carry (-25).
['skills/objectstack-data/SKILL.md', 6078],
['skills/objectstack-formula/SKILL.md', 5997], // -53 (was 6055), then -5 A2′ (was 6002)
['skills/objectstack-i18n/SKILL.md', 6333], // -11 (was 6349), then -5 A2′ (was 6338)
// 12705 -> 12984 (2026-08-31 app-repo-principles raise, see the block above).
Expand Down Expand Up @@ -678,7 +681,14 @@ export const CEILINGS = new Map([
// headings); the row carried 2,727 tokens of headroom left by an earlier
// shrink that nobody re-locked, so the TOC needed no raise. Pinned AT the
// measurement — zero headroom, the ratchet's steady state.
['skills/objectstack-data/references/data-hooks.md', 10066],
// 10066 -> 10011: re-locked at the landed count (main measured 10056; the
// move is -45). A `ctx.notice` row in the sandbox table and a `notice?` member
// in the HookContext listing, paid by same-file restatements: the
// reverse-engineering aside in the `body` intro, the `tenantId` blockquote
// (its two halves live in the listing comment and the two-axes note, which
// now names the two `tenantId` symbols), the `ctx.user` paragraph under the
// `??` line that shows it, and the `session.roles` history clause.
['skills/objectstack-data/references/data-hooks.md', 10011],
['skills/objectstack-data/rules/datasources.md', 911],
// -552 (was 3584): re-locked at the landed count (#16374) after PR #16187
// (the two `tree` rows) and PR #16369 (the ASCII decision tree deleted)
Expand Down
4 changes: 2 additions & 2 deletions skills/objectstack-data/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ Detailed rules, with incorrect/correct examples:
- **[Relationships](./rules/relationships.md)** — lookup vs master_detail, junction patterns, delete behaviors
- **[Validation Rules](./rules/validation.md)** — All validation types, script inversion, severity levels
- **[Index Strategy](./rules/indexing.md)** — btree/gin/gist/fulltext, composite indexes, partial indexes
- **[Data Lifecycle & Retention](./rules/lifecycle.md)** — `lifecycle` classes (record/audit/telemetry/transient/event), retention/TTL/rotation/archive policies; ❗ append-only objects must declare one (distinct from lifecycle *hooks* below)
- **[Data Lifecycle & Retention](./rules/lifecycle.md)** — `lifecycle` classes (record/audit/telemetry/transient/event), retention/TTL/rotation/archive policies; ❗ append-only objects must declare one (distinct from lifecycle *hooks* below); plus the **save notice** a hook hands the saver (`ctx.notice`)
- **[Lifecycle Hooks](./references/data-hooks.md)** — the lifecycle events, `handler` vs sandboxed `body` (ctx + capability contract), registration, canonical patterns
- **[Datasources & Federation](./rules/datasources.md)** — `defineDatasource`, external/federated objects (`remoteName`/`columnMap`), auto-connect gating, credentials; ❌ no `field.columnName` on external objects
- **[Security & Access Control](./rules/security.md)** — permission sets, assignment rows, RLS policies, `secret` / `requiredPermissions`, `tenancy`, platform-global posture
Expand Down Expand Up @@ -90,7 +90,7 @@ the publish door rejects it before that:
| `titleFormat` | — | **Refused (ADR-0079)**: a save and `os validate` refuse it (`title-format-retired`). Move it to `nameField`; a composite title follows the `nameField` row |
| `enable` | — | Capability flags (trackHistory, searchable, apiEnabled, etc.) |
| `fieldGroups` | — | Ordered list of logical field groups for forms/detail pages (see [Field Groups](./references/examples-objects.md#field-groups-mvp)) |
| `lifecycle` | `record` semantics (permanent) | Data retention/rotation/archival contract. **Required for append-only, high-write-rate objects** — a `telemetry`/`transient`/`event`/`audit` class must declare a bounding policy or parsing fails (see [Data Lifecycle & Retention](./rules/lifecycle.md)) |
| `lifecycle` | `record` semantics (permanent) | Data retention/rotation/archival contract. **Required for append-only, high-write-rate objects** (see [Data Lifecycle & Retention](./rules/lifecycle.md)) |

### Object Capabilities (`enable`)

Expand Down
19 changes: 6 additions & 13 deletions skills/objectstack-data/references/data-hooks.md
Original file line number Diff line number Diff line change
Expand Up @@ -310,9 +310,7 @@ onError: 'log'

A hook can carry its logic in one of two shapes. Everything above this point
showed the inline **`handler`** function; the section below documents the
**`body`** form — the one a metadata-only runtime actually executes, and the one
that was previously undocumented (you had to reverse-engineer
`@objectstack/runtime` to use it).
**`body`** form — the one a metadata-only runtime actually executes.

### `body` (sandboxed) vs `handler` (inline) — pick one

Expand Down Expand Up @@ -360,12 +358,13 @@ The sandbox is handed a **JSON snapshot** of these (built by
| `ctx.previous` | object \| `undefined` | Pre-write record on update/delete. **`undefined` on insert** → use `!ctx.previous` to detect *create*. |
| `ctx.result` | object \| `undefined` | `after*` only. ⚠️ **partial** on afterUpdate — see gotcha 1. |
| `ctx.user` | object \| `undefined` | `{ id, name, email, organizationId }`. `undefined` for system / unauthenticated writes. |
| `ctx.session` | object \| `undefined` | `{ userId, organizationId, isSystem, … }`. **No role list** — `session.roles` was retired in 17.0.0: it was declared but never produced, so every read was `undefined`. |
| `ctx.session` | object \| `undefined` | `{ userId, organizationId, isSystem, … }`. **No role list** — `session.roles` was retired in 17.0.0 (declared, never produced). |
| `ctx.event` | string | e.g. `'afterUpdate'` — dispatch on it when one hook subscribes to several events. |
| `ctx.object` | string | The target object name. |
| `ctx.api` | object | Cross-object CRUD. Gated by `api.read` / `api.write` — see below. |
| `ctx.log` | `{ debug, info, warn, error }` | Gated by `log`. Call **`ctx.log.info(msg, data?)`** — `ctx.log` is an **object, not** callable as `ctx.log(msg)`. Emission is **best-effort** (see Troubleshooting). |
| `ctx.crypto` | `{ randomUUID }` | Gated by `crypto.uuid`. |
| `ctx.notice` | `(message) => void` | **Tell the saver what this save set in motion** — answered as the create/update `notice`, shown on the save toast. No capability; insert/update only; last call wins. [`rules/lifecycle.md` § Save Notice](../rules/lifecycle.md#save-notice). |
| `ctx.title` | `(field?) => Promise<string \| null>` | **Name the record, not its id.** `ctx.title()` resolves this object's `nameField`, a **formula** included (no extra read); `ctx.title('account_id')` resolves a lookup's title (needs `api.read`). `null` when there is no title, never the id. **Per door:** `body` has it; a deprecated `handler` gets it at runtime, undeclared on `HookContext` (TS2339 when annotated) — use `body`; `ql.registerHook` and a bare `ObjectQL` `bindHooks` lack it. |

(Action bodies additionally receive `ctx.recordId` and `ctx.record`, and their
Expand Down Expand Up @@ -599,6 +598,7 @@ interface HookContext {
// Engine access
ql: IDataEngine; // ObjectQL engine instance
api?: ScopedContext; // Cross-object CRUD API
notice?: (message: string) => void; // Save notice: rules/lifecycle.md § Save Notice

// User info shortcut (undefined for system / unauthenticated writes)
user?: {
Expand All @@ -622,14 +622,6 @@ predicates, and in seed rows. Read it as **`organizationId`**:
const org = ctx.user?.organizationId ?? ctx.session?.organizationId;
```

> The former `ctx.session.tenantId` alias was removed in v16 — read the
> org under `organizationId`. (The generic driver-layer `execCtx.tenantId` /
> `DriverOptions.tenantId` isolation knob is a separate axis and is unaffected.)

`ctx.user` is the ergonomic shortcut for an authenticated caller; it is
`undefined` for system / unauthenticated writes, so read `ctx.session?.organizationId`
when a hook must work regardless of whether a user resolved.

> **Two isolation axes — don't conflate them.** `organization_id` is
> **org row-scoping**: many organizations share one database and every row
> carries its owning org (`current_user.organizationId` filters reads/writes;
Expand All @@ -638,7 +630,8 @@ when a hook must work regardless of whether a user resolved.
> ADR-0132). That is different from
> **environment / database-per-tenant** isolation (`service-tenant`,
> `driver-turso`), where "tenant" means an entire environment/database and the
> generic driver-layer `tenantId` knob can carry that environment id. The
> generic driver-layer `tenantId` knob (`execCtx.tenantId` / `DriverOptions.tenantId`)
> can carry that environment id. The
> object-metadata `tenancy.*` knob configures the *mechanism* (isolation on/off
> + which column); the *value* you read and write is your `organization_id`
> column. Under `single` the bootstrapped Default Organization is the only
Expand Down
Loading