kalup 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -6,7 +6,9 @@
6
6
  "type": "object",
7
7
  "required": ["format", "lineage", "serial", "portalId", "resources"],
8
8
  "properties": {
9
- "format": { "const": "kalup.state/1" },
9
+ "format": {
10
+ "const": "kalup.state/1"
11
+ },
10
12
  "lineage": {
11
13
  "description": "16 lowercase hex characters, new on rebuild and rebind. A plan made against another lineage is refused.",
12
14
  "type": "string",
@@ -26,17 +28,33 @@
26
28
  "type": "object",
27
29
  "required": ["planId", "writesHash", "actor", "at", "outcome"],
28
30
  "properties": {
29
- "planId": { "type": "string", "pattern": "^pl_[0-9a-f]{12}$" },
30
- "writesHash": { "type": "string", "pattern": "^sha256:[0-9a-f]{64}$" },
31
- "actor": { "type": "string" },
32
- "at": { "type": "string" },
33
- "outcome": { "enum": ["running", "done", "partial", "uncertain"] }
31
+ "planId": {
32
+ "type": "string",
33
+ "pattern": "^pl_[0-9a-f]{12}$"
34
+ },
35
+ "writesHash": {
36
+ "type": "string",
37
+ "pattern": "^sha256:[0-9a-f]{64}$"
38
+ },
39
+ "actor": {
40
+ "type": "string"
41
+ },
42
+ "at": {
43
+ "type": "string"
44
+ },
45
+ "outcome": {
46
+ "enum": ["running", "done", "partial", "uncertain"]
47
+ }
34
48
  },
35
49
  "additionalProperties": false
36
50
  },
37
51
  "resources": {
38
52
  "type": "object",
39
- "patternProperties": { "^[a-z]+:\\S+$": { "$ref": "#/$defs/resourceState" } },
53
+ "patternProperties": {
54
+ "^[a-z]+:\\S+$": {
55
+ "$ref": "#/$defs/resourceState"
56
+ }
57
+ },
40
58
  "additionalProperties": false
41
59
  }
42
60
  },
@@ -54,14 +72,32 @@
54
72
  "description": "The portal name the entry owns. It owns the resource only while this equals the name the address resolves to. null for runbook-only types.",
55
73
  "type": ["string", "null"]
56
74
  },
57
- "via": { "description": "Transport of the last write.", "type": "string" },
58
- "normVersion": { "description": "Normalizer version of the type that wrote base.", "type": "integer" },
59
- "base": { "$ref": "#/$defs/base" },
60
- "baseHash": { "description": "Replaces base for opaque payloads and runbook types.", "type": "string" },
75
+ "via": {
76
+ "description": "Transport of the last write.",
77
+ "type": "string"
78
+ },
79
+ "normVersion": {
80
+ "description": "Normalizer version of the type that wrote base.",
81
+ "type": "integer"
82
+ },
83
+ "base": {
84
+ "$ref": "#/$defs/base"
85
+ },
86
+ "baseHash": {
87
+ "description": "Replaces base for opaque payloads and runbook types.",
88
+ "type": "string"
89
+ },
61
90
  "attested": {
62
91
  "type": "object",
63
92
  "required": ["by", "at"],
64
- "properties": { "by": { "type": "string" }, "at": { "type": "string" } },
93
+ "properties": {
94
+ "by": {
95
+ "type": "string"
96
+ },
97
+ "at": {
98
+ "type": "string"
99
+ }
100
+ },
65
101
  "additionalProperties": false
66
102
  },
67
103
  "rewrites": {
@@ -70,9 +106,19 @@
70
106
  "additionalProperties": {
71
107
  "type": "object",
72
108
  "required": ["sent", "stored"],
73
- "properties": { "sent": {}, "stored": {} },
109
+ "properties": {
110
+ "sent": {},
111
+ "stored": {}
112
+ },
74
113
  "additionalProperties": false
75
114
  }
115
+ },
116
+ "typeIds": {
117
+ "description": "An association's HubSpot type IDs in this portal, its direction's first: they name it while HubSpot's schema read does not list its name yet.",
118
+ "type": "array",
119
+ "items": {
120
+ "type": "integer"
121
+ }
76
122
  }
77
123
  },
78
124
  "additionalProperties": false
@@ -81,16 +127,32 @@
81
127
  "description": "Per owned unit, the value config and portal last agreed on; possibly partial. Scalar units by field name. options: a map keyed by option value whose members hold the agreed label, hidden and description; a member with no fields means only its membership is agreed. optionsOrder: the agreed order of the members both sides held.",
82
128
  "type": "object",
83
129
  "properties": {
84
- "options": { "type": "object", "additionalProperties": { "$ref": "#/$defs/baseOption" } },
85
- "optionsOrder": { "type": "array", "items": { "type": "string" } }
130
+ "options": {
131
+ "type": "object",
132
+ "additionalProperties": {
133
+ "$ref": "#/$defs/baseOption"
134
+ }
135
+ },
136
+ "optionsOrder": {
137
+ "type": "array",
138
+ "items": {
139
+ "type": "string"
140
+ }
141
+ }
86
142
  }
87
143
  },
88
144
  "baseOption": {
89
145
  "type": "object",
90
146
  "properties": {
91
- "label": { "type": "string" },
92
- "hidden": { "type": "boolean" },
93
- "description": { "type": "string" }
147
+ "label": {
148
+ "type": "string"
149
+ },
150
+ "hidden": {
151
+ "type": "boolean"
152
+ },
153
+ "description": {
154
+ "type": "string"
155
+ }
94
156
  },
95
157
  "additionalProperties": false
96
158
  }
package/docs/apply.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # Apply
2
2
 
3
- `kalup apply <plan-file> [--yes | --approve <writesHash>]` applies a plan saved by `kalup plan --out` to the target it names. `kalup apply [--target <name>] [--take config <selector>] [--yes]` plans the target now and applies that plan through the same checks: at a terminal it prints the whole plan first, as `plan` does, then asks. Apply writes property groups and properties, on custom objects too, and never a custom object schema.
3
+ `kalup apply <plan-file> [--yes | --approve <writesHash>]` applies a plan saved by `kalup plan --out` to the target it names. `kalup apply [--target <name>] [--take config <selector>] [--yes]` plans the target now and applies that plan through the same checks: at a terminal it prints the whole plan first, as `plan` does, then asks. Apply writes custom objects, property groups, properties, pipelines, stages and associations.
4
4
 
5
5
  This page is the reference. For the walk-through with examples, see [kalup apply](https://kalup.dev/docs/commands/apply) on the website.
6
6
 
@@ -25,23 +25,26 @@ Before any write, in order:
25
25
  3. The write key (`credentials.write`, else the read key) passes the portal guard. Every request, reads included, uses it.
26
26
  4. The policy equals the plan's: `protected`, `drift`, `adopt`, `allowDestroy`, `yesLimit` and the objects in takeover (`E_POLICY_CHANGED`); each step's API version is current and unexpired, and the normalizer versions match (`E_PLAN_VERSION`).
27
27
  5. Every step but a release is on an object `kalup.config.ts` declares, the name bindings match the target's name overrides, and no two steps but releases resolve to one portal resource (`E_BINDING_CHANGED`).
28
- 6. Each delete has a `destroy` tombstone in `hubspot/removed.ts` or takeover's leave (takeover mode, in the pull scope, not excluded), is gone from config, and no address in config names its portal resource, read as data (`E_PLAN_DELETE`). The object files also tell takeover's option removals from config's own.
28
+ 6. Each delete has a `destroy` tombstone in `hubspot/removed.ts` or takeover's leave (takeover mode, in the pull scope, not excluded), a delete labelled `takeover` has takeover's leave whatever `hubspot/removed.ts` says, is gone from config, and no address in config names its portal resource, read as data (`E_PLAN_DELETE`). The object files also tell takeover's option removals from config's own.
29
29
  7. Approval, then the portal lock (`E_LOCKED`).
30
30
  8. State: a plan already applied with outcome `done` exits 0 ("Already applied"); otherwise lineage and serial equal the plan's (`E_STATE_CHANGED`).
31
- 9. A fresh read of each object the plan changes, its pipelines when a step touches one, and of the schemas list for a custom object (`E_INCOMPLETE` on a 403, `E_BINDING_CHANGED` for another type ID). Every `expect` must hold (`E_PLAN_STALE`), a delete's covering each field its base holds. Kalup derives each step's risk, labels and blocked status again (`E_PLAN_RISK` when the plan states less); a takeover removal needs `allowDestroy`, and never takes a HubSpot-defined property.
31
+ 9. A fresh read of each object the plan changes, its pipelines when a step touches one, and of the schemas list for a custom object (`E_INCOMPLETE` on a 403, `E_BINDING_CHANGED` for another type ID). Every `expect` must hold (`E_PLAN_STALE`), a delete's covering each field its base holds. Kalup derives each step's risk, labels and blocked status again (`E_PLAN_RISK` when the plan states less); a takeover removal needs `allowDestroy`, and never takes a HubSpot-defined property; a delete labelled `takeover` is a property or group on an object in takeover mode that `hubspot/removed.ts` neither names nor covers, never a custom object, pipeline, stage or association; a custom object's display fields name only properties HubSpot gives every custom object, the read found, or the plan creates.
32
32
  10. Three calls per write plus the reads use at most half of HubSpot's daily remainder (`E_BUDGET`).
33
33
 
34
34
  ## Running the steps
35
35
 
36
- Apply records `lastApply.outcome: running`, then runs the steps one at a time: groups, properties, pipeline creates, stage creates and updates (those that close a stage first, so a ticket pipeline keeps a closed stage), pipeline updates, releases, then deletes: properties, groups, stages, pipelines. Each write reads the resource again and compares it with `expect`, builds the request from that read, sends it once, and reads it back for up to 60 seconds, saying so on stderr after a few seconds.
36
+ Apply records `lastApply.outcome: running`, then runs the steps one at a time: custom object creates, groups, properties, the display fields of new custom objects, custom object updates, pipeline creates, stage creates and updates (those that close a stage first, so a ticket pipeline keeps a closed stage), pipeline updates, plain association creates, label creates, label updates, releases, then deletes: association labels, plain associations, properties, groups, stages, pipelines, custom objects. Each write reads the resource again and compares it with `expect`, builds the request from that read, sends it once, and reads it back for up to 60 seconds, saying so on stderr after a few seconds.
37
37
 
38
38
  - A property update sends the approved fields with the live `type` and `fieldType`; options go as the full live list with the approved changes, new ones last.
39
39
  - A pipeline create sends its stages with it. A stage update sends only the approved fields. A stage order change moves each stage that is out of place onto the slot of the stage it follows, one request each, reading the pipeline before each move: HubSpot renumbers the pipeline when a stage lands on a taken slot. If a move waits out a rate limit, the step stops stale; plan again to see what is left. Kalup never sends a pipeline PUT, which deletes every stage it does not name.
40
+ - A new custom object is created with its name, labels and description, and its primary display property when HubSpot gives every custom object that property, else `hs_object_id`. Its groups and properties follow, created on the type ID HubSpot returned. HubSpot makes the group `<name>_information` with the object, so when the object file lists that group, apply gives it config's label instead of creating it, and records it adopted. Once they have run, one schema update sets the display, required and searchable properties, since HubSpot refuses a field naming a property it does not hold. Apply derives that update from the create step and reports it inside the create's report as `display` (`outcome`, and `units`, `issue` when present), on an indented line under the create in the text. The create's own `outcome` stays the create's, and the run's outcome and exit code count the update too. When a property it names did not finish, it is `not-run`; the create stays `done`, and the next plan sets the fields as an update. A create HubSpot answers with a custom object a read in the run listed, or one the schemas list shows made before the answer, made nothing: it is `uncertain`, and nothing on the object runs.
41
+ - A custom object update sends every field of the schema, from a read made right before it, with the approved changes: HubSpot can set fields left out of a partial update back to older values. Custom objects are read from the schemas list, never the single read, which lags. A custom object delete archives it: HubSpot refuses while the object holds records, and Kalup never purges it.
42
+ - An association is read from both labels lists of its pair, by the name the schema read gives, else by the type IDs state records or the create answered with: HubSpot's schema read lists a new name only minutes after the create. A label update sends both labels with the type ID of its direction. A delete is done when neither list holds the association. State records each association's two type IDs.
40
43
  - A pipeline or stage is read back with a GET of its pipeline. A stage delete is done only when that read lacks the stage: HubSpot answers 204 to a delete of any stage, one that does not exist included.
41
44
  - A 429, 423 or 477 is waited out three times, reading again before each resend. A daily 429 stops the run.
42
45
  - A timeout, network failure or 5xx is `uncertain` and never resent: HubSpot documents no idempotency keys. Only reading back the approved values settles it (`E_UNCERTAIN_WRITE`).
43
46
  - A value HubSpot stores differently is `W_UNVERIFIED`: state records both, and the next plan notes it instead of writing again.
44
- - A delete runs only once every earlier step verified. HubSpot keeps an archived property restorable in its UI for 90 days; a deleted pipeline or stage is gone for good.
47
+ - A delete runs only once every earlier step verified. HubSpot keeps an archived property restorable in its UI for 90 days, and an archived custom object in its archive until someone purges it; a deleted pipeline, stage or association is gone for good.
45
48
 
46
49
  State is saved after each step that changes an entry, then the outcome: `done`, `partial` or `uncertain`. Each request is journaled in `.kalup/journal/portal-<id>/`, never with a key or body. SIGINT or SIGTERM stops before the next request and saves state. A second one exits at once, unless it comes within a second (npx passes one Ctrl-C on twice).
47
50
 
package/docs/config.md CHANGED
@@ -9,14 +9,15 @@ This page is the reference. For the walk-through with examples, see [Config file
9
9
  - `kalup.config.ts`: one `export default defineConfig({...})` and nothing after it. Fields: `name` (default: the name in the nearest `package.json` up to the repository root, else the directory name), `dir` (the folder of object files, relative to `kalup.config.ts` and inside the project, default `hubspot`; `E_SETTING_VALUE` otherwise), `state` (`'local'`, the default, or `'repo'`, state.md), `prefix`, `defaultTarget` (targets.md), `mode` (below), `objects` (the pull scope, pull.md) and `targets` (targets.md). A setting at a level that does not take it is `E_SETTING_LEVEL`, whose fix lists the levels that do; a value it does not take is `E_SETTING_VALUE`, with the nearest allowed one.
10
10
  - `hubspot/objects/<object>.ts`: one or more `export const <Name> = defineObject('<object>', {...})` or `defineCustomObject('<name>', {...})`. The writer adds an `export type <Name>Data = ...` line after each. A file with no such export is `E_MISSING_EXPORT`.
11
11
  - `hubspot/pipelines/<object>.ts`: one or more `export const <Name> = definePipeline('<object>', {...})`, below. A `definePipeline` export anywhere else is `E_UNSUPPORTED_FILE`.
12
- - `hubspot/index.ts`: the barrel, written by `pull` and `fmt`. It imports each object file as `./objects/<object>.js` and each pipeline file as `./pipelines/<object>.js`, which resolves under TypeScript `NodeNext`, `Node16` and `Bundler` resolution, bundlers such as Vite and Next.js, and plain Node running `tsc` output. Under `NodeNext`, import it as `./hubspot/index.js`.
12
+ - `hubspot/associations.ts`: one `export const Associations = defineAssociations({...})`, below. A `defineAssociations` export anywhere else is `E_UNSUPPORTED_FILE`.
13
+ - `hubspot/index.ts`: the barrel, written by `pull` and `fmt`. It imports each object file as `./objects/<object>.js`, each pipeline file as `./pipelines/<object>.js` and the associations file as `./associations.js`, which resolves under TypeScript `NodeNext`, `Node16` and `Bundler` resolution, bundlers such as Vite and Next.js, and plain Node running `tsc` output. Under `NodeNext`, import it as `./hubspot/index.js`.
13
14
  - `hubspot/removed.ts`: tombstones, below.
14
15
  - A `defineConfig` or `defineRemoved` file elsewhere under `hubspot/` is `E_UNSUPPORTED_FILE`.
15
16
  - `hubspot/blueprints.lock.json` and `hubspot/.blueprints/`: written by `kalup add` (blueprints.md).
16
17
 
17
18
  The folder belongs to Kalup alone: every `.ts` file in it is read as config, `pull` rewrites its `index.ts`, and `init` takes it out of the formatter's checks. Point `dir` at a folder of its own (`lib/config/hubspot`, not `lib/config` next to the app's modules); `init` refuses a folder that holds other `.ts` files (`E_DIR_IN_USE`). A 0.1 project keeps its `kalup/` folder while `dir` is unset and `hubspot/` holds no `.ts` file, with `W_LEGACY_DIR` on every command until you set `dir: 'kalup'` or move the folder. When both hold `.ts` files, every command stops with `E_DIR_AMBIGUOUS` until `dir` says which.
18
19
 
19
- Commit `kalup.config.ts` and the folder: the object and pipeline files, `index.ts`, `removed.ts`, the blueprints lock, and `state/` only with `state: 'repo'` (state.md). Never commit `.kalup/` (local state, saved plans, journals, history, snapshots), a plan file, `.env` or any file holding a key.
20
+ Commit `kalup.config.ts` and the folder: the object, pipeline and associations files, `index.ts`, `removed.ts`, the blueprints lock, and `state/` only with `state: 'repo'` (state.md). Never commit `.kalup/` (local state, saved plans, journals, history, snapshots), a plan file, `.env` or any file holding a key.
20
21
 
21
22
  ## The grammar
22
23
 
@@ -24,10 +25,10 @@ Anything else is `E_NOT_DATA`.
24
25
 
25
26
  - `import` lines. Imports from `@kalup/core` and `kalup` are rewritten; others are kept, for `p.json` validators.
26
27
  - Object literals of `key: value` entries, arrays, strings in single or double quotes on one line, numbers, `true` and `false`. No template strings, identifiers as values, spreads, computed keys, shorthand, or calls other than the builders.
27
- - A `//` comment on its own line above an export, a group entry, a property entry or a stage entry, and a comment block above the imports (the file header). Every other comment is an error, including any in `kalup.config.ts` but the header.
28
+ - A `//` comment on its own line above an export, a group entry, a property entry, a stage entry or an association entry, and a comment block above the imports (the file header). Every other comment is an error, including any in `kalup.config.ts` but the header.
28
29
  - `p.<kind>('<internal name>')` or `p.<kind>('<internal name>', {...})`, then any of `.strict()` (`p.enum` and `p.multiEnum` only), `.required()`, `.readonly()` and `.managed(false)`, each once. Any other chain call is `E_BAD_CHAIN`. A kind not listed below is `E_UNKNOWN_BUILDER`.
29
30
  - `p.json('<name>', <validator>, {...})`. The validator is opaque text and may not hold a `//` comment.
30
- - A custom object needs `labels: { singular, plural }` and `primaryDisplayProperty`, and may set `requiredProperties`, `searchableProperties` and `secondaryDisplayProperties`.
31
+ - A custom object needs `labels: { singular, plural }` and `primaryDisplayProperty`, and may set `description`, `requiredProperties`, `searchableProperties` and `secondaryDisplayProperties`. Its name, the first argument of `defineCustomObject`, starts with a letter and holds only letters, digits and underscores, at most 50 characters, and each label at most 50; `secondaryDisplayProperties` holds at most two properties, each once. Validate warns about a value breaking these rules (`W_OBJECT_FIELD`), since an object HubSpot holds can already break them, and plan blocks a create or update that would send one. HubSpot never changes the name once it creates the object. A display, required or searchable field may name a property HubSpot gives every custom object, such as `hs_object_id` or `hs_createdate`, or one the object file lists; any other is `W_OBJECT_PROPERTY`, a warning, since the portal may hold it outside the pull scope. A new custom object gets HubSpot's own properties, the group `<name>_information` and associations with activities only; its associations with other objects go in `hubspot/associations.ts`.
31
32
 
32
33
  `E_DUPLICATE_KEY`: a key twice in one literal, an export name twice in one file, or one internal name under two keys. `E_DUPLICATE_ADDRESS`: one address from two files or two exports.
33
34
 
@@ -79,13 +80,13 @@ The object key is the app's name for the property. Two exports of one object usi
79
80
 
80
81
  ## Per-target definitions
81
82
 
82
- A target's override `definition` (targets.md) replaces each field it states there, whole, and owns it, empty values included: a property's `label`, `description`, `group`, `fieldType`, `formField`, `options` (no `as`), `hidden`, `displayOrder`, the display fields, `calculationFormula` and lifecycle but `preventDestroy`; a group's `label`; a pipeline's `label` and `displayOrder`; a stage's `label` and metadata field. Else `E_OVERRIDE_DEFINITION`. `pull` writes these fields into the override.
83
+ A target's override `definition` (targets.md) replaces each field it states there, whole, and owns it, empty values included: a property's `label`, `description`, `group`, `fieldType`, `formField`, `options` (no `as`), `hidden`, `displayOrder`, the display fields, `calculationFormula` and lifecycle but `preventDestroy`; a group's `label`; a pipeline's `label` and `displayOrder`; a stage's `label` and metadata field; an association label's `label` and `inverseLabel` (a plain association takes none, and `label` alone leaves the other side as the file has it). Else `E_OVERRIDE_DEFINITION`. `pull` writes these fields into the override.
83
84
 
84
85
  ## Mode: addon and takeover
85
86
 
86
87
  `mode: 'addon' | 'takeover'` at the top level, under `objects.<object>`, under `targets.<target>`, or under `targets.<target>.objects.<object>`. The most specific wins, in that order from the last, and the default is `addon`: Kalup manages only what config names. A target `mode` that differs from an object's `mode` the target says nothing more about is `W_MODE_SHADOWED`.
87
88
 
88
- Under `takeover`, `plan` archives every custom property and group in the object's pull scope that config lacks and `hubspot/removed.ts` does not name (a group only once every property in it goes, and after them), and removes enum options only the portal holds. Never a HubSpot-defined or calculated property, a kind Kalup does not write, anything an object file lists, a name `exclude` covers, a property a custom object schema names, or a pipeline or stage. Every takeover removal is destructive: it needs `allowDestroy: true` on the target and a person at a terminal, and `--yes` and `--approve` never cover it. Without `allowDestroy` it is blocked, reason `policy`; after an incomplete read, reason `scope`.
89
+ Under `takeover`, `plan` archives every custom property and group in the object's pull scope that config lacks and `hubspot/removed.ts` does not name (a group only once every property in it goes, and after them), and removes enum options only the portal holds. Never a HubSpot-defined or calculated property, a kind Kalup does not write, anything an object file lists, a name `exclude` covers, a property a custom object schema names, a pipeline or stage, or an association. Every takeover removal is destructive: it needs `allowDestroy: true` on the target and a person at a terminal, and `--yes` and `--approve` never cover it. Without `allowDestroy` it is blocked, reason `policy`; after an incomplete read, reason `scope`.
89
90
 
90
91
  ## Removed resources
91
92
 
@@ -98,7 +99,7 @@ export default defineRemoved({
98
99
  })
99
100
  ```
100
101
 
101
- `destroy` deletes the resource, only on a target with `allowDestroy: true` (default false). `release` stops managing it, leaving it there. Keys are property, group, pipeline or stage addresses (`E_TOMBSTONE_ADDRESS`) that config no longer defines (`E_TOMBSTONE_CONFLICT`). A pipeline's tombstone covers its stages. HubSpot keeps no archive of a pipeline or stage: a delete is permanent.
102
+ `destroy` deletes the resource, only on a target with `allowDestroy: true` (default false). `release` stops managing it, leaving it there. Keys are custom object, property, group, pipeline, stage or association addresses (`E_TOMBSTONE_ADDRESS`) that config no longer defines (`E_TOMBSTONE_CONFLICT`). A pipeline's tombstone covers its stages, and a custom object's everything on it, its associations on either side included. HubSpot keeps no archive of a pipeline, a stage or an association: a delete is permanent.
102
103
 
103
104
  ## Pipelines
104
105
 
@@ -128,6 +129,31 @@ export const RenewalsPipeline = definePipeline('deals', {
128
129
 
129
130
  A target's override takes a pipeline's or stage's ID on that portal as `name`, `skip` (a skipped pipeline takes its stages), and a `definition` with a pipeline's `label` and `displayOrder` or a stage's `label` and metadata field.
130
131
 
132
+ ## Associations
133
+
134
+ ```ts
135
+ // hubspot/associations.ts
136
+ import { defineAssociations } from '@kalup/core'
137
+
138
+ export const Associations = defineAssociations({
139
+ // The person who signed the contract.
140
+ signer: { from: 'deals', to: 'contacts', name: 'deal_signer', label: 'Signer', inverseLabel: 'Signed deal' },
141
+ colleague: { from: 'companies', to: 'contacts', name: 'colleague', label: 'Colleague' },
142
+ visitCompany: { from: 'visit', to: 'companies', name: 'visit_to_company' },
143
+ })
144
+ ```
145
+
146
+ - Each entry is one association of a pair of objects. `from` and `to` are object keys under `objects`, both (`E_ASSOCIATION_FIELD`); a pair of one object with itself is not managed in this release.
147
+ - `name` is HubSpot's internal name: unique in the portal, set on create and never changed, so it is the address: `association:deals/contacts/deal_signer`. A label made in the HubSpot UI is named after its text in lower case with spaces as underscores. A name or object holding whitespace or `/` is `E_ASSOCIATION_NAME`; a name used twice is `E_DUPLICATE_KEY`.
148
+ - `label` is what records of `from` show, and `inverseLabel` what records of `to` show. Leave `inverseLabel` out and HubSpot shows the label on both sides; `pull` leaves it out when the two are equal. `inverseLabel` needs a `label` (`E_ASSOCIATION_FIELD`). A label is never empty, and two entries of a pair may not show one text from the same side, ignoring case (`E_DUPLICATE_LABEL`).
149
+ - An entry with no `label` is the plain association of the pair. A pair with a custom object has one HubSpot lets you manage, at most one per pair; between two standard objects HubSpot defines it, so an entry for it is `E_ASSOCIATION_FIELD`. Apply creates a pair's plain association before its labels. A label created on a pair with no plain association makes one too, under a name HubSpot picks: the plan says so.
150
+ - The name is the identity, not the direction: rewriting an entry from the other side is the same association, and state follows it. A tombstone on the other direction of an association config holds is `E_TOMBSTONE_CONFLICT`.
151
+ - The keys are free. In the app, `Associations.signer.name` is typed `'deal_signer'`, and `AssociationName<typeof Associations>` is the union of the names.
152
+ - Deleting a label is destructive: records lose that association. HubSpot refuses to delete a plain association while a label of its pair remains, so plan blocks it until the labels go.
153
+ - Kalup does not manage association limits, the most records one record may have under a label, in this release.
154
+
155
+ A target's override takes an association label's `label` and `inverseLabel` as a `definition`, and `skip`.
156
+
131
157
  ## Canonical form
132
158
 
133
- `kalup fmt` validates, then rewrites `kalup.config.ts`, `hubspot/removed.ts`, every object and pipeline file and the barrel: groups and properties sorted by internal name, stages in the order written, tombstones by address, fields in a fixed order, options in display order, quotes as biome writes them, 120 columns. It keeps every value you wrote, `description: ''`, `options: []`, `false` and an empty `lifecycle` included: a present field is owned. `fmt --check` lists the files it would change and exits 2 when there are any. Old files go to `.kalup/history/<timestamp>/` first; the last 20 runs are kept.
159
+ `kalup fmt` validates, then rewrites `kalup.config.ts`, `hubspot/removed.ts`, every object and pipeline file, the associations file and the barrel: groups and properties sorted by internal name, stages in the order written, association entries by name, tombstones by address, fields in a fixed order, options in display order, quotes as biome writes them, 120 columns. It keeps every value you wrote, `description: ''`, `options: []`, `false` and an empty `lifecycle` included: a present field is owned. `fmt --check` lists the files it would change and exits 2 when there are any. Old files go to `.kalup/history/<timestamp>/` first; the last 20 runs are kept.
@@ -11,7 +11,7 @@ This page is the reference. For the walk-through with examples, see [kalup docs]
11
11
 
12
12
  1. `# <project> data dictionary` and a line naming the source: the config files, or the target, portal ID and `observedAt` of the snapshot.
13
13
  2. `## Coverage`. For a snapshot: whether the read was complete, the objects not read with the missing scope, objects absent from the portal, what `skip` overrides left out, unsupported properties (Kalup does not write them), schemas without a label, `name` overrides, config properties in a group no address can hold, counts out of scope and shadowed, custom objects config does not name, objects whose pipelines were not read, and the fields Kalup does not capture. Reference properties record only their options.
14
- 3. One `## <object>` section per object key, sorted: a custom object's labels, display property and property lists; a groups table (internal name, label); a properties table (internal name, label, type, field type, group, managed or reference, description); one options table per enumeration (value, label, hidden, description), in display order; and per pipeline, sorted by ID, a `### Pipeline <label> (<id>)` heading, its display order, and a stages table in stage order (stage ID, label, and its probability or state; config adds the key). Config adds the key, codec, required and alias columns. Unsupported properties appear only under Coverage.
14
+ 3. One `## <object>` section per object key, sorted: a custom object's labels, description, display property and property lists; a groups table (internal name, label); a properties table (internal name, label, type, field type, group, managed or reference, description); one options table per enumeration (value, label, hidden, description), in display order; per pipeline, sorted by ID, a `### Pipeline <label> (<id>)` heading, its display order, and a stages table in stage order (stage ID, label, and its probability or state; config adds the key); and an associations table of the associations from the object, sorted (internal name, the other object, label and inverse label; config adds the key). Config adds the key, codec, required and alias columns. Unsupported properties appear only under Coverage.
15
15
  4. Config with `definition` overrides: `## Per-target overrides`, one row per address, field and target with its value, sorted.
16
16
 
17
17
  ## Escaping
@@ -20,7 +20,7 @@ Every string from a file or a portal is shown as text: newlines become spaces, c
20
20
 
21
21
  ## Deterministic
22
22
 
23
- The same source gives the same bytes: objects, groups, properties and pipelines sorted, options and stages in display order, and no timestamp but a snapshot's own `observedAt`. Commit the dictionary and check it in CI:
23
+ The same source gives the same bytes: objects, groups, properties, pipelines and associations sorted, options and stages in display order, and no timestamp but a snapshot's own `observedAt`. Commit the dictionary and check it in CI:
24
24
 
25
25
  ```sh
26
26
  npx --no-install kalup docs --out DATA-DICTIONARY.md
@@ -0,0 +1,21 @@
1
+ # E_ASSOCIATION_FIELD
2
+
3
+ An entry of `associations.ts` breaks a rule HubSpot keeps for association labels. Exit 3.
4
+
5
+ ## When
6
+
7
+ Both objects of an entry must be under `objects` in `kalup.config.ts`, so plan can read the pair. A label holds text: leave `label` out for the plain association of a pair. A pair has one plain association, and between two standard objects HubSpot defines it, so the files cannot. A label is unique per pair and direction (live runs, 2026-10-01 and 2026-10-05); two the same are `E_DUPLICATE_LABEL`. A pair of one object with itself is not managed in this release, and an inverseLabel needs a label.
8
+
9
+ ## Fix
10
+
11
+ Change or remove the entry the message names.
12
+
13
+ ## Example
14
+
15
+ ```ts
16
+ crew: { from: 'companies', to: 'contacts', name: 'crew_plain' },
17
+ ```
18
+
19
+ ```
20
+ hubspot/associations.ts:6: E_ASSOCIATION_FIELD: association:companies/contacts/crew_plain has no label, and HubSpot defines the plain association between companies and contacts (fix: give it a label, or remove it: the plain association between two standard objects is always there) (docs: errors/E_ASSOCIATION_FIELD.md)
21
+ ```
@@ -0,0 +1,17 @@
1
+ # E_ASSOCIATION_NAME
2
+
3
+ An entry of `associations.ts` names an object or an internal name holding whitespace or a slash. Exit 3.
4
+
5
+ ## When
6
+
7
+ An association is addressed as `association:<from>/<to>/<name>`, so from, to and name must each be non-empty and hold no whitespace or slash. The name is what HubSpot stores for both directions of the label, unique in the portal and never changed.
8
+
9
+ ## Fix
10
+
11
+ Use the object keys from `objects`, and an internal name of letters, digits and underscores, such as the label in lower case with underscores: `charter_signer`.
12
+
13
+ ## Example
14
+
15
+ ```
16
+ hubspot/associations.ts:6: E_ASSOCIATION_NAME: from, to and name must each be non-empty and hold no whitespace or slash, so an address can hold them (fix: use object keys and an internal name without spaces or slashes) (docs: errors/E_ASSOCIATION_NAME.md)
17
+ ```
@@ -4,14 +4,14 @@ A blueprint needs a custom object config does not define. Exit 1. Nothing was wr
4
4
 
5
5
  ## When
6
6
 
7
- A blueprint's resources and its `requires` list name objects. A standard object (contacts, companies, deals, tickets and the rest) is always there, and `kalup add` adds it to `objects` in `kalup.config.ts` when missing. A custom object has to exist in the portal and in config first: Kalup does not create custom object schemas, so the blueprint's properties would have nowhere to go.
7
+ A blueprint's resources and its `requires` list name objects. A standard object (contacts, companies, deals, tickets and the rest) is always there, and `kalup add` adds it to `objects` in `kalup.config.ts` when missing. A custom object has to be in config first: a blueprint carries no custom object schema, so its properties would have nowhere to go.
8
8
 
9
9
  ## Fix
10
10
 
11
- Create the custom object in HubSpot, add its key under `objects` in `kalup.config.ts`, run `kalup pull` to write its object file, then run the command again.
11
+ Add the key under `objects` in `kalup.config.ts`. Define the object with `defineCustomObject` in its object file, or, when HubSpot has it already, run `kalup pull` to write that file. Then run the command again.
12
12
 
13
13
  ## Example
14
14
 
15
15
  ```
16
- E_BLUEPRINT_REQUIRES: the blueprint needs the custom object vineyard, which config does not define. Nothing was written. (fix: add vineyard: {} under objects in kalup.config.ts and run kalup pull to write its object file, or create the object in HubSpot first) (docs: errors/E_BLUEPRINT_REQUIRES.md)
16
+ E_BLUEPRINT_REQUIRES: the blueprint needs the custom object vineyard, which config does not define. Nothing was written. (fix: add vineyard: {} under objects in kalup.config.ts and define it with defineCustomObject, or run kalup pull to write its object file when HubSpot has it) (docs: errors/E_BLUEPRINT_REQUIRES.md)
17
17
  ```
@@ -4,7 +4,7 @@ A saved plan deletes something config does not ask to delete. Exit 1. Nothing wa
4
4
 
5
5
  ## When
6
6
 
7
- A delete needs a `destroy` tombstone that `kalup rm` wrote, or takeover to ask for it (the mode of the object on the target is takeover, the address is in the pull scope, neither `exclude`, a `skip` override nor a tombstone names it, and the step carries the `takeover` label), and an address gone from config. Before approval, `kalup apply` reads `hubspot/removed.ts` and the object files as data, never running them, and refuses a delete step whose address has neither, is still in config, or sets `lifecycle.preventDestroy`; the message says why takeover does not archive it. It also refuses a delete of a portal resource that another address in config names through a name override on the target. The tombstone was removed after planning, the resource came back into config or into `exclude`, or the plan file was edited.
7
+ A delete needs a `destroy` tombstone that `kalup rm` wrote, or takeover to ask for it (the mode of the object on the target is takeover, the address is in the pull scope, neither `exclude`, a `skip` override nor a tombstone names it, and the step carries the `takeover` label), and an address gone from config. Before approval, `kalup apply` reads `hubspot/removed.ts` and the object files as data, never running them, and refuses a delete step whose address has neither, is still in config, or sets `lifecycle.preventDestroy`; the message says why takeover does not archive it. A step labelled `takeover` needs takeover to ask for it whatever `hubspot/removed.ts` says, so takeover never deletes a custom object, a pipeline or a stage. It also refuses a delete of a portal resource that another address in config names through a name override on the target, and a custom object archive while config still holds anything on the object, since the archive takes it along. The tombstone was removed after planning, the resource came back into config or into `exclude`, or the plan file was edited.
8
8
 
9
9
  ## Fix
10
10
 
@@ -4,7 +4,7 @@
4
4
 
5
5
  ## When
6
6
 
7
- The file named on the command line is missing, is not JSON, or does not match the `plan/1` schema. The message names the first place that fails. Apply also refuses a file whose steps contradict themselves: a change that writes a value the step's `desired` values do not hold. `kalup plan` never writes such a file.
7
+ The file named on the command line is missing, is not JSON, or does not match the `plan/1` schema. The message names the first place that fails. Apply also refuses a file whose steps contradict themselves: a change that writes a value the step's `desired` values do not hold, or a custom object archive whose `expect` does not count the properties, groups and pipelines it takes along. `kalup plan` never writes such a file.
8
8
 
9
9
  ## Fix
10
10
 
@@ -4,7 +4,7 @@ A step in the plan does not match what Kalup derives from state and the portal.
4
4
 
5
5
  ## When
6
6
 
7
- Under the portal lock, `kalup apply` reads state and the portal again and derives each step's risk, labels and blocked status as `plan` does. It refuses when a step states a lower risk than derived, leaves out a derived label (`reverts-ui-edit`, `overwrites-portal`, `takeover`), or would be blocked: an update of what state does not own, a delete of what state does not own that takeover does not archive, an adopt of what it does, a delete or takeover option removal the target does not allow, a takeover archive of what HubSpot defines, of a property in a group a `skip` override covers or one a custom object schema names, of a group that held no property, or whose `expect` leaves out a field the base holds (so an edit made in HubSpot after the review would not stop it), a custom object schema change, or a group delete while properties still name the group. A plan `kalup plan` saved matches, unless config changed since, such as a `skip` override added; otherwise the file was edited.
7
+ Under the portal lock, `kalup apply` reads state and the portal again and derives each step's risk, labels and blocked status as `plan` does. It refuses when a step states a lower risk than derived, leaves out a derived label (`reverts-ui-edit`, `overwrites-portal`, `takeover`), or would be blocked: an update of what state does not own, a delete of what state does not own that takeover does not archive, a delete labelled `takeover` of a custom object, a pipeline, a stage, something on an object whose mode is not takeover, or something `hubspot/removed.ts` names or covers, an adopt of what it does, a delete or takeover option removal the target does not allow, a takeover archive of what HubSpot defines, of a property in a group a `skip` override covers or one a custom object schema names, of a group that held no property, or whose `expect` leaves out a field the base holds (so an edit made in HubSpot after the review would not stop it) or, for a custom object archive, a count of what it takes along, a custom object schema change, or a group delete while properties still name the group. A plan `kalup plan` saved matches, unless config changed since, such as a `skip` override added; otherwise the file was edited.
8
8
 
9
9
  ## Fix
10
10
 
@@ -4,7 +4,7 @@
4
4
 
5
5
  ## When
6
6
 
7
- `preventDestroy: true` in a property's `lifecycle` says the property must never be deleted through Kalup. `kalup rm <address>` writes a `destroy` tombstone, which a later plan turns into a delete, so rm refuses it before it changes any file.
7
+ `preventDestroy: true` in a property's `lifecycle` says the property must never be deleted through Kalup. `kalup rm <address>` writes a `destroy` tombstone, which a later plan turns into a delete, so rm refuses it before it changes any file. A custom object's archive takes every group, property and pipeline on it along, so `kalup rm object:<name>` refuses while any of them sets `preventDestroy`, and names them. After `kalup rm object:<name> --release` they have left config, so rm will not turn that release into a destroy: remove the release tombstone, pull the object back, then remove it again.
8
8
 
9
9
  ## Fix
10
10
 
@@ -4,7 +4,7 @@ A key in `hubspot/removed.ts`, or the address given to `kalup rm`, is not the ad
4
4
 
5
5
  ## When
6
6
 
7
- Each key in `hubspot/removed.ts` is an address, such as `property:companies/legacy_score`: the type, a colon, the object, a slash and the name. A key with no object, such as `property:legacy_score`, names nothing and is refused. A stage address names its pipeline as well: `stage:deals/renewals/won`. This version removes properties, property groups, pipelines and stages only, so a key of another type, such as `object:parcels`, is refused as well.
7
+ Each key in `hubspot/removed.ts` is an address, such as `property:companies/legacy_score`: the type, a colon, the object, a slash and the name. A key with no object, such as `property:legacy_score`, names nothing and is refused. A stage address names its pipeline as well: `stage:deals/renewals/won`. This version removes custom objects, properties, property groups, pipelines and stages, so a key of another type, such as `list:renewals`, is refused as well, and so is `object:<name>` for a standard object, which HubSpot defines, or for a key that is not under `objects` in `kalup.config.ts`.
8
8
 
9
9
  ## Fix
10
10
 
@@ -4,7 +4,7 @@ An address is in `hubspot/removed.ts` and still defined in config. Exit 3.
4
4
 
5
5
  ## When
6
6
 
7
- A tombstone takes a resource out of config: `destroy` deletes it in the portal, `release` stops managing it and leaves it there. Config may not define the same address at the same time, not even as a reference without `label`, `group` and `fieldType`. This usually means the entry was added to `hubspot/removed.ts` by hand and the property or group was left in its object file.
7
+ A tombstone takes a resource out of config: `destroy` deletes it in the portal, `release` stops managing it and leaves it there. Config may not define the same address at the same time, not even as a reference without `label`, `group` and `fieldType`. This usually means the entry was added to `hubspot/removed.ts` by hand and the property or group was left in its object file. A custom object tombstone takes everything on the object along, so config may not hold any group, property, pipeline or stage on that object either; `kalup rm object:<name>` takes them out with it.
8
8
 
9
9
  ## Fix
10
10
 
@@ -4,7 +4,7 @@ A key under `objects` is neither a standard object nor a custom object in the po
4
4
 
5
5
  ## When
6
6
 
7
- A key that is not a standard object name (`contacts`, `companies`, `deals`, `line_items` and the rest, plural) is read as a custom object. None of the portal's custom objects has that name. For a key a `defineCustomObject` in config backs, only `pull` stops: `plan` creates the object and `compare` finds it on the config side only.
7
+ A key that is not a standard object name (`contacts`, `companies`, `deals`, `line_items` and the rest, plural) is read as a custom object. None of the portal's custom objects has that name, and config defines none either. A key a `defineCustomObject` in config backs is never this code: `pull` reports that object missing in portal and leaves its file as it is, `plan` creates it, and `compare` finds it on the config side only. A key whose object `hubspot/removed.ts` names is left out by `pull` too, whether or not HubSpot still holds it.
8
8
 
9
9
  ## Fix
10
10
 
@@ -0,0 +1,25 @@
1
+ # W_OBJECT_FIELD
2
+
3
+ A warning from validate: a custom object in config has a name, a label or secondary display properties HubSpot refuses. Exit stays 0.
4
+
5
+ ## When
6
+
7
+ A custom object name starts with a letter and holds only letters, digits and underscores, at most 50 characters, and its singular and plural labels hold at most 50 characters each. HubSpot refuses anything else on create (live runs, 2026-10-05). The name is the first argument of `defineCustomObject` and is permanent once HubSpot creates the object; the labels can change.
8
+
9
+ `secondaryDisplayProperties` holds at most two properties, each once: HubSpot refuses a third (live runs, 2026-10-01).
10
+
11
+ An object HubSpot holds already can break these rules, so validate only warns. `plan` blocks a create, or an update, that would send such a value, with reason `unsupported`.
12
+
13
+ ## Fix
14
+
15
+ Choose a name HubSpot takes, such as `orchard_visit`, or shorten the label. For an object HubSpot holds already, the name in config is the portal's: keep it. List at most two secondary display properties, each once.
16
+
17
+ ## Example
18
+
19
+ ```ts
20
+ export const Visit = defineCustomObject('orchard-visit', {
21
+ ```
22
+
23
+ ```
24
+ hubspot/objects/orchard_visit.ts:3: W_OBJECT_FIELD: 'orchard-visit' is not a custom object name HubSpot takes: a letter, then letters, digits and underscores, at most 50 characters (fix: choose another name; HubSpot never changes a custom object name once it creates the object) (docs: errors/W_OBJECT_FIELD.md)
25
+ ```
@@ -0,0 +1,17 @@
1
+ # W_OBJECT_PROPERTY
2
+
3
+ A warning from validate: a custom object's `primaryDisplayProperty`, `secondaryDisplayProperties`, `requiredProperties` or `searchableProperties` names a property its object file does not list. Exit stays 0.
4
+
5
+ ## When
6
+
7
+ HubSpot refuses a schema create or update that names a property it does not hold (live runs, 2026-10-05). A property HubSpot gives every custom object, such as `hs_object_id` or `hs_createdate`, needs no entry, and neither does any other `hs_` name, a prefix HubSpot reserves. Any other one the object file does not list may still be in the portal, outside the pull scope, so validate only warns. `plan` blocks a schema write that names a property neither the portal holds nor the plan creates, and raises this warning itself for an `hs_` name it takes as HubSpot's that is not one HubSpot is known to give every custom object.
8
+
9
+ ## Fix
10
+
11
+ Add the property to the object's `properties`: with its definition when Kalup should create it, or as a reference, `p.string('<name>')`, when HubSpot holds it already.
12
+
13
+ ## Example
14
+
15
+ ```
16
+ hubspot/objects/orchard_visit.ts:3: W_OBJECT_PROPERTY: primaryDisplayProperty of object:orchard_visit names visit_title, which the object file does not list; HubSpot refuses a schema write naming a property it does not hold (fix: add visit_title to the object's properties, as a reference if HubSpot holds it already: p.string('visit_title')) (docs: errors/W_OBJECT_PROPERTY.md)
17
+ ```
package/docs/plan.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # Plan
2
2
 
3
- `kalup plan [--target <name>] [--take config <address[#unit]>] [--out [<file>]] [--exit-code]` shows what apply would do to one target: a step per object, group, property, pipeline and stage config manages, `definition` overrides applied (config.md), then the releases and deletes tombstones ask for. It writes neither portal nor state. `--out <file>` saves the plan/1 document; `--out` alone saves it as `.kalup/plans/<target>-<planId>.json` and prints the path. `kalup apply <file>` applies either.
3
+ `kalup plan [--target <name>] [--take config <address[#unit]>] [--out [<file>]] [--exit-code]` shows what apply would do to one target: a step per object, group, property, pipeline, stage and association config manages, `definition` overrides applied (config.md), then the releases and deletes tombstones ask for. It writes neither portal nor state. `--out <file>` saves the plan/1 document; `--out` alone saves it as `.kalup/plans/<target>-<planId>.json` and prints the path. `kalup apply <file>` applies either.
4
4
 
5
5
  This page is the reference. For the walk-through with examples, see [kalup plan](https://kalup.dev/docs/commands/plan) and [Drift](https://kalup.dev/docs/concepts/drift) on the website.
6
6
 
@@ -9,8 +9,8 @@ This page is the reference. For the walk-through with examples, see [kalup plan]
9
9
  1. Validate (`E_NO_CONFIG` exit 1, other issues exit 3), then pick the target (targets.md).
10
10
  2. The read key, then the portal guard (`E_TARGET_PORTAL_MISMATCH`, exit 4).
11
11
  3. State for that portal, `.kalup/state/portal-<portalId>.json`; an unusable file is `E_STATE_INVALID`.
12
- 4. Pull's read and scope, plus tombstoned properties, and the pipelines of each object whose files define one, with `pipelines: true`, or with a tombstoned pipeline or stage. A 403 leaves that object unread (`E_SCOPE`); on the pipelines list, only its pipelines.
13
- 5. Limits Tracking (403 without a `crm.objects.*` scope; `W_LIMIT_UNREADABLE` for property and pipeline creates), then the three `archived=true` lists of each object with a property create or an owned property HubSpot no longer holds. A group delete reads no archived list: only active properties block it. A 403 there is exit 1.
12
+ 4. Pull's read and scope, plus tombstoned properties, and the pipelines of each object whose files define one, with `pipelines: true`, or with a tombstoned pipeline or stage, and the labels of each object pair in association scope. A 403 leaves that object unread (`E_SCOPE`); on the pipelines list, only its pipelines; on a labels list, only that pair's associations.
13
+ 5. Limits Tracking (403 without a `crm.objects.*` scope; `W_LIMIT_UNREADABLE` for property and pipeline creates; the association label counts of each pair the plan creates labels on, which only warn, `W_LIMIT_HEADROOM`, since HubSpot counts a label deleted in the last 40 seconds), then the three `archived=true` lists of each object with a property create or an owned property HubSpot no longer holds. A group delete reads no archived list: only active properties block it. A 403 there is exit 1.
14
14
  6. The plan, checked against `plan-1.schema.json` (`E_PLAN_SCHEMA` is a bug).
15
15
 
16
16
  ## State and the base
@@ -39,7 +39,15 @@ With `drift: 'overwrite'`, drift and conflicts are written labelled `reverts-ui-
39
39
 
40
40
  ## What blocks
41
41
 
42
- The first rule that matches: a `skip` override (no step, `coverage.excluded`); a `lookup` override; an unread object (`scope`, action `unknown`); a blocked parent or missing group (`dependency-blocked`); a property Kalup does not write (pull.md), whose fix makes it a `p.string` reference; for a create, a missing `name` override target, an archived property name (a create restores it; an archived group's name is created anew), or no limit room; HubSpot-defined or calculated, a `type` or `hasUniqueValue` difference, or read-only definition or options. A custom object schema is compared and never written: a missing one is blocked, and its differences are held or noted.
42
+ The first rule that matches: a `skip` override (no step, `coverage.excluded`); a `lookup` override; an unread object (`scope`, action `unknown`); a blocked parent or missing group (`dependency-blocked`); a property Kalup does not write (pull.md), whose fix makes it a `p.string` reference; for a create, a missing `name` override target, an archived property name (a create restores it; an archived group's name is created anew), or no limit room; HubSpot-defined or calculated, a `type` or `hasUniqueValue` difference, or read-only definition or options.
43
+
44
+ ## Custom objects
45
+
46
+ - A new custom object is one `create` step. Apply creates it with its name, labels and description, then its groups and properties, then sets the display, required and searchable properties, since HubSpot refuses those fields until the properties they name exist. The step's notes say what HubSpot adds to a new object (its own properties, the group `<name>_information` and associations with activities) and which fields wait for the properties. When the object file lists `<name>_information`, as a pull writes it once a property sits in it, that group's create step notes that apply gives HubSpot's group config's label instead.
47
+ - HubSpot's schemas list can show a custom object's values from before a write for some seconds after apply verified it, so a plan made right after such an apply can show the write as held drift. Plan again a minute later, and do not pull in that window: the pull would take the old values into config.
48
+ - An update writes the whole schema in one request. A create and every update are `safe`: the display fields change forms and record views, not record values. A delete is `destructive`.
49
+ - Blocked `unsupported`: a create whose name HubSpot holds archived, since the create would purge the archived object with its records and association labels (restore it in HubSpot and pull, purge it in HubSpot, or choose another name); a create whose name differs only in case from a custom object HubSpot holds, since HubSpot keeps names unique ignoring case; and a create or update whose display, required or searchable field names a property the portal does not hold and the plan does not create. When the plan blocks that property's create (a limit, for example), the object's step is blocked `dependency-blocked`, naming the property.
50
+ - `kalup rm object:<name>` writes one tombstone that covers everything on the object: the plan has one delete step, which archives the object. Its title says how many of the object's properties, groups and pipelines go with it, and that HubSpot keeps no properties on an archived custom object; apply stops before any write when its own read finds more than the plan did. When the key cannot read the object's pipelines, the step is blocked `scope`, since the plan cannot count them. HubSpot refuses the archive while the object holds records. A tombstone on something under the object that asks otherwise than the object's is blocked with the reason. Takeover never archives a custom object.
43
51
 
44
52
  ## Pipelines and stages
45
53
 
@@ -55,7 +63,18 @@ The first rule that matches: a `skip` override (no step, `coverage.excluded`); a
55
63
 
56
64
  ## Tombstones, missing and orphans
57
65
 
58
- `hubspot/removed.ts` tombstones name properties, groups, pipelines and stages:
66
+ ## Associations
67
+
68
+ - A create sends the name and both labels; a plain association's create sends an empty label. Apply creates a pair's plain association before its labels. A label created on a pair with a custom object and no plain association makes one too, under a name HubSpot picks: the step's note says so, and pull writes it once an object of the pair sets `associations: true`.
69
+ - An update sends both labels. Risk: a create and an update are `safe`; a delete is `destructive`, since records lose the association.
70
+ - A plain association delete is blocked `unsupported` while a label of its pair remains, one HubSpot does not name yet included, unless the plan deletes that label first: HubSpot refuses it.
71
+ - Blocked `unsupported` too: a plain association config gives a label, or a label config holds as a plain association (add an entry under another name instead); a create whose label the pair shows already from the same object, a plain create on a pair that holds a plain association, and a label create that fits HubSpot's cap only once a delete in this plan has run (deletes run last: apply the delete, then plan again).
72
+ - An association the read did not find, on a pair whose lists hold a type HubSpot's schema read does not name yet, is blocked: it may be that type. Plan again in a few minutes.
73
+ - HubSpot holds at most 50 labels per pair. Plan warns `W_LIMIT_HEADROOM` when its creates would pass the count Limits Tracking reports, and never blocks on it; HubSpot refuses the 51st with HTTP 437, which apply reports.
74
+ - Blocked `scope`: an association of a pair whose labels were not read or answered 403.
75
+ - Takeover never deletes an association. `notCovered` names association limits, which Kalup does not manage yet.
76
+
77
+ `hubspot/removed.ts` tombstones name custom objects, properties, groups, pipelines, stages and associations:
59
78
 
60
79
  - `release`: a `release` step drops the entry, even one naming another portal name; nothing is sent.
61
80
  - `destroy`, present: a `delete`, risk `destructive`, labelled `existed-before-kalup` for an adopted resource, expecting every base unit's live value. Blocked with `policy` without `allowDestroy: true`, `unsupported` when it is not archivable or a group still holds active properties the plan does not delete (archived ones do not block: HubSpot archives a group once every property in it is archived), `not-owned` without an owning entry.
@@ -64,7 +83,7 @@ The first rule that matches: a `skip` override (no step, `coverage.excluded`); a
64
83
 
65
84
  Under takeover (config.md), a `delete` labelled `takeover` archives each custom property and group in the pull scope that config lacks, with a `mode` note naming the statement that asked for it; an option removal takeover asks for carries the note too. One `Takeover on <objects>` heading precedes the first such step and says whether each is confirmed at a terminal or all are blocked. Blocked with `policy` without `allowDestroy`, `scope` after an incomplete read, `unsupported` when not archivable or a group keeps an active property. The `policy` fix leads with `kalup pull --target <t> --only <address>`, which keeps it in config, then `exclude` or `lifecycle: { options: 'additive' }` to leave it unmanaged, then `allowDestroy`. A delete expects every captured field's live value. Apply checks the same rules against its own read (a skipped group, a schema's properties, an empty group).
66
85
 
67
- Releases follow the config steps, then deletes, the tombstones' and then takeover's, properties before groups, then stages, then pipelines.
86
+ Releases follow the config steps, then deletes, the tombstones' and then takeover's, properties before groups, then stages, then pipelines, then association labels, then plain associations.
68
87
 
69
88
  `missing` lists owned resources a complete read did not find, with `archived` (`null` for a group) and the exits; `orphans`, owned entries config no longer names, with both `kalup rm` commands; one naming another portal name, only `--release`.
70
89