| 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:
docs/implementation/architecture.md
docs/implementation/identity.md — note the gaps: this skill replaces the tag identity model.
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.
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(),
})
.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:
type XFieldChange = {
field: keyof XSettings;
before: unknown;
after: unknown;
};
type XDiff = { kind: "unchanged" } | { kind: "patch"; changes: XFieldChange[] };
The diff routine:
- Iterate the keys present in the spec (use
Object.keys(spec) — only declared keys, never the server keys).
- For each declared key
k, compare spec[k] to server[k].
- 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.
- 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:
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",
fetch: getX,
diff: diffX,
execute: runXOp,
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:
- Loads at most one declared spec of the singleton's type.
- Fetches one server object.
- Calls
diff(spec, server) → XDiff.
- 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:
- Unchanged when declared fields match. Spec declares
{ timezone: "UTC" }, server returns { timezone: "UTC", week_start_day: 0, autocapture_opt_out: null, … } → kind: "unchanged".
- 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" }].
- 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.
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.
- 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:
pnpm typecheck — must pass.
- Add an example file under
examples/ exercising the singleton.
npx posthog-definitions apply --dry-run against the dev project. Inspect the plan: declared fields appear in the diff, undeclared fields do not.
npx posthog-definitions apply. Confirm the targeted fields changed in the UI; confirm no other settings drifted.
- 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.
- Edit one declared field, re-apply. Confirm only that one field appears in the PATCH (use
--verbose to inspect the request body).
- Remove a previously-declared field from the spec, re-apply. Confirm the plan is
(unchanged) — the server value persists. This is the abandoned-field smoke test.
- Manually change an undeclared setting in the UI, re-apply. The CLI must leave it alone. This is the field-scoping invariant smoke test.
Steps 7 and 8 are the singleton equivalent of the safety invariant. Do not skip them.
Update the support matrix
Flip the singleton's row from ❌ to ✅ in docs/resources.md. Note in the entry that it is a singleton — users coming from feature flags or cohorts will expect a key.
Update the README
The top-level README.md lists supported resources in its intro sentence and the required API scopes in the "Authenticate" step. Add the singleton to the intro list and append its read/write scope(s) to the bullet list.
Deletes / prune
Singletons cannot be deleted. --prune is a no-op for them. The registration object has no prune hook.
Out-of-scope (do not bundle in)
Same as the collection skill — multi-environment, state files, dev command — plus one singleton-specific item:
- Auto-reset. "When the user undeclares a field, reset it to PostHog's default." This requires either (a) knowing every field's default (brittle), or (b) keeping a "previously applied" record (a state file). MVP punts and persists. Raise it with the user if it comes up; it's not a free addition.