diff --git a/.changeset/28819-save-notice-discoverable.md b/.changeset/28819-save-notice-discoverable.md new file mode 100644 index 00000000000..9081b4c7e51 --- /dev/null +++ b/.changeset/28819-save-notice-discoverable.md @@ -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. diff --git a/scripts/check-skills-token-ratchet.mjs b/scripts/check-skills-token-ratchet.mjs index 2d2a9b19146..44f3ff8fcfe 100644 --- a/scripts/check-skills-token-ratchet.mjs +++ b/scripts/check-skills-token-ratchet.mjs @@ -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). @@ -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) diff --git a/skills/objectstack-data/SKILL.md b/skills/objectstack-data/SKILL.md index bc3cdbcfa9d..ccadff8e984 100644 --- a/skills/objectstack-data/SKILL.md +++ b/skills/objectstack-data/SKILL.md @@ -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 @@ -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`) diff --git a/skills/objectstack-data/references/data-hooks.md b/skills/objectstack-data/references/data-hooks.md index bc07085d25f..73bfb04581f 100644 --- a/skills/objectstack-data/references/data-hooks.md +++ b/skills/objectstack-data/references/data-hooks.md @@ -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 @@ -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` | **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 @@ -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?: { @@ -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; @@ -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