Skip to main content

add-singleton-resource

Add support for a PostHog resource that exists exactly once per project (project settings, organization billing config, plugin enablement flags, …). Singletons do not fit the tag-based identity model — they have no collection to filter, no key to disambiguate, and no orphans to track. This skill is the playbook for the *other* shape: one declarative block, field-level diff, PATCH-only execution. Use when the user wants to manage a resource where the project itself is the only "row".

Source facts

Repository
PostHog/posthog-definitions
Last source activity
May 14, 2026 at 03:11
Detected SKILL.md language
English
Stars
15
Forks
3

Install options

The review-first prompt is selected by default. You can switch to a direct command or download a local copy.

Review the source files

Read SKILL.md and any companion files shown by SkillsMP before deciding whether to install.

Showing SKILL.md

SKILL.md
Source instructions · Read-only preview
name
add-singleton-resource
description
Add support for a PostHog resource that exists exactly once per project (project settings, organization billing config, plugin enablement flags, …). Singletons do not fit the tag-based identity model — they have no collection to filter, no key to disambiguate, and no orphans to track. This skill is the playbook for the *other* shape: one declarative block, field-level diff, PATCH-only execution. Use when the user wants to manage a resource where the project itself is the only "row".
# Adding a singleton resource to posthog-definitions This skill is the playbook for resources that exist exactly **once per project** — there's a single row and no collection, so the identity, safety, and lifecycle story is fundamentally different from `add-resource`. Examples: project settings (`/api/projects/<id>/`), billing config, default-event property allowlists, anything keyed by the project itself. ## When to use this skill vs `add-resource` | Signal | This skill | `add-resource` | | --------------------------- | ----------------------------------------------- | ------------------------------------- | | API endpoint | `PATCH /api/projects/<id>/` (no sub-collection) | `POST /api/projects/<id>/<resource>/` | | Number of rows per project | exactly 1 | 0..N | | User declares | one block (`projectSettings({...})`) | many blocks across many files | | Has a writable `tags` field | usually no | yes (required) | | Can be deleted | no — always exists | yes | If you're unsure: list the API. If you GET a single object (not a paginated list of objects), it's a singleton. If you'd need a `key` field on the spec to tell two of them apart in code, it's not. ## Before you touch anything Re-read the same three architecture docs as `add-resource`: 1. `docs/implementation/architecture.md` 2. `docs/implementation/identity.md` — note the gaps: this skill replaces the tag identity model. 3. `docs/implementation/apply.md` — note the gaps: this skill replaces the create/update/orphan/prune lifecycle with PATCH-only. Then re-read the legacy insight pair (`src/sdk/insight.ts`, `src/client/insights.ts`, the insight-relevant blocks in `src/apply/{diff,execute,serialize,validate}.ts`) — not because singletons follow that shape, but because the pipeline plumbing is the same shape you'll register against. ## The safety story (no tags — fundamentally different) Collection resources protect hand-built rows with a tag invariant: _if it doesn't have my tag, I don't touch it._ That mechanism is unavailable here — the project always exists, and we always have to write to it. Singletons instead enforce a **field-scoping invariant:** > **A singleton apply only ever writes the fields the user explicitly declared.** The PATCH body must contain only the keys present in the user's spec. Fields the user did not mention must not appear in the request payload, even with `undefined` or `null` values — the request shape itself is the safety contract. PostHog's `PATCH` is a true partial update, so an absent key means "leave alone." Concretely, this means: - The spec accepts a `Partial<Settings>` rather than a `Settings`. - The diff phase enumerates only the keys present in the spec — never the union of spec ∪ server. - The execute phase serializes only those same keys. - If the user undeclares a previously-declared field, the field is **abandoned**, not reset. The server value persists. This is intentional: there is no "previously applied" state to recover from, and the safety invariant outranks reset semantics. State this contract in the user-facing JSDoc on the factory. Users who want to reset a field must explicitly set it to the desired value — there is no "remove" verb. ## Directory layout Singletons use the same per-resource directory pattern as collections, with fewer files: ``` src/resources/<singleton>/ ├── sdk.ts user-facing factory + the spec type ├── client.ts ServerXSchema (Zod) + get/patch wrappers ├── pipeline.ts validate, diff (field-level), execute (PATCH), format ├── pipeline.test.ts field-diff selection, unchanged-when-equal, untouched-fields invariant ├── client.test.ts Zod schema parses a real fixture (only if schema is non-trivial) └── index.ts public surface + registration object ``` Note: there is no `serialize` step in the same sense — payload construction is "spread the declared keys" — but it's tiny, so it goes in `pipeline.ts` rather than its own file. | # | File | What it contains | | --- | --------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ | | 1 | `src/resources/<singleton>/sdk.ts` | The `XSettings` (or similar) user-facing type and the `xSettings(spec): XSettings` factory. The spec type is a `Partial` over the full configurable surface. | | 2 | `src/resources/<singleton>/client.ts` | `ServerXSchema` (Zod) + `getX(config)` and `patchX(config, partial)` wrappers. No `list*` — there's nothing to list. | | 3 | `src/resources/<singleton>/pipeline.ts` | `validateX(spec)`, `XDiff` shape (per-field), `diffX(spec, server)`, `runXOp(config, op)`, `renderXOp(op)`, `xPayload(spec)`. | | 4 | `src/resources/<singleton>/index.ts` | Re-export `xSettings` and `XSettings`; export an `xResource` registration object. | | 5 | `src/resources/index.ts` | Add `xResource` to the `RESOURCES` array. | | 6 | `src/index.ts` | Re-export `xSettings` and `XSettings`. | ### Identity (or rather, the lack of it) A singleton has no key, no slug, no tag. Its identity is the project itself, addressed by `config.projectId`. There is no list filter, no `iac:*` namespace, and no orphan concept. Plural slug picking, hash-in-tag, `iac:hash:<hex>` — none of these apply. ### API response validation with Zod Same rules as `add-resource` — every response goes through a Zod schema with `.passthrough()` on the root and strict typing on the fields posthog-definitions actually reads. Singletons typically have **many** configurable fields (project settings can have ~50). Schema only the ones the user can declare via the factory; let `.passthrough()` carry the rest. ```ts import { z } from "zod"; export const ServerXSchema = z .object({ id: z.number(), name: z.string(), timezone: z.string(), week_start_day: z.number().int().min(0).max(6), autocapture_opt_out: z.boolean().nullable(), // … only the declarable fields }) .passthrough(); export type ServerX = z.infer<typeof ServerXSchema>; ``` If a declarable field is `null`-on-the-wire, mirror that with `.nullable()`. The factory type may still expose it as `boolean | undefined` (user-side undeclared is different from server-side null) — see "Nullability mapping" below. ### Nullability mapping Three states need to stay distinct: | Where | State | Meaning | | --------------- | -------------------------------------------- | ----------------------------------------------------------------------- | | User spec | field absent (`undefined`) | "I do not manage this." Field is excluded from PATCH and from the diff. | | User spec | field present with a value, including `null` | "Set the server field to this value." Included in PATCH and diff. | | Server response | `null` | The field is unset on the server. | The factory accepts `field?: T \| null`. The diff treats `undefined` as "skip this field entirely" and `null` as "set to null." Document this in the factory JSDoc — it's the trickiest bit of the surface. ## Diff (field-level, not row-level) `diffX` returns a list of per-field operations: ```ts type XFieldChange = { field: keyof XSettings; before: unknown; after: unknown; }; type XDiff = { kind: "unchanged" } | { kind: "patch"; changes: XFieldChange[] }; ``` The diff routine: 1. Iterate the keys present in the spec (use `Object.keys(spec)` — only declared keys, never the server keys). 2. For each declared key `k`, compare `spec[k]` to `server[k]`. 3. Equality: deep equal. Use a structural comparison that mirrors `specHash`'s canonicalization — sorted keys for objects, exact array order. Reuse `specHash` to compute per-value hashes if the value is a non-primitive; for primitives, `===` is fine. 4. If any field differs, return `{ kind: "patch", changes }`. Otherwise `{ kind: "unchanged" }`. There is **no `iac:hash:*` tag**. The server's current value is the source of truth and we always GET it before diffing. Hashing is a per-field comparison aid, not a stored marker. ## Validation In `pipeline.ts`, export `validateX(spec): string[]`. The validator runs purely on the declared fields. Cover at minimum: - Each declared field passes its own per-field constraint (timezone is a known string, ratios are 0..1, etc.). - Cross-field invariants (if any) hold _only when both fields are declared_ — never reject because an undeclared field is "missing." That would violate the field-scoping invariant. Push every error into the returned array — the generic pipeline driver aggregates and throws `ValidationError` once. ## Plan rendering `renderXOp(op)` formats the singleton like this: ``` Project settings timezone: "UTC" → "Europe/London" week_start_day: 0 → 1 (2 fields changed) ``` When `op.kind === "unchanged"`, render a single line: ``` Project settings (unchanged) ``` There is no per-row "create / update / orphan" breakdown to surface in the summary line. Treat the singleton as a single update in the summary counts. ## Registration Singletons register the same way as collections, but the registration object's hooks reflect the different lifecycle: ```ts // src/resources/<singleton>/index.ts import { xSettings, type XSettings } from "./sdk.js"; import { getX, patchX } from "./client.js"; import { validateX, diffX, runXOp, renderXOp } from "./pipeline.js"; export { xSettings, type XSettings }; export const xResource = { name: "<singleton>", kind: "singleton", // ← distinguishes from "collection" fetch: getX, // ← single GET, not a list diff: diffX, // ← takes (spec, server), returns XDiff execute: runXOp, // ← PATCH with declared fields only validate: validateX, render: renderXOp, } as const; ``` The generic driver branches on `kind`: collections iterate, singletons fetch once. The CLI does not change. If the generic driver in this codebase does not yet support `kind: "singleton"`, you need to add that branch — the existing pipeline assumes collection lifecycle. Look at `src/apply/{load,diff,execute,format-plan}.ts` and add a parallel path that: 1. Loads at most one declared spec of the singleton's type. 2. Fetches one server object. 3. Calls `diff(spec, server) → XDiff`. 4. Calls `execute` if `kind === "patch"`. Loader: a singleton's user-facing factory should be detectable by a `kind: "singleton"` discriminator on the returned object (mirror `looksLikeDashboard` / `looksLikeInsight` in `src/apply/load.ts`). If multiple files default-export the same singleton type, that's a user error — reject with a clear message, do not merge. ## Unit tests (required) The pipeline's pure functions are testable with no mocking. Cover at minimum, in `src/resources/<singleton>/pipeline.test.ts`: 1. **Unchanged when declared fields match.** Spec declares `{ timezone: "UTC" }`, server returns `{ timezone: "UTC", week_start_day: 0, autocapture_opt_out: null, … }` → `kind: "unchanged"`. 2. **Patch when a declared field differs.** Spec declares `{ timezone: "UTC" }`, server returns `{ timezone: "Europe/London", … }` → `kind: "patch"` with `[{ field: "timezone", before: "Europe/London", after: "UTC" }]`. 3. **Undeclared fields are never patched.** Spec declares `{ timezone: "UTC" }`, server returns `{ timezone: "UTC", week_start_day: 6 }` → `kind: "unchanged"`. The fact that `week_start_day` is 6 (and the server's default is 0) does not produce a change. 4. **`null` and `undefined` are distinct.** Spec declares `{ autocapture_opt_out: null }`, server returns `{ autocapture_opt_out: true }` → `kind: "patch"`. Spec omits `autocapture_opt_out` entirely, server returns `{ autocapture_opt_out: true }` → no change for that field. 5. **Validation rejects invalid declared values.** A declared field with an out-of-range value produces a validation issue. Reuse the 500ms test budget. Build the server fixture with a `serverX(overrides)` factory. If the Zod schema is non-trivial, add `src/resources/<singleton>/client.test.ts` with the same two cases as the collection skill: real fixture parses, fixture with required-field missing throws. Fixtures should come from a real `curl` against PostHog, not be invented. ## Verification (manual, end-to-end) Same shape as the collection flow, adapted: 1. `pnpm typecheck` — must pass. 2. Add an example file under `examples/` exercising the singleton. 3. `npx posthog-definitions apply --dry-run` against the dev project. Inspect the plan: declared fields appear in the diff, undeclared fields do not. 4. `npx posthog-definitions apply`. Confirm the targeted fields changed in the UI; confirm no other settings drifted. 5. Re-apply with no spec changes. The plan must show `(unchanged)`. **If anything is listed as a field change on a no-op apply, the equality check is wrong** — fix it before moving on.
View on GitHub
This SKILL.md is very large, so SkillsMP previews the first section here. View on GitHub