@harperfast/template-react-ts-ssr-studio 1.13.4 → 1.13.5

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.
Files changed (48) hide show
  1. package/.agents/skills/harper-best-practices/AGENTS.md +687 -453
  2. package/.agents/skills/harper-best-practices/SKILL.md +24 -21
  3. package/.agents/skills/harper-best-practices/rules/adding-tables-with-schemas.md +2 -0
  4. package/.agents/skills/harper-best-practices/rules/automatic-apis.md +2 -0
  5. package/.agents/skills/harper-best-practices/rules/caching.md +2 -0
  6. package/.agents/skills/harper-best-practices/rules/checking-authentication.md +2 -0
  7. package/.agents/skills/harper-best-practices/rules/custom-resources.md +2 -0
  8. package/.agents/skills/harper-best-practices/rules/defining-relationships.md +102 -40
  9. package/.agents/skills/harper-best-practices/rules/deploying-from-ci.md +121 -93
  10. package/.agents/skills/harper-best-practices/rules/deploying-to-harper-fabric.md +149 -103
  11. package/.agents/skills/harper-best-practices/rules/extending-tables.md +2 -0
  12. package/.agents/skills/harper-best-practices/rules/handling-binary-data.md +2 -0
  13. package/.agents/skills/harper-best-practices/rules/load-env.md +2 -0
  14. package/.agents/skills/harper-best-practices/rules/logging.md +2 -0
  15. package/.agents/skills/harper-best-practices/rules/programmatic-table-requests.md +124 -91
  16. package/.agents/skills/harper-best-practices/rules/querying-rest-apis.md +86 -82
  17. package/.agents/skills/harper-best-practices/rules/real-time-apps.md +2 -0
  18. package/.agents/skills/harper-best-practices/rules/schema-design-tooling.md +92 -59
  19. package/.agents/skills/harper-best-practices/rules/serving-web-content.md +2 -0
  20. package/.agents/skills/harper-best-practices/rules/typescript-type-stripping.md +2 -0
  21. package/.agents/skills/harper-best-practices/rules/using-blob-datatype.md +2 -0
  22. package/.agents/skills/harper-best-practices/rules/vector-indexing.md +2 -0
  23. package/.agents/skills/harper-best-practices/rules.manifest.yaml +44 -0
  24. package/agent/skills/harper-best-practices/AGENTS.md +687 -453
  25. package/agent/skills/harper-best-practices/SKILL.md +26 -21
  26. package/agent/skills/harper-best-practices/rules/adding-tables-with-schemas.md +2 -0
  27. package/agent/skills/harper-best-practices/rules/automatic-apis.md +2 -0
  28. package/agent/skills/harper-best-practices/rules/caching.md +2 -0
  29. package/agent/skills/harper-best-practices/rules/checking-authentication.md +2 -0
  30. package/agent/skills/harper-best-practices/rules/custom-resources.md +2 -0
  31. package/agent/skills/harper-best-practices/rules/defining-relationships.md +102 -40
  32. package/agent/skills/harper-best-practices/rules/deploying-from-ci.md +121 -93
  33. package/agent/skills/harper-best-practices/rules/deploying-to-harper-fabric.md +149 -103
  34. package/agent/skills/harper-best-practices/rules/extending-tables.md +2 -0
  35. package/agent/skills/harper-best-practices/rules/handling-binary-data.md +2 -0
  36. package/agent/skills/harper-best-practices/rules/load-env.md +2 -0
  37. package/agent/skills/harper-best-practices/rules/logging.md +2 -0
  38. package/agent/skills/harper-best-practices/rules/programmatic-table-requests.md +124 -91
  39. package/agent/skills/harper-best-practices/rules/querying-rest-apis.md +86 -82
  40. package/agent/skills/harper-best-practices/rules/real-time-apps.md +2 -0
  41. package/agent/skills/harper-best-practices/rules/schema-design-tooling.md +92 -59
  42. package/agent/skills/harper-best-practices/rules/serving-web-content.md +2 -0
  43. package/agent/skills/harper-best-practices/rules/typescript-type-stripping.md +2 -0
  44. package/agent/skills/harper-best-practices/rules/using-blob-datatype.md +2 -0
  45. package/agent/skills/harper-best-practices/rules/vector-indexing.md +2 -0
  46. package/agent/skills/harper-best-practices/rules.manifest.yaml +44 -0
  47. package/package.json +1 -1
  48. package/skills-lock.json +1 -1
@@ -8,6 +8,8 @@ Guidelines for building scalable, secure, and performant applications on Harper.
8
8
 
9
9
  Instructions for the agent to follow when adding tables to a Harper database.
10
10
 
11
+ **Instead of:** Postgres, MySQL, MongoDB, DynamoDB, Supabase, Firebase. Use this rule rather than adding that service. See `harper-architecture`.
12
+
11
13
  #### When to Use
12
14
 
13
15
  Use this skill when you need to define new data structures or modify existing ones in a Harper database.
@@ -40,26 +42,26 @@ type ExamplePerson @table @export {
40
42
  }
41
43
  ```
42
44
 
43
- ### 1.2 Schema Design Tooling
45
+ ### 1.2 Schema Design and GraphQL Tooling
44
46
 
45
47
  Instructions for the agent to follow when designing Harper schemas, applying core directives, and configuring GraphQL tooling.
46
48
 
47
49
  #### When to Use
48
50
 
49
- Apply this rule when creating or modifying Harper schema files (`.graphql`), configuring `graphqlSchema` in `config.yaml`, or deciding which directives to apply to tables and fields. Use it whenever a task involves defining tables, primary keys, indexes, or export behavior.
51
+ Apply this rule when creating or modifying Harper schema files, configuring `graphqlSchema` in `config.yaml`, or deciding which directives to apply to tables and fields. Use it whenever a task involves defining tables, primary keys, indexes, or export behavior.
50
52
 
51
53
  #### How It Works
52
54
 
53
- 1. **Register the schema file** in the component's `config.yaml` using the `graphqlSchema` plugin key:
55
+ 1. **Load the schema via `graphqlSchema`**: In the component's `config.yaml`, point to the schema file:
54
56
 
55
57
  ```yaml
56
58
  graphqlSchema:
57
59
  files: 'schema.graphql'
58
60
  ```
59
61
 
60
- Both plugins and applications can specify schemas.
62
+ Both plugins and applications can specify schemas. The schema ensures required tables exist on deployment, enforces types, controls indexing, and defines relationships.
61
63
 
62
- 2. **Mark types as tables** using `@table`. The type name becomes the table name by default:
64
+ 2. **Mark tables with `@table`**: Apply `@table` to every GraphQL type that should become a Harper database table. The type name becomes the table name by default.
63
65
 
64
66
  ```graphql
65
67
  type Dog @table {
@@ -70,47 +72,60 @@ Apply this rule when creating or modifying Harper schema files (`.graphql`), con
70
72
  }
71
73
  ```
72
74
 
73
- 3. **Designate a primary key** on every table using `@primaryKey`. Primary keys must be unique; duplicate inserts are rejected. If no primary key is provided on insert, Harper auto-generates one:
74
- - `String` or `ID` → UUID string
75
- - `Int`, `Long`, or `Any` → auto-incrementing integer
75
+ Use `@table` arguments to override defaults:
76
76
 
77
- Use `Long` or `Any` for auto-generated numeric keys; `Int` is 32-bit and may be insufficient for large tables.
77
+ | Argument | Type | Default | Description |
78
+ | ------------------------ | --------- | ----------------------------- | ------------------------------------------------------------------------------ |
79
+ | `table` | `String` | type name | Override the table name |
80
+ | `database` | `String` | `"data"` | Database to place the table in |
81
+ | `expiration` | `Int` | — | Seconds until a record goes stale |
82
+ | `eviction` | `Int` | `0` | Additional seconds after `expiration` before physical removal |
83
+ | `scanInterval` | `Int` | `(expiration + eviction) / 4` | Seconds between eviction scans |
84
+ | `replicate` | `Boolean` | `true` | Enable replication of this table |
85
+ | `replicaExpiration` | `Float` | `3600` | Seconds a record fetched from another node is kept as a local replica |
86
+ | `replicaRefreshFraction` | `Float` | `0.5` | Fraction of `replicaExpiration` after which a read renews the replica lifetime |
87
+ | `audit` | `Boolean` | `logging.auditLog` | Enable the table's transaction log |
88
+ | `cacheControl` | `String` | — | `Cache-Control` header for anonymous GET/HEAD 200/304 responses |
89
+ | `randomAccessFields` | `Boolean` | `storage.randomAccessFields` | Pin this table's record encoding |
78
90
 
79
- 4. **Index fields for querying** using `@indexed`. Required for filtering by an attribute in REST queries, SQL, or NoSQL operations:
91
+ 3. **Designate a primary key with `@primaryKey`**: Apply `@primaryKey` to exactly one field per table. Primary keys must be unique; duplicate inserts are rejected.
80
92
 
81
93
  ```graphql
82
- type Breed @table {
94
+ type Product @table {
83
95
  id: Long @primaryKey
84
- name: String @indexed
96
+ name: String
85
97
  }
86
98
  ```
87
99
 
88
- If the field value is an array, each element is individually indexed. Null values are indexed by default.
100
+ If no primary key is provided on insert, Harper auto-generates one:
101
+ - **UUID string** — when type is `String` or `ID`
102
+ - **Auto-incrementing integer** — when type is `Int`, `Long`, or `Any`
103
+
104
+ Prefer `Long` or `Any` for auto-generated numeric keys; `Int` is 32-bit and may be insufficient for large tables.
89
105
 
90
- 5. **Expose tables as REST/MQTT endpoints** using `@export`. The optional `name` parameter sets the URL path segment; without it, the type name is used:
106
+ 4. **Index queryable fields with `@indexed`**: Apply `@indexed` to any field that must be filterable via REST queries, SQL, or NoSQL operations.
91
107
 
92
108
  ```graphql
93
- type MyTable @table @export(name: "my-table") {
109
+ type Product @table {
94
110
  id: Long @primaryKey
111
+ category: String @indexed
112
+ price: Float @indexed
95
113
  }
96
114
  ```
97
115
 
98
- `@export` alone does not serve HTTP traffic — REST must also be enabled for the application via `rest: true` in `config.yaml` or Harper's built-in default. `@export` is a routing directive, not access control; omitting it returns 404 but does not protect the data.
116
+ If the field value is an array, each element is individually indexed. Null values are indexed by default.
99
117
 
100
- 6. **Configure `@table` arguments** to control database placement, expiration, replication, and caching behavior. Key arguments:
118
+ 5. **Expose tables externally with `@export`**: Apply `@export` to make a table accessible via REST, MQTT, and other interfaces. The optional `name` parameter sets the URL path segment.
101
119
 
102
- | Argument | Type | Default | Description |
103
- | -------------------- | --------- | ----------------------------- | -------------------------------------------------------------- |
104
- | `table` | `String` | type name | Override the table name |
105
- | `database` | `String` | `"data"` | Database to place the table in |
106
- | `expiration` | `Int` | — | Seconds until a record goes stale |
107
- | `eviction` | `Int` | `0` | Additional seconds after `expiration` before physical removal |
108
- | `scanInterval` | `Int` | `(expiration + eviction) / 4` | Seconds between eviction scans |
109
- | `replicate` | `Boolean` | `true` | Enable replication of this table |
110
- | `cacheControl` | `String` | — | `Cache-Control` header on anonymous GET/HEAD 200/304 responses |
111
- | `randomAccessFields` | `Boolean` | `storage.randomAccessFields` | Pin this table's record encoding |
120
+ ```graphql
121
+ type MyTable @table @export(name: "my-table") {
122
+ id: Long @primaryKey
123
+ }
124
+ ```
112
125
 
113
- 7. **Seal a type** with `@sealed` to prevent records from including properties beyond those declared:
126
+ `@export` alone does not serve HTTP traffic — REST must also be enabled for the application via `rest: true` in `config.yaml` or Harper's built-in default. `@export` is a routing directive, not access control: omitting it returns 404 to callers but the table remains accessible through the Operations API and SQL.
127
+
128
+ 6. **Prevent extra properties with `@sealed`** (when needed): Apply `@sealed` to reject records that include properties beyond those declared in the schema. By default, Harper allows additional properties.
114
129
 
115
130
  ```graphql
116
131
  type StrictRecord @table @sealed {
@@ -119,13 +134,33 @@ Apply this rule when creating or modifying Harper schema files (`.graphql`), con
119
134
  }
120
135
  ```
121
136
 
122
- 8. **Use timestamp directives** for automatic record lifecycle tracking:
123
- - `@createdTime` — assigns Unix epoch milliseconds on record creation
124
- - `@updatedTime` — assigns Unix epoch milliseconds on each update
125
- - `@expiresAt` — marks a field as the record's absolute expiration time (Unix epoch milliseconds); authoritative over the table-level `expiration` default
137
+ 7. **Use unique database names for plugins and applications**: All tables default to the `data` database. Specify a distinct `database` argument on `@table` to avoid naming collisions between components.
126
138
 
127
- 9. **Use `@embed`** to automatically compute an embedding vector for an attribute whenever the source field is written (requires `source` and `model` arguments; field type must be `[Float]`):
139
+ 8. **Apply timestamp and expiration field directives** where needed:
140
+ - `@createdTime` — auto-assigns a Unix epoch millisecond timestamp on record creation.
141
+ - `@updatedTime` — auto-assigns a Unix epoch millisecond timestamp on each update.
142
+ - `@expiresAt` — marks a field as the record's absolute expiration time (Unix epoch milliseconds); overrides the table-level `expiration` default.
143
+ - `@hidden` — suppresses the type or field from MCP tool descriptors and the OpenAPI document. Does not restrict data access.
128
144
 
145
+ ```graphql
146
+ type Event @table {
147
+ id: Long @primaryKey
148
+ createdAt: Long @createdTime
149
+ updatedAt: Long @updatedTime
150
+ }
151
+
152
+ type Session @table {
153
+ id: ID @primaryKey
154
+ token: String
155
+ expiresAt: Long @expiresAt
156
+ }
157
+ ```
158
+
159
+ Key `@expiresAt` behaviors:
160
+ - Takes an absolute Unix epoch millisecond timestamp, not a duration. A full-record `put` that omits the field clears it; a `patch` preserves it.
161
+ - A per-record value may extend or shorten a record's expiration relative to the table-level `expiration` default. When a record omits the field, the table default (if any) applies.
162
+
163
+ 9. **Use `@embed`** to automatically compute an embedding vector for an attribute whenever the source field is written (requires `source` and `model` arguments; field type must be `[Float]`):
129
164
  ```graphql
130
165
  type Document @table {
131
166
  id: Long @primaryKey
@@ -133,12 +168,11 @@ Apply this rule when creating or modifying Harper schema files (`.graphql`), con
133
168
  embedding: [Float] @embed(source: "text", model: "default")
134
169
  }
135
170
  ```
136
-
137
- 10. **Use `@hidden`** on types or fields to suppress them from MCP tool descriptors and the OpenAPI document. This is a metadata-visibility directive only — it does not restrict data access. Use `attribute_permissions` on roles for field-level access control.
171
+ The attribute is automatically indexed with an HNSW vector index. A failure to compute the embedding fails the write. An update that does not touch the `source` field leaves the vector unchanged. Setting the `source` field to `null` sets the vector to `null`.
138
172
 
139
173
  #### Examples
140
174
 
141
- **Minimal two-table schema:**
175
+ **Minimal schema with two tables:**
142
176
 
143
177
  ```graphql
144
178
  type Dog @table {
@@ -154,7 +188,7 @@ type Breed @table {
154
188
  }
155
189
  ```
156
190
 
157
- **Table with expiration, eviction, and scan tuning:**
191
+ **Caching table with expiration, eviction, and scan tuning:**
158
192
 
159
193
  ```graphql
160
194
  # Expire after 5 minutes, evict after 1 hour, scan every 10 minutes
@@ -174,7 +208,7 @@ type Product @table(cacheControl: "public, max-age=60") @export {
174
208
  }
175
209
  ```
176
210
 
177
- **Table with multiple `@table` arguments combined:**
211
+ **Table in a named database with expiration:**
178
212
 
179
213
  ```graphql
180
214
  type Event @table(database: "analytics", expiration: 86400) {
@@ -183,29 +217,28 @@ type Event @table(database: "analytics", expiration: 86400) {
183
217
  }
184
218
  ```
185
219
 
186
- **Session table with per-record expiration:**
220
+ **Replica lifetime tuning:**
187
221
 
188
222
  ```graphql
189
- type Session @table {
223
+ # Keep fetched records for a day; a read after six hours renews them
224
+ type Order @table(replicaExpiration: 86400, replicaRefreshFraction: 0.25) {
190
225
  id: ID @primaryKey
191
- token: String
192
- expiresAt: Long @expiresAt
226
+ region: String @indexed
193
227
  }
194
228
  ```
195
229
 
196
- **Table with timestamp tracking and indexed fields:**
230
+ **Random-access field encoding pinned on the table:**
197
231
 
198
232
  ```graphql
199
- type Order @table(database: "commerce") @export {
200
- id: Long @primaryKey
201
- userId: String @indexed
202
- status: String @indexed
203
- createdAt: Long @createdTime
204
- updatedAt: Long @updatedTime
233
+ type Reading @table(randomAccessFields: true) {
234
+ id: ID @primaryKey
235
+ sensorId: String @indexed
236
+ celsius: Float
237
+ recordedAt: Int
205
238
  }
206
239
  ```
207
240
 
208
- **`config.yaml` schema registration:**
241
+ **`config.yaml` loading the schema:**
209
242
 
210
243
  ```yaml
211
244
  graphqlSchema:
@@ -214,25 +247,29 @@ graphqlSchema:
214
247
 
215
248
  #### Notes
216
249
 
217
- - Use unique `database` names in plugins and applications to avoid table naming collisions, since all tables default to the `"data"` database.
218
- - Disabling replication (`replicate: false`) and re-enabling it later will not catch up on writes made while replication was disabled.
219
- - `cacheControl` emits the header only on anonymous (unauthenticated) GET/HEAD `200`/`304` responses. Authenticated responses receive `Cache-Control: private, no-cache` regardless of the declaration. The header is never emitted on `401` responses.
220
- - `@expiresAt` requires an absolute Unix epoch millisecond timestamp, not a duration. Negative values are ignored and fall back to the table default. A full-record `put` that omits the field clears it; a `patch` preserves it.
221
- - Eviction removes non-indexed record data but does not remove a record from its secondary indexes; indexes remain functional for evicted records, with full records fetched on demand.
222
- - `scanInterval` is clock-aligned to the server's local timezone, not startup-aligned — the server's startup time does not affect when eviction runs.
223
- - `randomAccessFields` pins the table's encoding at creation time. Editing the argument later does not repin an existing table. Omit it to follow the global `storage.randomAccessFields` setting.
250
+ - Schemas are flexible by default — records may include additional properties. Use `@sealed` to prevent this.
251
+ - `@export` is a routing directive, not an access control mechanism. Use RBAC and `attribute_permissions` to restrict data access.
252
+ - `cacheControl` on `@table` only emits the header for anonymous (unauthenticated) GET/HEAD 200/304 responses. Authenticated responses always receive `Cache-Control: private, no-cache`.
253
+ - `expiration`, `eviction`, and `scanInterval` work together: `expiration` marks a record stale, `eviction` controls physical removal, and `scanInterval` sets how often the sweep runs. Eviction scans are clock-aligned to the server's local timezone, not startup-aligned.
254
+ - Eviction removes non-indexed record data but does not remove a record from its secondary indexes; indexes remain functional for evicted records.
255
+ - `replicaExpiration` and `replicaRefreshFraction` apply only on the RocksDB engine. On LMDB, remote reads remain proxy reads regardless of these settings.
256
+ - Disabling replication on a table (`replicate: false`) and re-enabling it later will not catch up on writes made while replication was disabled.
257
+ - `randomAccessFields` on `@table` pins the encoding when the table is created. Editing the argument later does not repin an existing table.
258
+ - `@hidden` is a metadata-visibility directive only — it does not restrict data access. Use `attribute_permissions` on roles for field-level access control.
224
259
 
225
260
  ### 1.3 Defining Relationships Between Tables in Harper
226
261
 
227
- Instructions for the agent to follow when defining and querying relationships between tables in Harper using the `@relationship` directive.
262
+ Instructions for the agent to define and query relationships between Harper tables using the `@relationship` directive in GraphQL schemas.
263
+
264
+ **Instead of:** ORM relations and join tables (Prisma, Drizzle, Sequelize). Use this rule rather than adding that service. See `harper-architecture`.
228
265
 
229
266
  #### When to Use
230
267
 
231
- Apply this rule when adding foreign key relationships between schema tables, enabling join queries, or returning nested related records in query results. Use it any time a schema type needs to reference records in another table via a foreign key attribute.
268
+ Apply this rule when writing Harper schemas that require join queries across tables, or when query results must include related records as nested properties. Use it whenever a foreign key exists in one table that references another, or when modeling one-to-many, many-to-one, or many-to-many associations.
232
269
 
233
270
  #### How It Works
234
271
 
235
- 1. **Use `@relationship(from: attribute)` for many-to-one or many-to-many**: Place this on the field in the table that holds the foreign key. The `from` parameter names the attribute on this table that stores the foreign key referencing the target table's primary key.
272
+ 1. **Declare a many-to-one relationship with `from`**: Place `@relationship(from: attribute)` on a field when the foreign key lives in the current table and references the primary key of the target table. The result type is a single object.
236
273
 
237
274
  ```graphql
238
275
  type RealityShow @table @export {
@@ -248,17 +285,7 @@ Apply this rule when adding foreign key relationships between schema tables, ena
248
285
  }
249
286
  ```
250
287
 
251
- If the foreign key attribute is an array, the relationship becomes many-to-many:
252
-
253
- ```graphql
254
- type RealityShow @table @export {
255
- id: Long @primaryKey
256
- networkIds: [Long] @indexed
257
- networks: [Network] @relationship(from: networkIds)
258
- }
259
- ```
260
-
261
- 2. **Use `@relationship(to: attribute)` for one-to-many or many-to-many**: Place this on the table whose primary key is referenced by the foreign key in the target table. The `to` parameter names the attribute on the target table that holds the foreign key. The result type **must** be an array.
288
+ 2. **Declare a one-to-many relationship with `to`**: Place `@relationship(to: attribute)` on a field when the foreign key lives in the target table and references the primary key of the current table. The result type **must** be an array.
262
289
 
263
290
  ```graphql
264
291
  type Network @table @export {
@@ -268,14 +295,14 @@ Apply this rule when adding foreign key relationships between schema tables, ena
268
295
  }
269
296
  ```
270
297
 
271
- 3. **Use `@relationship(from: attribute, to: attribute)` for foreign key to foreign key joins**: Specify both `from` and `to` when neither side uses the primary key. Harper resolves the relationship by searching the target table's `to` attribute for matches using this record's `from` attribute value. The result type must be an array.
298
+ 3. **Declare a foreign-key-to-foreign-key relationship with `@relationship(from: attribute, to: attribute)`**: Specify both `from` and `to` when neither side uses the primary key. Harper resolves the relationship by searching the target table's `to` attribute for matches using the current record's `from` attribute value. The result type must be an array.
272
299
 
273
300
  ```graphql
274
301
  type OrderItem @table @export {
275
302
  id: Long @primaryKey
276
303
  orderId: Long @indexed
277
304
  productSku: Long @indexed
278
- products: [Product] @relationship(from: productSku, to: sku)
305
+ products: [Product] @relationship(from: productSku, to: sku) # matches products by sku, not primary key
279
306
  }
280
307
 
281
308
  type Product @table @export {
@@ -285,14 +312,25 @@ Apply this rule when adding foreign key relationships between schema tables, ena
285
312
  }
286
313
  ```
287
314
 
288
- 4. **Query across relationships using dot-syntax**: Filter records by related table attributes using chained dot notation. This behaves as an INNER JOIN — only records with a matching related record are returned.
315
+ 4. **Declare a many-to-many relationship using an array foreign key with `from`**: When the foreign key field is an array, `@relationship(from: attribute)` establishes a many-to-many relationship. The result type must be an array.
316
+
317
+ ```graphql
318
+ type RealityShow @table @export {
319
+ id: Long @primaryKey
320
+ networkIds: [Long] @indexed
321
+ networks: [Network] @relationship(from: networkIds)
322
+ }
323
+ ```
324
+
325
+ 5. **Query across relationships using dot-syntax**: Filter records by related table attributes using dot-chained attribute names. This behaves as an INNER JOIN — only parents with a qualifying related record are returned.
289
326
 
290
327
  ```
328
+ GET /RealityShow?network.name=Bravo
291
329
  GET /Product/?brand.name=Microsoft
292
330
  GET /Brand/?products.name=Keyboard
293
331
  ```
294
332
 
295
- 5. **Include relationship fields in results using `select()`**: Relationship attributes are not returned by default. Use `select()` to include them, optionally specifying nested fields with `{}`.
333
+ 6. **Include relationship fields in results using `select()`**: Relationship attributes are not included by default. Use `select()` to project them, optionally with a nested field list using `{}`.
296
334
 
297
335
  ```
298
336
  GET /Product/?brand.name=Microsoft&select(name,brand)
@@ -300,24 +338,27 @@ Apply this rule when adding foreign key relationships between schema tables, ena
300
338
  GET /Product/?name=Keyboard&select(name,brand{name,id})
301
339
  ```
302
340
 
303
- When selecting a relationship without filtering on it, Harper performs a LEFT JOIN — the relationship property is omitted if the foreign key is null or references a non-existent record.
341
+ Selecting a relationship without an outer dot-syntax filter on it behaves as a LEFT JOIN: parents remain even when there are no related records.
304
342
 
305
- 6. **Model many-to-many without a junction table**: Store an array of foreign key values and use `@relationship(from: ...)` pointing to that array attribute. The array order of the foreign key values is preserved when resolving the relationship.
343
+ 7. **Filter and page nested relationship results using `[]` inside `select()`**: Apply conditions, sort, and pagination to each parent's related records by placing them inside square brackets in the `select()` expression.
306
344
 
307
- ```graphql
308
- type Product @table @export {
309
- id: Long @primaryKey
310
- name: String
311
- resellerIds: [Long] @indexed
312
- resellers: [Reseller] @relationship(from: "resellerIds")
313
- }
314
345
  ```
346
+ GET /Brand/?select(id,name,products[price=ge=number:30&sort(price)&limit(1)&select(id,name,price)])
347
+ ```
348
+
349
+ An outer relationship filter (e.g., `products.price=ge=number:30`) removes parents without a qualifying child **and** narrows the nested candidates. A filter inside `products[...]` only narrows projected children without removing their parent.
315
350
 
316
- 7. **Define self-referential relationships** for parent-child hierarchies by pointing `@relationship` back at the same table type.
351
+ | Nested option | Behavior |
352
+ | ------------------ | -------------------------------------------------------------------------- |
353
+ | Conditions | Qualify children; combine with `&` (AND), `\|` (OR), and groups. |
354
+ | `sort(price)` | Order eligible children before pagination. |
355
+ | `limit(end)` | Return at most `end` children per parent. |
356
+ | `limit(start,end)` | Skip `start` eligible children and return at most `end - start`. |
357
+ | `select(id,price)` | Project fields from returned children; omit to return whole child records. |
317
358
 
318
359
  #### Examples
319
360
 
320
- **Full schema with bidirectional relationships:**
361
+ ##### Schema with bidirectional relationships
321
362
 
322
363
  ```graphql
323
364
  type Product @table @export {
@@ -334,30 +375,88 @@ type Brand @table @export {
334
375
  }
335
376
  ```
336
377
 
337
- **Querying with joins and nested select:**
378
+ ##### Many-to-many without a junction table
338
379
 
380
+ ```graphql
381
+ type Product @table @export {
382
+ id: Long @primaryKey
383
+ name: String
384
+ resellerIds: [Long] @indexed
385
+ resellers: [Reseller] @relationship(from: "resellerIds")
386
+ }
339
387
  ```
340
- GET /Product/?brand.name=Microsoft&select(name,brand{name,id})
341
- GET /Brand/?products.name=Keyboard
388
+
389
+ ```
390
+ GET /Product/?resellers.name=Cool Shop&select(id,name,resellers{name,id})
391
+ ```
392
+
393
+ The array order of `resellerIds` is preserved when resolving the relationship.
394
+
395
+ ##### Nested filtering and pagination scenarios
396
+
397
+ Schema for the following examples:
398
+
399
+ ```graphql
400
+ type Brand @table @export {
401
+ id: Int @primaryKey
402
+ name: String @indexed
403
+ products: [Product] @relationship(to: "brandId")
404
+ }
405
+
406
+ type Product @table @export {
407
+ id: ID @primaryKey
408
+ brandId: Int @indexed
409
+ name: String
410
+ price: Int @indexed
411
+ }
342
412
  ```
343
413
 
344
- **Many-to-many query with nested select:**
414
+ **Keep every brand; show its cheapest product costing at least 30** (LEFT JOIN behavior — Budget Basics returns with an empty product list):
345
415
 
346
416
  ```
347
- GET /Product/?resellers.name=Cool Shop&select(id,name,resellers{name,id})
417
+ GET /Brand/?select(id,name,products[price=ge=number:30&sort(price)&limit(1)&select(id,name,price)])
418
+ ```
419
+
420
+ **Return only qualifying brands; inherit the outer price filter** (INNER JOIN behavior — Budget Basics is removed):
421
+
422
+ ```
423
+ GET /Brand/?products.price=ge=number:30&select(id,name,products[sort(price)&limit(1)&select(id,name,price)])
424
+ ```
425
+
426
+ **Page through each brand's qualifying products** (offset applies independently per brand):
427
+
428
+ ```
429
+ GET /Brand/?products.price=ge=number:30&select(id,name,products[sort(price)&limit(1,2)&select(id,name,price)])
430
+ ```
431
+
432
+ **Bound parent pages with stable sorting**:
433
+
434
+ ```
435
+ GET /Brand/?products.price=ge=number:30&sort(name,id)&limit(0,20)&select(id,name,products[sort(price,id)&limit(1)&select(id,name,price)])
436
+ ```
437
+
438
+ **Combine OR filters with child sorting and pagination**:
439
+
440
+ ```
441
+ GET /Brand/?sort(name,id)&limit(0,20)&select(id,name,products[[price=lt=number:30|price=ge=number:60]&sort(-price,id)&limit(1,2)&select(id,name,price)])
348
442
  ```
349
443
 
350
444
  #### Notes
351
445
 
352
- - Every attribute named in `from` or `to` must exist on the respective table and be annotated with `@indexed` to support join queries.
353
- - The `to`-only and `from`+`to` forms both require the result field type to be an array (e.g., `[RealityShow]`).
354
- - The `from`-only form on a non-array attribute produces a many-to-one relationship; on an array attribute it produces many-to-many.
355
- - Self-referential relationships are supported for hierarchical data within a single table.
446
+ - Index all foreign key fields (e.g., `brandId`, `price`) and outer filter fields to support efficient join queries.
447
+ - Use a unique field as the final sort key (e.g., `sort(price,id)`) to ensure deterministic ordering when values are equal.
448
+ - A nested `limit()` bounds children per parent, not the number of parents. Apply a separate outer `limit()` to bound the parent result set.
449
+ - Nested filters require a table-backed relationship. Filters on plain JSON projections or full-text predicates inside a relationship selection return HTTP 400.
450
+ - Invalid `limit()` bounds (negative or fractional values) return HTTP 400. `limit(0)` returns no children without reading them.
451
+ - A condition traversing another relationship qualifies the immediate child only; it does not narrow that child's projected descendants. Place grandchild constraints in the grandchild's own bracket selection.
452
+ - Schemas can define self-referential relationships to enable parent-child hierarchies within a single table.
356
453
 
357
454
  ### 1.4 Vector Indexing
358
455
 
359
456
  Instructions for the agent to enable HNSW vector indexes on table fields and query them for similarity search in Harper.
360
457
 
458
+ **Instead of:** Pinecone, Weaviate, pgvector. Use this rule rather than adding that service. See `harper-architecture`.
459
+
361
460
  #### When to Use
362
461
 
363
462
  Apply this rule when adding a vector similarity search capability to a Harper table — for example, storing text embeddings and querying for nearest neighbors, filtering by distance threshold, or combining vector search with record-level access control. See [adding-tables-with-schemas.md](adding-tables-with-schemas.md) for how to define the surrounding table schema.
@@ -549,6 +648,8 @@ let results = Document.search(
549
648
 
550
649
  Instructions for the agent to follow when storing and retrieving large binary content using the `Blob` data type in Harper.
551
650
 
651
+ **Instead of:** S3 or GCS buckets for app-managed files. Use this rule rather than adding that service. See `harper-architecture`.
652
+
552
653
  #### When to Use
553
654
 
554
655
  Apply this rule when a schema field needs to store large binary content such as images, video, audio, or large HTML — typically content larger than 20KB. Use `Blob` instead of `Bytes` when streaming support and out-of-record storage are required. See [handling-binary-data](handling-binary-data.md) for broader binary data guidance.
@@ -651,6 +752,8 @@ await MyTable.put({ id: 'my-record', data: blob });
651
752
 
652
753
  Instructions for the agent to follow when storing and serving binary data (images, audio, arbitrary content types) in Harper.
653
754
 
755
+ **Instead of:** S3 or GCS buckets for app-managed files. Use this rule rather than adding that service. See `harper-architecture`.
756
+
654
757
  #### When to Use
655
758
 
656
759
  Apply this rule when a Harper resource needs to accept, store, or serve binary payloads such as images, audio files, or calendar data. Use it when clients send raw binary via `PUT`/`POST`, or when they send `base64`-encoded data inside a JSON body.
@@ -767,6 +870,8 @@ export class Photo extends tables.Photo {
767
870
 
768
871
  Instructions for the agent to follow when using Harper's automatically generated REST and WebSocket APIs for exported tables and resources.
769
872
 
873
+ **Instead of:** Hand-written CRUD routes (Express, Fastify, Next.js API routes). Use this rule rather than adding that service. See `harper-architecture`.
874
+
770
875
  #### When to Use
771
876
 
772
877
  Apply this rule when enabling HTTP REST endpoints or WebSocket subscriptions for Harper tables and custom resources. Use it whenever you need to configure the REST plugin, understand the auto-generated endpoint surface, or wire up real-time WebSocket connections. See [querying-rest-apis.md](querying-rest-apis.md) for query syntax and [real-time-apps.md](real-time-apps.md) for real-time patterns.
@@ -958,30 +1063,25 @@ export class InternalLog extends Resource {
958
1063
  - Server-Sent Events subscriptions are served on the same paths, negotiated via `Accept: text/event-stream`; they are not affected by the `webSocket` option.
959
1064
  - See [querying-rest-apis.md](querying-rest-apis.md) for full query syntax on collection endpoints and [real-time-apps.md](real-time-apps.md) for real-time subscription patterns.
960
1065
 
961
- ### 2.2 Querying REST APIs
1066
+ ### 2.2 Querying Harper REST APIs
962
1067
 
963
- Instructions for the agent to filter, sort, select, and paginate records through Harper's URL-based REST query language.
1068
+ Instructions for the agent to filter, sort, select, and paginate records using Harper's URL-based query language on REST collection endpoints.
964
1069
 
965
1070
  #### When to Use
966
1071
 
967
- Apply this rule whenever you need to construct or handle GET requests against Harper collection endpoints that require filtering by attribute value, comparison operators, sorting, field selection, or paginated results. This rule also covers type coercion syntax and relationship joins via dot notation. See [automatic-apis.md](automatic-apis.md) for how REST endpoints are generated from schemas.
1072
+ Apply this rule whenever building or modifying REST requests against Harper collection paths that need attribute filtering, comparison operators, sorting, field selection, pagination, or relationship joins. This rule covers all query parameter construction for Harper's automatic REST APIs (see [automatic-apis.md](automatic-apis.md)).
968
1073
 
969
1074
  #### How It Works
970
1075
 
971
- 1. **Filter by attribute**: Add query parameters matching attribute names and values. The queried attribute must be indexed.
1076
+ 1. **Filter by attribute**: Add query parameters matching attribute name to value. The queried attribute must be indexed.
972
1077
 
973
1078
  ```
974
1079
  GET /Product/?category=software
975
1080
  GET /Product/?category=software&inStock=true
976
- ```
977
-
978
- To filter for null values:
979
-
980
- ```
981
1081
  GET /Product/?discount=null
982
1082
  ```
983
1083
 
984
- 2. **Apply comparison operators (FIQL syntax)**: Use FIQL operators in the query string for range and pattern matching.
1084
+ 2. **Apply comparison operators (FIQL syntax)**: Use FIQL operators in the query string for range and string comparisons.
985
1085
 
986
1086
  | Operator | Meaning |
987
1087
  | -------------------- | -------------------------------------- |
@@ -1002,21 +1102,18 @@ Apply this rule whenever you need to construct or handle GET requests against Ha
1002
1102
  GET /Product/?price=le=20
1003
1103
  GET /Product/?name==Keyboard*
1004
1104
  GET /Product/?category=software&price=gt=100&price=lt=200
1005
- ```
1006
-
1007
- For date fields, URL-encode colons as `%3A`:
1008
-
1009
- ```
1010
1105
  GET /Product/?listDate=gt=2017-03-08T09%3A30%3A00.000Z
1011
1106
  ```
1012
1107
 
1013
- 3. **Chain conditions for range queries**: Omit the attribute name on the second condition to apply it to the same attribute. Only `gt`/`ge` combined with `lt`/`le` is supported.
1108
+ 3. **Chain conditions for range queries**: Omit the attribute name on the second condition to apply it to the same attribute.
1014
1109
 
1015
1110
  ```
1016
1111
  GET /Product/?price=gt=100&lt=200
1017
1112
  ```
1018
1113
 
1019
- 4. **Apply type conversion**: For FIQL comparators (`==`, `!=`, `=gt=`, etc.), Harper converts values automatically. Use explicit type prefixes to force a specific type.
1114
+ Chaining supports `gt`/`ge` combined with `lt`/`le` only.
1115
+
1116
+ 4. **Apply type conversion**: For FIQL comparators, Harper converts values automatically. Use explicit type prefixes when needed.
1020
1117
 
1021
1118
  | Syntax | Behavior |
1022
1119
  | ----------------------------------------- | ------------------------------------------- |
@@ -1036,80 +1133,83 @@ Apply this rule whenever you need to construct or handle GET requests against Ha
1036
1133
  GET /Product/?rating=5|featured=true
1037
1134
  ```
1038
1135
 
1039
- 6. **Group conditions**: Use parentheses or square brackets to control evaluation order. Prefer square brackets when building queries from user input, since standard URI encoding safely encodes `[` and `]`.
1136
+ 6. **Group conditions**: Use parentheses or square brackets to control order of operations. Prefer square brackets when constructing queries from user input.
1040
1137
 
1041
1138
  ```
1042
1139
  GET /Product/?rating=5|(price=gt=100&price=lt=200)
1043
1140
  GET /Product/?rating=5&[tag=fast|tag=scalable|tag=efficient]
1044
1141
  ```
1045
1142
 
1046
- Build grouped queries in JavaScript:
1143
+ Construct from JavaScript:
1047
1144
 
1048
1145
  ```javascript
1049
1146
  let url = `/Product/?rating=5&[${tags.map(encodeURIComponent).join('|')}]`;
1050
1147
  ```
1051
1148
 
1052
- 7. **Select specific properties with `select()`**: Append `select()` as a query function separated by `&`.
1149
+ 7. **Select specific properties with `select()`**: Specify which properties to include in the response.
1053
1150
 
1054
- | Syntax | Returns |
1055
- | -------------------------------------- | ------------------------------------------- |
1056
- | `?select(property)` | Values of a single property directly |
1057
- | `?select(property1,property2)` | Objects with only the specified properties |
1058
- | `?select([property1,property2])` | Arrays of property values |
1059
- | `?select(property1,)` | Objects with a single specified property |
1060
- | `?select(property{subProp1,subProp2})` | Nested objects with specific sub-properties |
1151
+ | Syntax | Returns |
1152
+ | -------------------------------------- | ------------------------------------------------- |
1153
+ | `?select(property)` | Values of a single property directly |
1154
+ | `?select(property1,property2)` | Objects with only the specified properties |
1155
+ | `?select([property1,property2])` | Arrays of property values |
1156
+ | `?select(property1,)` | Objects with a single specified property |
1157
+ | `?select(property{subProp1,subProp2})` | Nested objects with specific sub-properties |
1158
+ | `?select(id,products[limit(1)])` | Parents with a constrained relationship selection |
1061
1159
 
1062
1160
  ```
1063
1161
  GET /Product/?category=software&select(name)
1064
1162
  GET /Product/?brand.name=Microsoft&select(name,brand{name})
1065
1163
  ```
1066
1164
 
1067
- 8. **Limit results with `limit(end)` or `limit(start,end)`**: Append as a query function.
1165
+ 8. **Limit results with `limit(end)` or `limit(start,end)`**: Return records in a zero-based window from `start` (inclusive) to `end` (exclusive). `limit(end)` starts at zero.
1068
1166
 
1069
1167
  ```
1070
1168
  GET /Product/?rating=gt=3&inStock=true&select(rating,name)&limit(20)
1071
1169
  GET /Product/?rating=gt=3&limit(10,30)
1072
1170
  ```
1073
1171
 
1074
- 9. **Sort results with `sort(property)` or `sort(+property,-property,...)`**: Prefix `+` or no prefix = ascending; `-` = descending. List multiple properties to break ties in order.
1172
+ 9. **Sort results with `sort(property)` or `sort(+property,-property,...)`**: Prefix `+` or no prefix = ascending; `-` = descending. Multiple properties break ties in order.
1075
1173
 
1076
1174
  ```
1077
1175
  GET /Product/?rating=gt=3&sort(+name)
1078
1176
  GET /Product/?sort(+rating,-price)
1079
1177
  ```
1080
1178
 
1081
- 10. **Paginate with a total count**: Use `limit(start,end)` for paging and send a `Prefer` request header to receive a total match count.
1179
+ 10. **Request a total count for pagination**: Send a `Prefer` header on `GET` or `HEAD` requests. Counting requires a bounded `limit()` with page size no larger than 10,000 and window end no larger than 1,000,000.
1082
1180
 
1083
- | `Prefer` value | Meaning |
1084
- | ----------------- | ---------------------------------------- |
1085
- | `count=exact` | Exact count of matching records (opt-in) |
1086
- | `count=estimated` | Fast approximate count |
1181
+ | `Prefer` value | Meaning |
1182
+ | ----------------- | --------------------------------------------------------------------------------------------- |
1183
+ | `count=exact` | Exact number of matching records. Off by default; requires `exactCount: true` in REST config. |
1184
+ | `count=estimated` | Fast approximate count. Does not require `exactCount`. |
1087
1185
 
1088
1186
  ```
1089
1187
  GET /Product/?category=software&limit(0,25)
1090
1188
  Prefer: count=exact
1091
1189
  ```
1092
1190
 
1093
- The server responds with:
1191
+ Response headers:
1094
1192
 
1095
- | Header | Example | Description |
1096
- | -------------------- | ----------------- | ------------------------------------------------------ |
1097
- | `Content-Range` | `items 0-24/1234` | 0-based inclusive range out of total matching records |
1098
- | `Range-Unit` | `items` | Unit used by `Content-Range` |
1099
- | `Preference-Applied` | `count=exact` | Count mode the server applied (`exact` or `estimated`) |
1100
-
1101
- The response status is always `200`. When the total cannot be produced, it is reported as `*` (e.g., `Content-Range: items 0-24/*`).
1193
+ ```
1194
+ HTTP/1.1 200 OK
1195
+ Content-Range: items 0-24/1234
1196
+ Range-Unit: items
1197
+ Preference-Applied: count=exact
1198
+ ```
1102
1199
 
1103
- Enable exact counts in the application's REST configuration:
1200
+ Enable exact counts in REST configuration:
1104
1201
 
1105
1202
  ```yaml
1106
1203
  rest:
1107
1204
  exactCount: true
1108
1205
  ```
1109
1206
 
1110
- Without this, a `count=exact` request is served as an estimate.
1207
+ - The response status for paginated requests is always `200`; Harper does not use `206 Partial Content`.
1208
+ - A `HEAD` request with a `count` preference returns the count headers with no body.
1209
+ - When CORS is enabled, `Content-Range`, `Range-Unit`, and `Preference-Applied` are added to `Access-Control-Expose-Headers`.
1210
+ - Without `exactCount: true`, a `count=exact` request is served as an estimate. When the total cannot be produced, it is reported as `*` (e.g., `Content-Range: items 0-24/*`). `Preference-Applied` still echoes the requested mode, so an unavailable total (`.../*`) is distinct from a request that asked for no count.
1111
1211
 
1112
- 11. **Query across relationships using dot syntax**: Define relationships in the schema with `@relationship`, then filter and select across them.
1212
+ 11. **Query across relationships**: Use dot-syntax on attributes defined with `@relationship` in the schema.
1113
1213
 
1114
1214
  ```
1115
1215
  GET /Product/?brand.name=Microsoft
@@ -1117,32 +1217,47 @@ Apply this rule whenever you need to construct or handle GET requests against Ha
1117
1217
  GET /Product/?brand.name=Microsoft&select(name,brand{name})
1118
1218
  ```
1119
1219
 
1120
- Filtering on a related attribute produces INNER JOIN behavior. Selecting a relationship without filtering produces LEFT JOIN behavior — the property is omitted if the foreign key is null or references a non-existent record.
1220
+ Selecting a relationship without an outer dot-syntax filter behaves as a LEFT JOIN. An outer filter such as `products.price=ge=number:30` removes parents without a qualifying product and also narrows the selected products.
1121
1221
 
1122
- 12. **Access a specific property by URL**: Append the property name with dot syntax to the record ID.
1222
+ 12. **Filter and page nested relationships**: Use square brackets inside `select()` to apply conditions, sort, and pagination to each parent's related records.
1123
1223
 
1124
1224
  ```
1125
- GET /MyTable/123.propertyName
1225
+ GET /Brand/?select(id,name,products[price=ge=number:30&sort(price)&limit(1)&select(id,name,price)])
1126
1226
  ```
1127
1227
 
1128
- This only works for declared schema properties. The suffixes `.json`, `.cbor`, `.msgpack`, and `.csv` are reserved as content-type selectors.
1228
+ An outer relationship filter also narrows that relationship's nested results — do not repeat the outer filter inside `[...]` unless you want to narrow further.
1229
+
1230
+ | Nested option | Behavior |
1231
+ | ------------------ | --------------------------------------------------------------- |
1232
+ | Conditions | Qualify children; combine with `&` (AND), `\|` (OR), and groups |
1233
+ | `sort(price)` | Order eligible children before pagination |
1234
+ | `limit(end)` | Return at most `end` children per parent |
1235
+ | `limit(start,end)` | Skip `start` eligible children and return at most `end - start` |
1236
+ | `select(id,price)` | Project fields from returned children |
1237
+ - Invalid nested bounds (negative or fractional values) return HTTP 400. `limit(0)` returns no children without reading them.
1129
1238
 
1130
1239
  #### Examples
1131
1240
 
1132
- **Range filter with select and sort:**
1241
+ ##### Range filter with sort and limit
1242
+
1243
+ ```
1244
+ GET /Product/?category=software&price=gt=100&price=lt=200&sort(+price)&limit(0,25)
1245
+ ```
1246
+
1247
+ ##### Relationship join with nested selection
1133
1248
 
1134
1249
  ```
1135
- GET /Product/?category=software&price=gt=100&price=lt=200&select(name,price)&sort(+price)
1250
+ GET /Product/?brand.name=Microsoft&select(name,brand{name,id})
1136
1251
  ```
1137
1252
 
1138
- **Paginated request with exact count:**
1253
+ ##### Paginated request with exact count
1139
1254
 
1140
1255
  ```
1141
1256
  GET /Product/?category=software&limit(0,25)
1142
1257
  Prefer: count=exact
1143
1258
  ```
1144
1259
 
1145
- Response headers:
1260
+ Response:
1146
1261
 
1147
1262
  ```
1148
1263
  HTTP/1.1 200 OK
@@ -1151,27 +1266,21 @@ Range-Unit: items
1151
1266
  Preference-Applied: count=exact
1152
1267
  ```
1153
1268
 
1154
- **Relationship schema and join query:**
1269
+ ##### Nested relationship filter with OR, sort, and pagination
1155
1270
 
1156
- ```graphql
1157
- type Product @table @export {
1158
- id: Long @primaryKey
1159
- name: String
1160
- brandId: Long @indexed
1161
- brand: Brand @relationship(from: "brandId")
1162
- }
1163
- type Brand @table @export {
1164
- id: Long @primaryKey
1165
- name: String
1166
- products: [Product] @relationship(to: "brandId")
1167
- }
1271
+ ```
1272
+ GET /Brand/?sort(name,id)&limit(0,20)&select(id,name,products[[price=lt=number:30|price=ge=number:60]&sort(-price,id)&limit(1,2)&select(id,name,price)])
1168
1273
  ```
1169
1274
 
1275
+ ##### Outer filter inheriting into nested selection
1276
+
1170
1277
  ```
1171
- GET /Product/?brand.name=Microsoft&select(name,brand{name,id})
1278
+ GET /Brand/?products.price=ge=number:30&select(id,name,products[sort(price)&limit(1)&select(id,name,price)])
1172
1279
  ```
1173
1280
 
1174
- **Many-to-many relationship:**
1281
+ ##### Many-to-many relationship query
1282
+
1283
+ Schema:
1175
1284
 
1176
1285
  ```graphql
1177
1286
  type Product @table @export {
@@ -1182,29 +1291,31 @@ type Product @table @export {
1182
1291
  }
1183
1292
  ```
1184
1293
 
1294
+ Query:
1295
+
1185
1296
  ```
1186
1297
  GET /Product/?resellers.name=Cool Shop&select(id,name,resellers{name,id})
1187
1298
  ```
1188
1299
 
1189
- **OR grouping with user-supplied tags:**
1190
-
1191
- ```javascript
1192
- let url = `/Product/?rating=5&[${tags.map(encodeURIComponent).join('|')}]`;
1193
- ```
1194
-
1195
1300
  #### Notes
1196
1301
 
1197
- - The queried attribute must be indexed for basic attribute filtering to execute. For multi-attribute queries, only one attribute needs to be indexed.
1198
- - Null indexing requires indexes created after the feature was introduced. Rebuild existing indexes (remove and re-add) to support `name==null` queries.
1199
- - `limit()` must be a non-negative integer no larger than **10,000**, and the requested window (offset + limit) no larger than **1,000,000**, for count headers to be returned.
1200
- - A `HEAD` request with a `Prefer: count=exact` header returns count headers with no body — it saves bandwidth but still scans the matched set.
1201
- - When CORS is enabled, `Content-Range`, `Range-Unit`, and `Preference-Applied` are added to `Access-Control-Expose-Headers` automatically.
1202
- - Operators `!=` and `=ct=` (contains) do not produce cardinality estimates; the total is reported as `*` when these are used with `count=exact`.
1302
+ - Queried attributes must be indexed for filtering to execute; only one attribute in a multi-attribute query needs to be indexed.
1303
+ - URL-encode colons in date values as `%3A` (e.g., `listDate=gt=2017-03-08T09%3A30%3A00.000Z`).
1304
+ - Square brackets are preferred over parentheses when constructing queries programmatically because standard URI encoding safely encodes `[` and `]`.
1305
+ - Brackets inside `select()` after a relationship name (e.g., `select(id,products[limit(1)])`) form a nested relationship query, not a grouping expression.
1306
+ - For full-text fields marked `@fullText`, use operators such as `=matches=`, `=matches_phrase=`, `=matches_prefix=`, `=matches_fuzzy=`, and their `=not_*=` negated forms. Full-text queries cannot provide an exact total count.
1307
+ - `limit(end)` and `limit(start,end)` inside a nested relationship selection apply per parent, not to the total result set.
1308
+ - Always add a unique field as the final sort key (e.g., `sort(name,id)`) to ensure deterministic ordering across pages.
1309
+ - Nested filters require a table-backed relationship; filters on plain JSON projections or full-text predicates inside a relationship selection return HTTP 400.
1310
+ - For streaming JSON-array responses, check for a terminal object shaped like `{ "error": "ErrorName: message" }` and treat the response as failed.
1311
+ - Access a specific property of a record using dot syntax on the record ID: `GET /MyTable/123.propertyName`. This only works for declared schema properties. The suffixes `.json`, `.cbor`, `.msgpack`, and `.csv` are reserved as content-type selectors and take precedence over a property of the same name.
1203
1312
 
1204
1313
  ### 2.3 Real-Time Apps with WebSockets and Pub/Sub
1205
1314
 
1206
1315
  Instructions for the agent to follow when building real-time features in Harper using WebSockets and Pub/Sub.
1207
1316
 
1317
+ **Instead of:** Socket.io, `ws`, Pusher, Ably, Redis pub/sub, a separate MQTT broker. Use this rule rather than adding that service. See `harper-architecture`.
1318
+
1208
1319
  #### When to Use
1209
1320
 
1210
1321
  Apply this rule when implementing any feature that requires real-time bidirectional communication, live data streaming, or push-based updates in a Harper application. This includes chat, live dashboards, sensor feeds, and any scenario where clients must receive resource changes as they happen.
@@ -1297,6 +1408,8 @@ export class Example extends Resource {
1297
1408
 
1298
1409
  Instructions for the agent to handle user authentication, sessions, and JWT token issuance in Harper Resources.
1299
1410
 
1411
+ **Instead of:** Hand-rolled sessions, `passport`, `jsonwebtoken`, `bcrypt`. Use this rule rather than adding that service. See `harper-architecture`.
1412
+
1300
1413
  #### When to Use
1301
1414
 
1302
1415
  Apply this rule when implementing login/logout flows, reading the current authenticated user, issuing or refreshing JWT tokens, or minting scoped tokens from a custom Resource. Use it whenever a Resource must gate behavior on identity or credentials. See [custom-resources.md](custom-resources.md) for the broader Resource authoring model.
@@ -1603,6 +1716,8 @@ curl --location --request POST 'http://localhost:9925' \
1603
1716
 
1604
1717
  Instructions for the agent to follow when defining custom REST endpoints with JavaScript or TypeScript in Harper.
1605
1718
 
1719
+ **Instead of:** Express, Fastify, or Koa route handlers; serverless functions. Use this rule rather than adding that service. See `harper-architecture`.
1720
+
1606
1721
  #### When to Use
1607
1722
 
1608
1723
  Apply this rule when you need to create custom HTTP endpoints, wrap external APIs, or register routes programmatically in a Harper application. Use it any time business logic must live outside a table-backed schema, or when you need fine-grained control over URL shape and path parameters.
@@ -1773,6 +1888,8 @@ server.resources.set('my-path', Foo);
1773
1888
 
1774
1889
  Instructions for the agent to follow when adding custom logic to automatically generated table resources in Harper.
1775
1890
 
1891
+ **Instead of:** Middleware and controllers wrapped around CRUD routes. Use this rule rather than adding that service. See `harper-architecture`.
1892
+
1776
1893
  #### When to Use
1777
1894
 
1778
1895
  Apply this rule when you need to override or augment the default HTTP handler behavior (GET, POST, PUT, PATCH, DELETE) for a Harper table resource. Use it when a table needs computed properties, input transformation, authorization checks, or custom error responses.
@@ -1867,149 +1984,155 @@ export class MyTable extends tables.MyTable {
1867
1984
 
1868
1985
  ### 3.3 Programmatic Table Requests
1869
1986
 
1870
- Instructions for the agent to follow when interacting with Harper tables programmatically using the `tables` object.
1987
+ Instructions for the agent to interact with Harper tables programmatically using the `tables` object, the Query API, and transactions.
1988
+
1989
+ **Instead of:** ORM and query-builder clients (Prisma, Drizzle, Knex). Use this rule rather than adding that service. See `harper-architecture`.
1871
1990
 
1872
1991
  #### When to Use
1873
1992
 
1874
- Apply this rule when writing server-side Harper component code that reads from or writes to tables using the programmatic API — for example, in HTTP handlers, background jobs, timers, or SSR entry points. Use it whenever you need to construct queries with `conditions`, `sort`, `select`, pagination, or transactions outside of the REST layer.
1993
+ Apply this rule when writing server-side Harper code that reads, writes, searches, or transactionally modifies table data through the `tables` object. Use it when building background jobs, SSR renderers, or any code that calls `search(`, `addTo`, `select`, `sort`, or `transaction()` directly rather than through REST endpoints.
1875
1994
 
1876
1995
  #### How It Works
1877
1996
 
1878
- 1. **Import `tables` (and other APIs) from `harper`**: Access every table defined in `schema.graphql` as a property of `tables`. Each property is the table class implementing the Resource API.
1997
+ 1. **Import `tables` (and other APIs) from `harper`**: Access every table defined in `schema.graphql` as a named property of `tables`. This is equivalent to `databases.data.<TableName>`.
1879
1998
 
1880
1999
  ```javascript
1881
- import { tables, transaction } from 'harper';
2000
+ import { tables, Resource } from 'harper';
1882
2001
  const { Product } = tables;
1883
2002
  // same as: databases.data.Product
1884
2003
  ```
1885
2004
 
1886
- For components in their own directory, run:
2005
+ CommonJS is also supported: `const { tables, Resource } = require('harper');`
2006
+
2007
+ For components in their own directory, link the package first:
1887
2008
 
1888
2009
  ```bash
1889
2010
  npm link harper
1890
2011
  ```
1891
2012
 
1892
- All installed components have `harper` automatically linked.
2013
+ When bundling for SSR with Vite, keep `harper` external so it resolves to the runtime:
1893
2014
 
1894
- 2. **Define your schema with `@table`**: Every type you want to access via `tables` must carry the `@table` directive. Mark queryable fields with `@indexed`.
2015
+ ```javascript
2016
+ // vite.config
2017
+ ssr: {
2018
+ external: ['harper'];
2019
+ }
2020
+ ```
2021
+
2022
+ 2. **Define your schema with `@table` (and `@indexed` where needed)**: Tables must be declared in `schema.graphql`. Mark attributes that will be used as sort keys or standalone condition attributes with `@indexed`.
1895
2023
 
1896
2024
  ```graphql
1897
2025
  type Product @table {
1898
2026
  id: Long @primaryKey
1899
2027
  name: String
1900
- price: Float
2028
+ price: Float @indexed
1901
2029
  }
1902
2030
  ```
1903
2031
 
1904
- 3. **Query records using `search(`**: Pass a Query object to `search()`. The query is an async iterable.
2032
+ 3. **Query records using `search(`**: Pass a Query object to `search()`. The query is an async iterable; iterate the result with `for await`. Use a `conditions` array to filter records. Each condition specifies `attribute`, `value`, and an optional `comparator`.
2033
+
2034
+ | Property | Description |
2035
+ | ------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
2036
+ | `attribute` | Property name, or array for chained/joined properties |
2037
+ | `value` | The value to match |
2038
+ | `comparator` | `equals` (default), `greater_than`, `greater_than_equal`, `less_than`, `less_than_equal`, `starts_with`, `contains`, `ends_with`, `between`, `not_equal`, or a full-text comparator |
2039
+ | `conditions` | Nested conditions array |
2040
+ | `operator` | `and` (default) or `or` for the nested `conditions` |
1905
2041
 
1906
2042
  ```javascript
1907
- const query = {
2043
+ for await (const record of Product.search({
1908
2044
  conditions: [{ attribute: 'price', comparator: 'less_than', value: 8.0 }],
1909
- };
1910
- for await (const record of Product.search(query)) {
2045
+ })) {
1911
2046
  // process record
1912
2047
  }
1913
2048
  ```
1914
2049
 
1915
- 4. **Build `conditions`**: Each condition object supports the following properties:
2050
+ 4. **Use `select` to control which properties are returned**: Pass an array of property names, a string for a single property, or nested objects for relationships.
2051
+
2052
+ ```javascript
2053
+ const book = await Book.get({
2054
+ id: 42,
2055
+ select: ['id', 'title', { name: 'author', select: ['name'] }],
2056
+ });
2057
+ ```
1916
2058
 
1917
- | Property | Description |
1918
- | ------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------- |
1919
- | `attribute` | Property name, or array for chained/joined properties (e.g. `['brand', 'name']`) |
1920
- | `value` | The value to match |
1921
- | `comparator` | `equals` (default), `greater_than`, `greater_than_equal`, `less_than`, `less_than_equal`, `starts_with`, `contains`, `ends_with`, `between`, `not_equal` |
1922
- | `conditions` | Nested conditions array |
1923
- | `operator` | `and` (default) or `or` for the nested `conditions` |
2059
+ Special `select` properties:
2060
+ - `$id` — returns the primary key regardless of its name
2061
+ - `$updatedtime` — returns the last-updated timestamp
2062
+ - `$distance` — returns computed vector distance when using a vector index
2063
+ - `$score` — returns BM25 relevance score for full-text queries
2064
+ - `$highlights` — returns full-text fragments and matching spans
1924
2065
 
1925
- 5. **Apply `select` to shape results**: Pass an array of property names, a string for a single property, or nested objects for relationships.
2066
+ 5. **Use `sort` with an `@indexed` attribute or combine with `conditions`**: Harper requires either an `@indexed` sort attribute or at least one `conditions` entry when sorting. Sorting by a non-indexed attribute with zero conditions raises:
1926
2067
 
1927
- ```javascript
1928
- // Flat select
1929
- Product.search({ select: ['name', 'price'] });
2068
+ > `HdbError: <attribute> is not indexed and not combined with any other conditions`
1930
2069
 
1931
- // Nested relationship select
1932
- Book.get({ id: 42, select: ['id', 'title', { name: 'author', select: ['name'] }] });
1933
- ```
2070
+ Note: `@primaryKey` alone is treated as not indexed for sort purposes. To iterate a whole table in primary-key order, add an open-ended condition:
1934
2071
 
1935
- Special `select` values: `$id`, `$updatedtime`, `$distance`.
2072
+ ```javascript
2073
+ Product.search({
2074
+ conditions: [{ attribute: 'id', comparator: 'greater_than', value: '' }],
2075
+ sort: { attribute: 'id' },
2076
+ });
2077
+ ```
1936
2078
 
1937
- 6. **Apply `sort`**: Harper uses an index to provide sort order.
2079
+ `sort` object properties:
1938
2080
 
1939
2081
  | Property | Description |
1940
2082
  | ------------ | ---------------------------------------------------------- |
1941
2083
  | `attribute` | Property name (or array for chained relationship property) |
1942
2084
  | `descending` | Sort descending if `true` (default: `false`) |
1943
2085
  | `next` | Secondary sort to resolve ties (same structure) |
1944
- - If the sort `attribute` is `@indexed`, no condition is required.
1945
- - If the sort `attribute` is not indexed, at least one entry in `conditions` (on **any** attribute) is required.
1946
- - Sorting by a non-indexed attribute with zero conditions raises:
1947
- > `HdbError: <attribute> is not indexed and not combined with any other conditions`
1948
- - The bare `@primaryKey` is treated as not indexed for sort purposes. To iterate in primary-key order, add an open-ended condition:
1949
-
1950
- ```javascript
1951
- Product.search({
1952
- conditions: [{ attribute: 'id', comparator: 'greater_than', value: '' }],
1953
- sort: { attribute: 'id' },
1954
- });
1955
- ```
1956
-
1957
- - Pass `allowFullScan: true` to permit an unconditional ordered scan, or omit `sort` entirely to iterate without an index requirement.
1958
2086
 
1959
- 7. **Paginate with `limit` and `offset`**:
2087
+ 6. **Apply `limit` and `offset` for pagination**: Add these to the Query object to bound result sets.
1960
2088
 
1961
2089
  ```javascript
1962
- Product.search({ conditions: [...], limit: 20, offset: 40 });
2090
+ Product.search({
2091
+ conditions: [{ attribute: 'price', comparator: 'less_than', value: 100 }],
2092
+ limit: 20,
2093
+ offset: 40,
2094
+ });
1963
2095
  ```
1964
2096
 
1965
- 8. **Debug with `explain` and `enforceExecutionOrder`**:
2097
+ 7. **Use `explain` and `enforceExecutionOrder` for debugging and optimization**:
1966
2098
  - `explain: true` — returns conditions reordered as Harper will execute them.
1967
2099
  - `enforceExecutionOrder: true` — forces conditions to execute in the order supplied, disabling Harper's automatic re-ordering.
1968
2100
 
1969
- 9. **Use `addTo` for concurrent-safe increments**: `addTo(property, value)` uses CRDT incrementation, safe across threads and nodes.
2101
+ 8. **Use `addTo` for concurrent-safe numeric updates**: Increments or decrements a property using CRDT incrementation, safe across threads and nodes.
1970
2102
 
1971
2103
  ```javascript
1972
2104
  static async post(target, data) {
1973
2105
  const record = await this.update(target.id);
1974
- record.addTo('quantity', -1);
2106
+ record.addTo('quantity', -1); // decrement safely across nodes
1975
2107
  }
1976
2108
  ```
1977
2109
 
1978
- 10. **Wrap background work in `transaction()`**: Harper auto-starts transactions for HTTP handlers. Use `transaction()` explicitly for timers, background jobs, or any code outside a natural transaction context.
2110
+ 9. **Wrap background or timer work in `transaction()`**: HTTP handlers get a transaction automatically. Use `transaction()` explicitly for timers, background jobs, or any code outside a natural request context. Always `await` the call and `catch` errors.
1979
2111
 
1980
- ```javascript
1981
- await transaction(async (txn) => {
1982
- for (let item of data) {
1983
- await MyTable.put(item, txn);
1984
- }
1985
- });
1986
- ```
1987
-
1988
- The `txn` object exposes:
1989
-
1990
- | Member | Type | Description |
1991
- | --------------------- | --------------- | ------------------------------------------------------ |
1992
- | `commit()` | `() => Promise` | Commits the current transaction |
1993
- | `abort()` | `() => void` | Aborts the transaction and resets it |
1994
- | `resetReadSnapshot()` | `() => void` | Resets the read snapshot to the latest committed state |
1995
- | `timestamp` | `number` | Timestamp associated with the current transaction |
2112
+ ```javascript
2113
+ await transaction(async (txn) => {
2114
+ for (let item of data) {
2115
+ await MyTable.put(item, txn);
2116
+ }
2117
+ });
2118
+ ```
1996
2119
 
1997
- On normal callback completion the transaction commits automatically. If the callback throws, the transaction is aborted.
2120
+ The `txn` object exposes:
1998
2121
 
1999
- 11. **Understand atomicity**: Transactions span a single database. All tables within the same database share one transactional context — reads return a consistent snapshot and writes across multiple tables commit atomically. Cross-database operations get separate transactions with no cross-database atomicity guarantee.
2122
+ | Member | Type | Description |
2123
+ | --------------------- | --------------- | ------------------------------------------------------ |
2124
+ | `commit()` | `() => Promise` | Commits the current transaction |
2125
+ | `abort()` | `() => void` | Aborts the transaction and resets it |
2126
+ | `resetReadSnapshot()` | `() => void` | Resets the read snapshot to the latest committed state |
2127
+ | `timestamp` | `number` | Timestamp associated with the current transaction |
2000
2128
 
2001
- 12. **Keep `harper` external when bundling for SSR**: In `vite.config`, mark `harper` as external so it resolves to the runtime rather than being bundled.
2129
+ On normal callback completion the transaction commits automatically. If the callback throws, the transaction is aborted.
2002
2130
 
2003
- ```javascript
2004
- // vite.config
2005
- ssr: {
2006
- external: ['harper'];
2007
- }
2008
- ```
2131
+ 10. **Understand atomicity boundaries**: All tables within the same database share a single transactional context — reads return a consistent snapshot and writes are committed atomically. Tables in different databases each get their own transaction with no cross-database atomicity guarantee.
2009
2132
 
2010
2133
  #### Examples
2011
2134
 
2012
- **Full CRUD flow:**
2135
+ ##### Basic CRUD and search
2013
2136
 
2014
2137
  ```javascript
2015
2138
  import { tables } from 'harper';
@@ -2025,14 +2148,15 @@ await Product.patch(created.id, { price: Math.round(created.price * 0.8 * 100) /
2025
2148
  const record = await Product.get(created.id);
2026
2149
 
2027
2150
  // Search with conditions
2028
- for await (const record of Product.search({
2151
+ const query = {
2029
2152
  conditions: [{ attribute: 'price', comparator: 'less_than', value: 8.0 }],
2030
- })) {
2153
+ };
2154
+ for await (const record of Product.search(query)) {
2031
2155
  // process record
2032
2156
  }
2033
2157
  ```
2034
2158
 
2035
- **Nested conditions with `or`:**
2159
+ ##### Nested conditions with `or`
2036
2160
 
2037
2161
  ```javascript
2038
2162
  Product.search({
@@ -2049,13 +2173,32 @@ Product.search({
2049
2173
  });
2050
2174
  ```
2051
2175
 
2052
- **Chained attribute reference (join):**
2176
+ ##### Chained attribute reference (join/relationship)
2053
2177
 
2054
2178
  ```javascript
2055
2179
  Product.search({ conditions: [{ attribute: ['brand', 'name'], value: 'Harper' }] });
2056
2180
  ```
2057
2181
 
2058
- **Background job with `transaction()`:**
2182
+ ##### Selecting and constraining related records
2183
+
2184
+ ```javascript
2185
+ const brand = await Brand.get({
2186
+ id: 42,
2187
+ select: [
2188
+ 'id',
2189
+ {
2190
+ name: 'products',
2191
+ conditions: [{ attribute: 'price', comparator: 'greater_than_equal', value: 30 }],
2192
+ sort: { attribute: 'price' },
2193
+ offset: 0,
2194
+ limit: 1,
2195
+ select: ['id', 'price'],
2196
+ },
2197
+ ],
2198
+ });
2199
+ ```
2200
+
2201
+ ##### Background job with `transaction()`
2059
2202
 
2060
2203
  ```javascript
2061
2204
  import { isMainThread } from 'node:worker_threads';
@@ -2065,7 +2208,7 @@ const { MyTable } = tables;
2065
2208
  if (isMainThread) {
2066
2209
  let running = false;
2067
2210
  setInterval(async () => {
2068
- if (running) return;
2211
+ if (running) return; // the previous run has not committed yet
2069
2212
  running = true;
2070
2213
  try {
2071
2214
  let data = await (await fetch('https://example.com/data')).json();
@@ -2079,11 +2222,11 @@ if (isMainThread) {
2079
2222
  } finally {
2080
2223
  running = false;
2081
2224
  }
2082
- }, 3600000);
2225
+ }, 3600000); // every hour
2083
2226
  }
2084
2227
  ```
2085
2228
 
2086
- **SSR with Harper data:**
2229
+ ##### SSR with Vite
2087
2230
 
2088
2231
  ```typescript
2089
2232
  import { tables } from 'harper';
@@ -2094,19 +2237,28 @@ export async function render(url: string): Promise<string> {
2094
2237
  }
2095
2238
  ```
2096
2239
 
2240
+ Keep `harper` external in `vite.config` so it resolves to the runtime:
2241
+
2242
+ ```javascript
2243
+ ssr: {
2244
+ external: ['harper'];
2245
+ }
2246
+ ```
2247
+
2097
2248
  #### Notes
2098
2249
 
2099
- - `tables` and `databases` calls run in a trusted server-side context and do **not** automatically apply the target table's role permissions.
2100
- - Programmatic `update`, `patch`, and `delete` calls operate directly on stored data. Scope destructive operations with specific `conditions`, validate the affected set before writing, and gate them behind authorization controls.
2101
- - If `transaction()` is called with a context that already has an active transaction, it reuses that transaction — making it safe to call defensively.
2102
- - Always `await` the `transaction()` call and `catch` errors when outside a request context; an unawaited call means a failed write is never observed.
2103
- - Guard against timer overlap: if a job can outlast its interval, use a `running` flag to skip a tick rather than opening two transactions over the same rows.
2104
- - CommonJS is also supported: `const { tables, Resource } = require('harper');`
2250
+ - Calls through `tables` run in a trusted server-side context and do **not** automatically apply the target table's role permissions. Set `checkPermission: true` on the query and pass the authenticated `context` as the second argument when authorization is required.
2251
+ - `update`, `patch`, and `delete` operate directly on stored data. Scope destructive operations with specific `conditions`, validate the affected set before writing, and gate them behind authorization controls.
2252
+ - Pass `allowFullScan: true` to permit an unconditional ordered scan without conditions. If order doesn't matter, omit `sort` entirely — `search({})` iterates without an index requirement.
2253
+ - If `transaction()` is called with a context that already has an active transaction, it reuses that transaction and executes the callback immediately — safe to call defensively.
2254
+ - Guard timer-based jobs against overlap: a timer fires on schedule regardless of whether the previous run has committed. Use a `running` flag to skip a tick when the previous run is still active.
2105
2255
 
2106
2256
  ### 3.4 TypeScript Type Stripping in Harper
2107
2257
 
2108
2258
  Instructions for the agent to run `.ts` files directly in Harper without a build step using Node.js's built-in type stripping.
2109
2259
 
2260
+ **Instead of:** `tsc` or `ts-node` build steps for server code. Use this rule rather than adding that service. See `harper-architecture`.
2261
+
2110
2262
  #### When to Use
2111
2263
 
2112
2264
  Apply this rule when writing Harper resource files in TypeScript. Use it any time you need to reference `.ts` source files from `config.yaml` or import between local TypeScript modules in a Harper project.
@@ -2161,6 +2313,8 @@ jsResource:
2161
2313
 
2162
2314
  Instructions for the agent to implement integrated data caching from external sources using Harper's cache table directives and `sourcedFrom` API.
2163
2315
 
2316
+ **Instead of:** Redis or Memcached cache-aside layers. Use this rule rather than adding that service. See `harper-architecture`.
2317
+
2164
2318
  #### When to Use
2165
2319
 
2166
2320
  Apply this rule when an application needs to wrap an external API, microservice, or database with a fast local cache. Use it when you need to define TTL-based cache expiration, connect an upstream data source to a Harper table, or implement on-demand cache invalidation.
@@ -2320,11 +2474,13 @@ await fetch('http://localhost:9926/JokeCache/1', {
2320
2474
 
2321
2475
  ### 4.1 Deploying to Harper Fabric
2322
2476
 
2323
- Instructions for the agent to follow when deploying a Harper application to a Harper Fabric cluster, or any remote Harper instance, with the `harper` CLI, and when checking, staging, or going back to a release.
2477
+ Instructions for the agent to deploy a Harper application to Harper Fabric using the CLI, verify certification, and roll back using a previous release's `deployment_id`.
2478
+
2479
+ **Instead of:** Docker Compose stacks; deploying several services separately. Use this rule rather than adding that service. See `harper-architecture`.
2324
2480
 
2325
2481
  #### When to Use
2326
2482
 
2327
- Apply this rule when deploying to a remote cluster, choosing how it restarts, reading a deploy's `certification`, staging a release to make live later, or rolling back to a previous release. To deploy from CI with no stored credential, see [deploying-from-ci.md](deploying-from-ci.md). To set up the cluster first, see [creating-a-fabric-account-and-cluster.md](creating-a-fabric-account-and-cluster.md).
2483
+ Apply this rule when deploying a Harper application to a remote Harper Fabric cluster, checking whether a rolling deploy was certified on each node, or restoring a previous release without rebuilding. Also apply it when provisioning credentials for private-source deploys or staging a build for later activation.
2328
2484
 
2329
2485
  #### How It Works
2330
2486
 
@@ -2335,10 +2491,9 @@ Apply this rule when deploying to a remote cluster, choosing how it restarts, re
2335
2491
  # Provide cluster username and password when prompted
2336
2492
  ```
2337
2493
 
2338
- - For CI/CD on GitHub Actions, use workload identity (OIDC) instead, so the pipeline stores no Harper credential. From v5.4.0, `harper deploy setup=true provider=github-actions` sets it up in one command: see [deploying-from-ci.md](deploying-from-ci.md). Elsewhere, give the pipeline a refresh token from `harper login --for-ci` rather than a password.
2339
- - For a one-off command, `auth_username=` and `auth_password=` also work, and take precedence over environment variables and saved login tokens. Don't use them in production.
2494
+ For CI/CD pipelines, use workload identity (OIDC) or a refresh token from `harper login --for-ci` instead of a password. See [deploying-from-ci.md](deploying-from-ci.md) for a complete GitHub Actions workflow. For a one-off command, `auth_username=` and `auth_password=` also work, and take precedence over environment variables and saved login tokens. Don't use them in production.
2340
2495
 
2341
- 2. **Deploy with a restart**: Run `harper deploy`, with `restart=true` or `restart=rolling` so the release is certified before it serves (step 3).
2496
+ 2. **Deploy the application**: Run `harper deploy` with the required parameters. Omit `package` to upload the current directory; supply a reference for npm, GitHub, or tarball sources.
2342
2497
 
2343
2498
  ```bash
2344
2499
  harper deploy \
@@ -2349,54 +2504,38 @@ Apply this rule when deploying to a remote cluster, choosing how it restarts, re
2349
2504
  replicated=true
2350
2505
  ```
2351
2506
 
2352
- - On Harper Pro and Fabric, a deploy goes to every node in the cluster unless you pass `replicated=false`. Harper core on its own does not replicate.
2353
- - `project` defaults to the current directory's name for a directory deploy, or is derived from the package for a package deploy.
2354
- - A bare `target` host defaults to `https://<host>:9925`.
2355
- - Pass `json=true` to print the result as JSON instead of YAML.
2356
- - Every result carries a `deployment_id`, staged or not. It names this release when you go back to it later (step 4).
2507
+ A bare `target` host defaults to `https://<host>:9925`. Use `restart=rolling` for a staggered, zero-downtime restart across the cluster. Pass `replicated=false` to deploy only to the node you called. Pass `json=true` to print the result as JSON instead of YAML.
2357
2508
 
2358
- 3. **Choose the restart, and read `certification`**: `restart=true` restarts Harper after deploying. On a cluster, use `restart=rolling`, the staggered, zero-downtime restart: the command exits `0` once the node you called has taken the release and started the rolling restart's job, and returns its `restartJobId`.
2509
+ 3. **Choose a package source**: The `package` field accepts any valid npm dependency value.
2359
2510
 
2360
- Both certify the release in a canary worker before rolling it out. The canary replaces worker 0 and loads every component, but is held out of traffic until it reports whether the deployed component loaded. Until then, the workers already running keep serving the previous release. A deploy that succeeds, and is not staged, reports the result in `certification`:
2511
+ | Source | Value |
2512
+ | ------------------ | --------------------------------------------------------------------------------------------- |
2513
+ | npm package | `package="@harperdb/status-check"` |
2514
+ | GitHub (public) | `package="HarperFast/status-check"` or `package="https://github.com/HarperFast/status-check"` |
2515
+ | Private repo (SSH) | `package="git+ssh://git@github.com:HarperDB/secret-app.git"` |
2516
+ | Tarball | `package="https://example.com/application.tar.gz"` |
2361
2517
 
2362
- | `certification` | Meaning |
2363
- | --------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
2364
- | `certified` | The canary loaded the release, and the rollout went on. |
2365
- | `uncertified` | The rollout went on unchecked: the component ran nothing as it loaded (`loadComponent: dev-only`, an absent `if-installed` component, or safe mode), or no worker was replaced. |
2366
- | `unavailable` | The deploy restarted unchecked: no worker on this node loads the component, its `isolated` setting changed, it was already live, or it was deployed from a local directory (`package: file:<dir>`). |
2367
- | `not-requested` | The deploy did not restart. |
2368
- - **A rejected release fails the deploy.** If the canary throws while loading the component, exits, or doesn't report within a minute, the deploy fails with `400`. The release it replaced is made live again, and nothing is replicated to other nodes. The error's `certification` is an object whose `status` is `rejected`, or `interrupted` for a restart that stopped before the canary decided.
2369
- - **A release with nothing to go back to fails closed.** A rejected first deploy, or one whose previous release was not kept, stays on disk, but no worker on that node loads it, even after Harper restarts. `get_status` reports the component as failed. Deploy a fix, or once the cause is fixed, activate the same `deployment_id` again with `restart`.
2370
- - **A rolling deploy exits before every node has taken the release.** The other nodes stage it, and the job activates it on each in turn, each with a canary of its own. A node that rejects it keeps the previous release, or fails closed if it had none. Nothing is rolled back on the others, and the job fails naming each node that did not activate it. The deployment record reports `success` while the job runs and after it fails, so poll the job instead: `harper get_job id=<restartJobId>` ends `COMPLETE`, or `ERROR` with a `message` listing each node's outcome under `activated`.
2371
- - **Poll the node that received the deploy.** A job is recorded only on the node that ran it, so any other node answers `get_job` with an empty array. A role with an `operations` allowlist, or a token with an `operations` scope, must list `get_job` to poll it.
2372
- - Another deploy or `drop_component` of the component on that node is refused with `409` until the rollout has finished.
2373
- - A node running a version before v5.4.0 activates the release without a canary.
2518
+ For git tags, use the `semver` directive: `HarperFast/application-template#semver:v1.0.0`. For an SSH-based private repo, register the key with `add_ssh_key` first.
2374
2519
 
2375
- 4. **Go back to a previous release by its `deployment_id`**: When a deploy replaces the live release, Harper keeps the replaced one under the `deployment_id` that deployed it. Activating that id puts back the exact installed release with no fetch, rebuild, or reinstall, where deploying an older commit installs it again from source. Find the id with `list_deployments`, then deploy it. On a cluster, restart with `rolling` and poll its job (step 3):
2520
+ 4. **Provision credentials for private repositories**: Run `harper deploy setup=true` once per component and source. (With `provider=github-actions`, it sets up GitHub Actions OIDC deploys instead.) This requires `super_user` privileges and is interactive. It calls `get_secrets_public_key`, `set_secret`, and `grant_secret`. Run it with an administrative credential rather than the CI identity it provisions for:
2376
2521
 
2377
2522
  ```bash
2378
- harper list_deployments project=my-app status=success
2379
- harper deploy project=my-app deployment_id=<id> restart=rolling
2523
+ harper deploy setup=true
2380
2524
  ```
2381
2525
 
2382
- ```json
2383
- {
2384
- "operation": "deploy_component",
2385
- "project": "my-app",
2386
- "deployment_id": "<id of the deployment you want back>",
2387
- "restart": "rolling"
2388
- }
2389
- ```
2526
+ The command:
2527
+ 1. Fetches the cluster's public key with `get_secrets_public_key`.
2528
+ 2. Encrypts the token locally into an `enc:v1:` envelope.
2529
+ 3. Stores only the ciphertext with `set_secret`, scoped to the component, never in the global `processEnv` tier.
2530
+ 4. Grants the component permission to resolve it with `grant_secret`.
2390
2531
 
2391
- - `list_deployments` returns records newest first. Filter by `project` and `status`; omit `limit` to return every matching record. A deploy in progress reads `pending`, or `loading`, until its `completed_at` is set.
2392
- - The newest `success` is not necessarily the release every node is serving: a rolling deploy's record says `success` before the other nodes have taken the release, and keeps saying it if one rejects it.
2393
- - Redeploy an older commit by `ref` (step 7) only when the release is no longer kept.
2394
- - The activation restores the root config entry that deployment published. The release it replaces is kept in turn, so you can go forward again the same way.
2395
- - Kept releases and staged builds count against `deployment.stagingRetention.maxCount` (default `5`). Each kept release is a full installed copy of the component, `node_modules` included, so budget disk for it. `0` keeps no replaced release.
2396
- - A release made live before v5.3.0, or deployed from a `file:` directory, is not kept.
2397
- - Each node answers for itself. A node that never had the release, or no longer keeps it, answers `404`, which `deploy_component` reports as a failed peer. `409` means the build is there but can't be activated.
2532
+ Use a **fine-grained** personal access token with **Contents: Read-only** on the target repository. Avoid broad tokens: the `gh` CLI session token typically carries `read:org`, `gist`, and `workflow` scopes across your whole account. Then deploy with `credential=true`:
2533
+
2534
+ ```bash
2535
+ harper deploy by_ref=true credential=true restart=true replicated=true
2536
+ ```
2398
2537
 
2399
- 5. **Stage a release to make live later**: `activate=false` (`activate: false` in the operation) builds and installs the release on each node without making it live, so the slow, fallible part happens when you choose and the cut-over is quick. The result's `deployment_id` names the staged build:
2538
+ 5. **Stage a release to make live later**: `activate=false` (`activate: false` in the operation) builds and installs the release on each node without making it live, so the slow, fallible part happens when you choose and the cut-over is quick. Cannot be combined with `restart`.
2400
2539
 
2401
2540
  ```json
2402
2541
  {
@@ -2407,6 +2546,8 @@ Apply this rule when deploying to a remote cluster, choosing how it restarts, re
2407
2546
  }
2408
2547
  ```
2409
2548
 
2549
+ The response names the artifact waiting to be activated:
2550
+
2410
2551
  ```json
2411
2552
  {
2412
2553
  "deployment_id": "a3f8c2d1-...",
@@ -2414,147 +2555,214 @@ Apply this rule when deploying to a remote cluster, choosing how it restarts, re
2414
2555
  }
2415
2556
  ```
2416
2557
 
2417
- Make it live by deploying that `deployment_id`, as in step 4. Nothing changes until then, and `get_deployment` reports it as `staged`.
2418
- - Pass `restart` on the activation, not the stage: `restart` is rejected alongside `activate: false`.
2419
- - The activation takes no `package`, `payload`, `credentials`, install options, or `urlPath`/`host`. They are rejected, because the staged build already decided them.
2420
- - Activating the release that is already live succeeds without a swap, so if an activation fails partway (a peer was unreachable, or the root config entry wasn't written), retry the same `deployment_id`. A node that already switched answers success, one still holding the build switches now, and one that holds neither answers `404`; the retry also finishes what the failed attempt left half done. With `restart`, a node where the release is already live still restarts every worker, isolated applications' included. Without it, the node marks the component as needing a restart.
2421
- - A staged build outlives later deploys: stage v2, deploy v3, and activating v2's id returns the component to v2.
2422
- - `file:` directory sources cannot be staged.
2423
- - Upgrade every node before staging. A node before v5.3.0 doesn't recognize `activate: false`, and serves the release immediately. A peer that doesn't confirm staging fails the operation with the node names.
2558
+ Nothing about the running component changes, and `get_deployment` reports it as `staged`. Make it live by deploying that `deployment_id`, as in step 6. The activation takes no `package`, `payload`, `credentials`, install options, or `urlPath`/`host`. They are rejected, because the staged build already decided them.
2424
2559
 
2425
- 6. **Choose a package source**: The `package` field accepts any valid npm dependency value:
2560
+ 6. **Activate a staged build or roll back**: Pass `deployment_id` with `restart` to make a staged build live, or to restore a release a later deploy replaced, with no rebuild or reinstall. Pass `restart` here rather than on the stage.
2426
2561
 
2427
- | Source | `package` value |
2428
- | ----------------------- | --------------------------------------------------------------------------------------------- |
2429
- | Current local directory | Omit `package` |
2430
- | npm package | `package="@harperdb/status-check"` |
2431
- | GitHub (public) | `package="HarperFast/status-check"` or `package="https://github.com/HarperFast/status-check"` |
2432
- | Private repo (SSH) | `package="git+ssh://git@github.com:HarperDB/secret-app.git"` |
2433
- | Tarball | `package="https://example.com/application.tar.gz"` |
2562
+ ```bash
2563
+ harper deploy project=<name> deployment_id=<id> restart=true
2564
+ ```
2434
2565
 
2435
- For git tags, use the `semver` directive: `HarperFast/application-template#semver:v1.0.0`. For an SSH-based private repo, register the key with `add_ssh_key` first.
2566
+ Use `list_deployments` to find the `deployment_id` of the release to restore:
2436
2567
 
2437
- 7. **Deploy by reference for a reproducible source**: `by_ref=true` sends a pinned git reference instead of uploading the working directory, and the cluster fetches and builds that exact commit. It resolves the repository's `origin` remote and the current commit, then deploys `package=git+https://github.com/<owner>/<repo>.git#<full commit SHA>`. Pass `ref` to deploy a specific tag or commit instead of `HEAD`; it implies `by_ref`.
2568
+ ```json
2569
+ { "operation": "list_deployments", "project": "my-app", "status": "success", "limit": 20 }
2570
+ ```
2571
+
2572
+ - A `404` response means the id never existed on that node, was pruned, or the release was replaced and not kept. A `409` means the build exists but cannot be activated (e.g., belongs to another component, or files changed after staging).
2573
+ - Kept releases and staged builds count against `deployment.stagingRetention.maxCount` (`stagingRetention` default: `5`). Each kept release is a full installed copy of the component, `node_modules` included, so budget disk for it. `0` keeps no replaced releases.
2574
+
2575
+ 7. **Deploy by reference for a reproducible source**: `by_ref=true` sends a pinned git reference instead of uploading the working directory, and the cluster fetches and builds that exact commit. It resolves the repository's `origin` remote and the current commit, then deploys `package=git+https://github.com/<owner>/<repo>.git#<full commit SHA>`. Defaults to `HEAD`.
2438
2576
 
2439
2577
  ```bash
2440
2578
  harper deploy by_ref=true restart=true replicated=true
2579
+ ```
2580
+
2581
+ Deploy a specific tag or commit with `ref`:
2582
+
2583
+ ```bash
2441
2584
  harper deploy ref=v1.2.0 restart=true replicated=true
2442
2585
  harper deploy ref=9f8c2a1 restart=true replicated=true
2443
2586
  ```
2444
2587
 
2445
- - A `ref` is resolved to a full commit SHA before the deploy is sent, from the local checkout or the remote. A full commit SHA is accepted directly. If a `ref` can't be resolved, the deploy stops: run `git fetch` and retry, or pass a full SHA.
2588
+ - Tags and branches are resolved to a full commit SHA before the deploy is sent. A `ref` that cannot be resolved stops the deploy — run `git fetch` and retry, or pass a full commit SHA.
2446
2589
  - Any other `ref` must name something a clone can fetch: `refs/heads/*` or `refs/tags/*`, or a bare branch or tag name. A qualified ref outside those, such as `refs/pull/123/head`, is rejected up front.
2447
- - Commit and push first: the cluster clones from the remote. `by_ref` warns when the working tree is dirty, and when the commit isn't on any remote branch. The second check is skipped under GitHub Actions.
2448
2590
  - On a GitHub Actions `pull_request` run, `by_ref` deploys the pull request's head commit. If the event payload isn't readable, the deploy stops; pass the commit explicitly.
2449
- - The cluster installs and builds from source on each node, so commit your lockfile if the build must be reproducible. If the build can't run on the node, deploy the built output as a payload instead.
2591
+ - Commit and push first — the cluster clones from the remote and only sees pushed commits.
2592
+
2593
+ 8. **Check certification after a rolling deploy**: A deploy with `restart=rolling` certifies the release in a canary worker before rolling it out. The deploy response includes a `certification` field:
2594
+
2595
+ | `certification` | Meaning |
2596
+ | --------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
2597
+ | `certified` | The canary loaded the release; rollout proceeded. |
2598
+ | `uncertified` | The rollout went on unchecked: the component ran nothing as it loaded (`loadComponent: dev-only`, an absent `if-installed` component, or safe mode), or no worker was replaced. |
2599
+ | `unavailable` | Deploy restarted unchecked (no worker loads the component, `isolated` setting changed, already live, or `file:` directory source). |
2600
+ | `not-requested` | The deploy did not restart. |
2601
+ - **A rejected release fails the deploy.** If the canary throws while loading the component, exits, or doesn't report within a minute, the deploy fails with `400`. The release it replaced is made live again, and nothing is replicated to other nodes. The error carries the decision as an object in `certification` with `status` set to `rejected`, or to `interrupted` for a restart that stopped before the canary decided.
2602
+ - **A release with nothing to go back to fails closed.** A rejected first deploy, or one whose previous release was not kept, stays on disk, but no worker on that node loads it, even after Harper restarts. `get_status` reports the component as failed and says why. Deploy a fix, or activate the same `deployment_id` again with `restart`.
2603
+ - Another deploy or `drop_component` of the component on that node is refused with `409` until the rollout has finished.
2450
2604
 
2451
- 8. **Deploy a private repository by reference**: Pass `credential=true` with `by_ref=true`. The CLI attaches a `credentials` reference, and the cluster resolves the secret in memory at clone time, so no token travels in the operation body or lands on disk.
2605
+ 9. **Wait for a rolling deploy to finish**: A `deploy_component` with `"restart": "rolling"` returns a `restartJobId`. Poll it with `get_job` to learn whether every node took the release.
2452
2606
 
2453
- ```bash
2454
- harper deploy by_ref=true credential=true restart=true replicated=true
2607
+ ```json
2608
+ { "operation": "get_job", "id": "4a982782-929a-4507-8794-26dae1132def" }
2455
2609
  ```
2456
2610
 
2457
- Provision that credential once per component and source with `harper deploy setup=true`. (With `provider=github-actions`, it sets up GitHub Actions deploys instead: see [deploying-from-ci.md](deploying-from-ci.md).) It is interactive, and its operations require **super_user**, so run it with an administrative credential, not the CI identity it provisions for. It:
2458
- 1. Fetches the cluster's public key with `get_secrets_public_key`.
2459
- 2. Encrypts the token locally into an `enc:v1:` envelope, so the plaintext never leaves your machine.
2460
- 3. Stores only the ciphertext with `set_secret`, scoped to the component, never in the global `processEnv` tier.
2461
- 4. Grants the component permission to resolve it with `grant_secret`.
2462
- 5. Prints the `credentials` reference for the deploy to use.
2463
-
2464
- Use a **fine-grained** personal access token with **Contents: Read-only** on that one repository. Avoid the `gh` CLI's session token: it typically carries `repo`, `read:org`, `gist`, and `workflow` scopes across your whole account. The sealed token is durable, and later deploys reuse it.
2611
+ `get_job` returns status `COMPLETE`, `IN_PROGRESS`, or `ERROR`. It answers only on the node that ran the job. A deploy-only role that waits for rolling deploys must include `get_job` in its operations allowlist.
2612
+ - `list_deployments` returns records newest first. Filter by `project` and `status`; omit `limit` to return every matching record. A deploy in progress reads `pending`, or `loading`, until its `completed_at` is set.
2465
2613
 
2466
2614
  #### Examples
2467
2615
 
2468
- **Deploy after logging in, and wait for a rolling restart:**
2616
+ ##### Full rolling deploy from a pinned tag
2469
2617
 
2470
2618
  ```bash
2619
+ # Authenticate once
2471
2620
  harper login https://my-cluster.harperdbcloud.com
2621
+
2622
+ # Deploy a pinned release with rolling restart
2472
2623
  harper deploy \
2473
2624
  project=my-app \
2474
- package="HarperFast/my-app" \
2625
+ ref=v1.2.0 \
2475
2626
  target=https://my-cluster.harperdbcloud.com \
2476
2627
  restart=rolling \
2477
- json=true > deploy.json
2478
- # Poll the node that received the deploy until the job is COMPLETE or ERROR
2479
- harper get_job id=<restartJobId from deploy.json> json=true
2628
+ replicated=true
2480
2629
  ```
2481
2630
 
2482
- **Roll back to the release a deploy replaced, node by node:**
2631
+ ##### Stage a build, then activate in a maintenance window
2483
2632
 
2484
- ```bash
2485
- harper list_deployments project=my-app status=success
2486
- harper deploy project=my-app deployment_id=<previous deployment_id> restart=rolling json=true
2487
- # Then poll its restartJobId with get_job, as above
2633
+ ```json
2634
+ {
2635
+ "operation": "deploy_component",
2636
+ "project": "my-app",
2637
+ "package": "npm:@my-org/my-app@1.2.3",
2638
+ "activate": false
2639
+ }
2488
2640
  ```
2489
2641
 
2490
- **Stage a release now, and make it live in a maintenance window:**
2642
+ Response:
2491
2643
 
2492
- ```bash
2493
- harper deploy project=my-app package=npm:@my-org/my-app@1.2.3 activate=false
2494
- # Later, with the deployment_id the stage returned
2495
- harper deploy project=my-app deployment_id=<staged deployment_id> restart=rolling
2644
+ ```json
2645
+ {
2646
+ "deployment_id": "a3f8c2d1-...",
2647
+ "message": "Staged: my-app. Deploy it with deploy_component deployment_id=a3f8c2d1-..."
2648
+ }
2496
2649
  ```
2497
2650
 
2498
- **Deploy a pull request's head commit by reference in GitHub Actions:**
2651
+ Activate later:
2499
2652
 
2500
- ```bash
2501
- harper deploy ref=${{ github.event.pull_request.head.sha }} restart=true replicated=true
2653
+ ```json
2654
+ {
2655
+ "operation": "deploy_component",
2656
+ "project": "my-app",
2657
+ "deployment_id": "a3f8c2d1-...",
2658
+ "restart": true
2659
+ }
2502
2660
  ```
2503
2661
 
2504
- **Deploy a private repository by reference:**
2662
+ ##### Roll back to a previous release
2505
2663
 
2506
- ```bash
2507
- # Once, as super_user
2508
- harper deploy setup=true
2664
+ ```json
2665
+ {
2666
+ "operation": "list_deployments",
2667
+ "project": "my-app"
2668
+ }
2669
+ ```
2670
+
2671
+ Then:
2509
2672
 
2510
- # Every deploy
2511
- harper deploy by_ref=true credential=true restart=true replicated=true
2673
+ ```json
2674
+ {
2675
+ "operation": "deploy_component",
2676
+ "project": "my-app",
2677
+ "deployment_id": "<id of the deployment you want back>",
2678
+ "restart": true
2679
+ }
2512
2680
  ```
2513
2681
 
2682
+ ##### Rejected canary — error response shape
2683
+
2684
+ ```json
2685
+ {
2686
+ "error": "my-app was not deployed: release 9b2e... failed to load in its canary worker: my-app.jsResource: ... Deployment a3f8c2d1..., the release it replaced, is live again. No other node received it. Deploy a fix, or activate deployment 9b2e... again once the cause is fixed.",
2687
+ "deployment_id": "9b2e...",
2688
+ "certification": {
2689
+ "status": "rejected",
2690
+ "reason": "my-app.jsResource: ...",
2691
+ "failures": [
2692
+ { "key": "my-app.jsResource", "name": "ResourceLoadError", "message": "...", "stack": "..." }
2693
+ ],
2694
+ "restored": "a3f8c2d1...",
2695
+ "failed_closed": false
2696
+ }
2697
+ }
2698
+ ```
2699
+
2700
+ #### Notes
2701
+
2702
+ - `harper login` stores a token; subsequent `harper deploy` calls to the same target need no credentials. See [creating-a-fabric-account-and-cluster.md](creating-a-fabric-account-and-cluster.md) for cluster setup.
2703
+ - For GitHub Actions, prefer OIDC workload identity over stored tokens. See [deploying-from-ci.md](deploying-from-ci.md).
2704
+ - `restart=rolling` and `activate=false` cannot be combined.
2705
+ - `deployment_id` activation takes no `package`, `payload`, `credentials`, install options, `urlPath`, or `host` — supplying them is rejected.
2706
+ - `file:` directory sources (`package: file:<dir>`) cannot be staged and are not kept as rollback targets.
2707
+ - `stagingRetention.maxCount` (default `5`) caps staged builds and kept replaced releases together per component.
2708
+ - `get_job` on a `restartJobId` answers only on the node that received the deploy; any other node returns an empty array.
2709
+ - A rolling deploy's record reads `rolling_out` until the job has visited every node; filter `list_deployments` without `status` and check each record's `status` for a finished value (`success`, `partial`, `failed`, `staged`). `completed_at` alone does not confirm a finished state, since a `rolling_out` record already has it.
2710
+ - `enc:v1:` envelopes store only ciphertext — plaintext never leaves your machine and never appears in operation logs or replication.
2711
+ - Tags used in `refs/tags` are resolved to a commit SHA before the deploy is sent; a tag that moves mid-deploy cannot cause nodes to run different code.
2712
+ - `super_user` is required to run `harper deploy setup=true`.
2713
+
2514
2714
  ### 4.2 Deploying a Harper Application from GitHub Actions with OIDC
2515
2715
 
2516
- Instructions for the agent to configure and execute credential-free Harper deployments from GitHub Actions using OIDC trusted publishing, including one-command setup and complete workflow examples.
2716
+ Instructions for the agent to configure and use OIDC trusted publishing so a GitHub Actions workflow can deploy to Harper with no stored credential.
2517
2717
 
2518
2718
  #### When to Use
2519
2719
 
2520
- Apply this rule when asked to deploy from CI, to set up or fix a deploy workflow (including the `.github/workflows/deploy.yaml` that create-harper projects ship), or to move a pipeline off a stored Harper credential such as `HARPER_CLI_REFRESH_TOKEN`. Use it when setting up OIDC trust for the first time, writing or debugging a deploy workflow, or revoking/rotating pipeline access.
2720
+ Apply this rule when wiring up a GitHub Actions workflow to deploy a Harper application without storing a `HARPER_CLI_REFRESH_TOKEN` or any other durable credential in CI. Use it both for the one-command setup path (branch-triggered deploys) and for the manual policy path (tag-triggered releases). Also apply it when diagnosing a rejected OIDC exchange or revoking pipeline access.
2521
2721
 
2522
2722
  #### How It Works
2523
2723
 
2524
- ##### One-Command Setup (Branch-Based Deploys)
2724
+ ##### One-Command Setup (branch-triggered deploys)
2525
2725
 
2526
- 1. **Run setup once** from the project checkout, signed in as a `super_user`:
2726
+ 1. **Run setup once** from the project's checkout, signed in as a `super_user`:
2527
2727
 
2528
2728
  ```bash
2529
- harper login https://my-cluster.example.com:9925
2530
2729
  harper deploy setup=true provider=github-actions project=my-app
2531
2730
  ```
2532
2731
 
2533
- If your workflow file is not `.github/workflows/deploy.yaml`, pass `workflow=` explicitly — otherwise the policy pins `deploy.yaml` and runs of your workflow cannot match it:
2732
+ If your workflow file is not `.github/workflows/deploy.yaml`, pass `workflow=`:
2534
2733
 
2535
2734
  ```bash
2536
2735
  harper deploy setup=true provider=github-actions project=my-app workflow=.github/workflows/deploy.yml
2537
2736
  ```
2538
2737
 
2539
- 2. **Understand what setup does** — in order:
2540
- - Resolves the GitHub repository's numeric id and canonical name from the `origin` remote (or `repo=<owner>/<name>`). Pass `repository_id=<id>` when the API is unavailable (e.g., private repo without `gh`).
2541
- - **The workflow is checked before anything is written.** It must be able to run on `main` (`branch=`) and run a job in the `production` environment (`environment=`) with `permissions: id-token: write`; one that never could stops setup. What it can't decide is reported as not verified.
2542
- - Creates a role and a user named `<project>-ci-deploy` with permissions limited to `deploy_component`, `get_job`, and `registration_info`, and a trust policy named `github-actions-<project>`.
2543
- - Reads the policy back and compares it claim by claim with what it intended to write.
2544
- - Sets the repository's `HARPER_CLI_TARGET` Actions variable via `gh` if one does not already exist.
2738
+ 2. **Understand what setup does** — it executes these steps automatically:
2739
+ - Resolves the GitHub repository's numeric `repository_id` from the `origin` remote (or `repo=<owner>/<name>`). Pass `repository_id=<id>` explicitly for private repos without `gh`.
2740
+ - Validates the workflow file: it must be able to run on `main` (or `branch=`), run a job in the `production` environment (`environment=`), and grant `id-token: write`. Setup stops before writing anything if the workflow could never match or never request a token.
2741
+ - Creates a role and a user named `<project>-ci-deploy` with permissions limited to `deploy_component`, `get_deployment`, `get_job`, and `registration_info`, plus the trust policy `github-actions-<project>`.
2742
+ - Reads the policy back and compares it claim by claim.
2743
+ - Sets the repository's `HARPER_CLI_TARGET` variable via `gh` if one does not already exist.
2545
2744
 
2546
- 3. **Handle idempotency**: setup only creates — it never updates. A role, user, or policy that already exists but differs stops setup and names what differs. To replace a policy:
2745
+ 3. **Handle idempotency**: setup only creates — it never overwrites. A role, user, or policy that already exists but differs stops setup before writing. To replace a policy:
2547
2746
 
2548
2747
  ```bash
2549
2748
  harper drop_oidc_trust id=github-actions-<project>
2550
- harper deploy setup=true provider=github-actions project=my-app
2551
2749
  ```
2552
2750
 
2553
- ##### Manual Setup (Tag-Based or Custom Deploys)
2751
+ Then run setup again.
2554
2752
 
2555
- Use this path when deploying on version tags, because a branch-pinned policy cannot match a tag ref.
2753
+ ---
2556
2754
 
2557
- 1. **Create a deploy-only role and user**:
2755
+ ##### Manual Setup (tag-triggered deploys)
2756
+
2757
+ Use this path when the workflow deploys on version tags, because a branch pin cannot match a tag ref.
2758
+
2759
+ 1. **Log in as `super_user`**:
2760
+
2761
+ ```bash
2762
+ harper login https://my-cluster.example.com:9925
2763
+ ```
2764
+
2765
+ 2. **Create a deploy-only role and user** named `<project>-ci-deploy`:
2558
2766
 
2559
2767
  ```bash
2560
2768
  harper add_role role=ci_deploy permission='{"operations":["deploy_component","get_job","get_deployment","registration_info"]}'
@@ -2562,12 +2770,12 @@ Use this path when deploying on version tags, because a branch-pinned policy can
2562
2770
  ```
2563
2771
 
2564
2772
  - `deploy_component` — the deploy itself.
2565
- - `get_job` — required for the wait step; an `operations` allowlist refuses anything not listed, so without it the wait step fails with `403`.
2566
- - `get_deployment` — reads the deployment record.
2567
- - `registration_info` — version check `harper deploy` makes before uploading.
2773
+ - `get_deployment` — required for `harper deploy restart=rolling` to wait on every node; without it that wait fails with `403`.
2774
+ - `get_job` — required for the wait step; without it the wait step fails with `403`.
2775
+ - `registration_info` — version check before upload; omitting it logs a refused operation on every run.
2568
2776
  - `restart_service` is not needed: Harper starts the rolling restart's job itself. The password is required to create the user but a GitHub Actions pipeline never uses it, so this command discards it.
2569
2777
 
2570
- 2. **Add a trust policy** pinned to `repository_id`, `workflow_path`, and `environment` (use this shape when the tag is unknown at policy-write time):
2778
+ 3. **Add a trust policy** pinning `repository_id`, `workflow_path`, and `environment` (use this shape for tag-triggered workflows where `workflow_ref` cannot be pinned):
2571
2779
 
2572
2780
  ```bash
2573
2781
  harper add_oidc_trust \
@@ -2579,45 +2787,40 @@ Use this path when deploying on version tags, because a branch-pinned policy can
2579
2787
  ```
2580
2788
 
2581
2789
  Key claim rules:
2582
- - `repository_id` is immutable — survives renames. Look it up with `gh api repos/my-org/my-app --jq .id`.
2583
- - `workflow_path` pins the workflow file. Harper derives it from the token's `workflow_ref` by removing the `@<ref>` suffix, so it is `<owner>/<repo>/<path>`.
2790
+ - `repository_id` is immutable and survives renames. Look it up with `gh api repos/my-org/my-app --jq .id`.
2791
+ - `workflow_path` is derived from `workflow_ref` by removing the `@<ref>` suffix; format is `<owner>/<repo>/<path>`.
2584
2792
  - `environment` is the ref gate. Without it, anyone who can push a branch can mint a token.
2585
2793
  - `audience` must be the exact target URL with its port and a **trailing slash**.
2586
2794
  - A workflow that deploys on a push to `main` can pin `workflow_ref` (`my-org/my-app/.github/workflows/deploy.yml@refs/heads/main`) instead, which gates the ref by itself.
2587
2795
 
2588
- 3. **Configure the GitHub environment**: in **Settings → Environments → production**, set **Deployment branches and tags** to the `v*` tag pattern. Add required reviewers if a release needs approval.
2796
+ 4. **Configure the GitHub environment**: in **Settings → Environments → production**, set **Deployment branches and tags** to the `v*` tag pattern. Add required reviewers if a release needs approval.
2589
2797
 
2590
- 4. **Store the target as a repository variable** (not a secret — it is not sensitive):
2798
+ 5. **Store the target as a repository variable** (not a secret):
2591
2799
 
2592
2800
  ```bash
2593
2801
  gh variable set HARPER_CLI_TARGET --repo my-org/my-app --body https://my-cluster.example.com:9925/
2594
2802
  ```
2595
2803
 
2804
+ ---
2805
+
2596
2806
  ##### How OIDC Authentication Works in the Workflow
2597
2807
 
2598
- Once a trust policy exists, every `harper` command in the deploy job authenticates through it automatically when all four conditions hold:
2808
+ A job authenticates automatically when all four conditions hold:
2599
2809
 
2600
- 1. **The job may request an identity token.** On GitHub Actions that is `permissions: id-token: write` on the job.
2601
- 2. **`HARPER_CLI_TARGET` is the policy's `audience`.** The CLI requests its token for the target it connects to, so the target must be the URL the policy names, with its port and trailing slash. Use `vars.HARPER_CLI_TARGET` — it is not sensitive, so a repository variable rather than a secret.
2602
- 3. **The run matches the policy's claims**: its repository, its workflow file and branch, and its environment.
2810
+ 1. **The job declares `permissions: id-token: write`.**
2811
+ 2. **`HARPER_CLI_TARGET` is the policy's `audience`.** The CLI requests its token for the target it connects to, so the target must be the URL the policy names, with its port and trailing slash. It is not sensitive, so use `vars.HARPER_CLI_TARGET` rather than a secret.
2812
+ 3. **The run matches the policy's claims**: its `repository_id`, `workflow_path`/`workflow_ref`, and `environment`.
2603
2813
  4. **Nothing else is configured to authenticate.** `auth_username=`/`auth_password=`, credentials in the target URL, `HARPER_CLI_USERNAME`/`HARPER_CLI_PASSWORD`, `HARPER_CLI_REFRESH_TOKEN` or `HARPER_CLI_OPERATION_TOKEN`, and a saved `harper login` all outrank the exchange.
2604
2814
 
2605
- What each command may then do is up to the role of the user the policy names: a role that lists only `deploy_component`, `get_job` and `registration_info`, like the one setup creates, refuses anything else with a `403`.
2815
+ Nothing in the workflow names the policy. Every `harper` command in the job (`harper deploy`, `harper get_job`, etc.) authenticates through it automatically.
2606
2816
 
2607
2817
  The exchange runs only when GitHub sets `ACTIONS_ID_TOKEN_REQUEST_URL` and `ACTIONS_ID_TOKEN_REQUEST_TOKEN`, which it does for a job that declares `permissions: id-token: write`. Without them, the CLI falls through to its other credential sources rather than reporting a failure.
2608
2818
 
2609
- Nothing in the workflow names the policy. The CLI prints to stderr which policy authenticated it and as whom:
2610
-
2611
- ```
2612
- Requesting a GitHub Actions identity token for https://my-instance.harperdb.io:9925/...
2613
- Authenticated as 'ci-deploy' via OIDC trust policy 'my-app-prod'.
2614
- ```
2615
-
2616
- Diagnose a rejected exchange with `list_oidc_trust` (check for `invalid_reason` on each entry) and the instance's `oidc-trust` log. The server deliberately does not report which check failed.
2819
+ ---
2617
2820
 
2618
2821
  ##### Policy Specificity for GitHub Actions
2619
2822
 
2620
- For `https://token.actions.githubusercontent.com`, a policy must satisfy all three of these:
2823
+ For `issuer=https://token.actions.githubusercontent.com`, every policy must satisfy all three requirements:
2621
2824
 
2622
2825
  | Requirement | Satisfied by one of | Left open otherwise |
2623
2826
  | ---------------------- | ------------------------------------------------------------------------ | ----------------------------------------------- |
@@ -2627,21 +2830,68 @@ For `https://token.actions.githubusercontent.com`, a policy must satisfy all thr
2627
2830
 
2628
2831
  Additional constraints:
2629
2832
 
2630
- - `repository_id` is preferred over `repository` — it is immutable and survives renames.
2631
- - A tag-triggered release cannot pin `workflow_ref` since the tag is unknown when the policy is written. Pin `workflow_path` and gate on `environment`.
2632
2833
  - `ref_type: tag` is not accepted as a ref gate, `sub` is not accepted as a pin, and `job_workflow_ref` pins the workflow without gating the ref. `pull_request_target` runs are denied unless the policy constrains `event_name`.
2633
2834
 
2634
- ##### Revoking Access
2835
+ ---
2836
+
2837
+ ##### Diagnosing a Rejected Exchange
2838
+
2839
+ Run `list_oidc_trust` (requires `super_user`) to list all policies including disabled ones:
2840
+
2841
+ ```json
2842
+ { "operation": "list_oidc_trust" }
2843
+ ```
2844
+
2845
+ `list_oidc_trust` returns `{ "policies": [ ... ] }`. Each entry includes `id`, `issuer`, `audience`, `claims`, `user`, `enabled`, and optionally `invalid_reason`. Check `invalid_reason` first when diagnosing a rejected exchange — it is set when the policy is malformed or its user was deleted or deactivated after the policy was written. The exchange itself will not report what failed.
2846
+
2847
+ Check the instance's `oidc-trust` log for server-side detail.
2848
+
2849
+ ---
2635
2850
 
2636
- - **Stop future runs**: `harper drop_oidc_trust id=github-actions-<project>` (does not revoke already-issued tokens, which are valid up to one hour).
2637
- - **Stop an already-issued token**: `harper alter_user username=<project>-ci-deploy active=false`.
2638
- - **No CI secret to rotate** — there is none.
2851
+ ##### Revoking Pipeline Access
2852
+
2853
+ - **Stop future runs** — drop the policy:
2854
+
2855
+ ```bash
2856
+ harper drop_oidc_trust id=github-actions-<project>
2857
+ ```
2858
+
2859
+ This does not revoke tokens already issued (they are stateless JWTs valid for one hour).
2860
+
2861
+ - **Stop a token already in circulation** — deactivate the user:
2862
+
2863
+ ```bash
2864
+ harper alter_user username=<project>-ci-deploy active=false
2865
+ ```
2866
+
2867
+ Use both together when responding to a suspected compromise.
2868
+
2869
+ ---
2870
+
2871
+ ##### Providing a Durable Credential for a Private Source
2872
+
2873
+ Do not use the workflow's ephemeral `GITHUB_TOKEN` for Harper's stored credential — it expires when the job ends, and later installs (a new node joining, a reinstall after restore) would fail. Instead use a durable token:
2874
+
2875
+ - For a private repository: a fine-grained personal access token with **Contents: read-only** scoped to the application repository.
2876
+ - For GitHub Packages: a token with the `read:packages` scope.
2877
+
2878
+ Store it in Harper once from your machine:
2879
+
2880
+ ```bash
2881
+ # Private git repository
2882
+ harper deploy setup=true provider=github project=my-app
2883
+
2884
+ # GitHub Packages
2885
+ harper deploy setup=true provider=npm project=my-app registry=https://npm.pkg.github.com scope=@my-org
2886
+ ```
2887
+
2888
+ The name is derived from the component and the host: `deploy.my-app.git.github.com` for the repository, and `deploy.my-app.npm.pkg.github.com` for GitHub Packages. Reference it by that name in the workflow — the token never appears in your pipeline.
2639
2889
 
2640
2890
  #### Examples
2641
2891
 
2642
2892
  ##### Path A: Deploy a Tagged Release (`v1.2.3`) from the Repository
2643
2893
 
2644
- Push a semver tag (`v1.2.3`), and every node clones and installs that commit. `by_ref=true` deploys `git+https://github.com/<owner>/<repo>.git#<sha>`, where the SHA is the commit the tag points to (`GITHUB_SHA`), and every node clones that exact commit even if the tag moves later. For a private repository, add `credential=true` to attach the stored credential (`deploy.my-app.git.github.com`). If the repository needs a build step to be runnable, don't reach for `install_allow_scripts`: use Path B.
2894
+ Push a semver tag (`v1.2.3`), and every node clones and installs that commit. `by_ref=true` deploys `git+https://github.com/<owner>/<repo>.git#<sha>`, where the SHA is the commit the tag points to (`GITHUB_SHA`), and every node clones that exact commit even if the tag is moved later. If this repository needs a build step to be runnable, do not reach for `install_allow_scripts` — use Path B instead.
2645
2895
 
2646
2896
  ```yaml
2647
2897
  # .github/workflows/deploy.yml
@@ -2671,6 +2921,8 @@ jobs:
2671
2921
  run: harper deploy project=my-app by_ref=true restart=rolling json=true > deploy.json
2672
2922
  - name: Wait for every node to take the release
2673
2923
  run: |
2924
+ # From v5.4.0 a certified rolling deploy waited for every node itself.
2925
+ [ "$(jq -r '.rollout.status // empty' deploy.json)" != success ] || exit 0
2674
2926
  JOB_ID=$(jq -r '.restartJobId // empty' deploy.json)
2675
2927
  [ -n "$JOB_ID" ] || { echo "The rolling deploy returned no restartJobId"; exit 1; }
2676
2928
  LAST="could not be read"
@@ -2691,14 +2943,17 @@ jobs:
2691
2943
  Key details:
2692
2944
 
2693
2945
  - **No secrets.** `HARPER_CLI_TARGET` is the only thing the workflow is given. `id-token: write` lets the CLI request an identity token, and `environment: production` puts the `environment` claim the trust policy requires into it.
2694
- - **`persist-credentials: false`** keeps the job's `GITHUB_TOKEN` out of `.git/config`.
2946
+ - **`persist-credentials: false`** keeps the job's `GITHUB_TOKEN` out of `.git/config`, where `actions/checkout` otherwise leaves it for every later step.
2695
2947
  - **`harper@^5.3`** pins the CLI's major version. 5.3 is the first CLI that authenticates with the identity token.
2696
2948
  - **`replicated` isn't needed.** On Harper Fabric and Harper Pro, a deploy goes to every node unless you pass `replicated=false`. Each node fetches and installs the package itself.
2697
- - **`restart=rolling`** restarts nodes one at a time. Use `restart=true` for a single-node or dev instance.
2698
- - **`json=true`** prints the deploy's result as JSON on stdout; progress and authentication messages go to stderr.
2699
- - **The wait step** polls `restartJobId` via `get_job` until `COMPLETE` or `ERROR`, checking `.[0].status`. It retries a poll that fails (such as one that reaches a node while it restarts) and gives up after 60 polls (~10 minutes).
2700
- - **`certification`**: on v5.4.0 and later, `restart=rolling` loads the release in a canary worker on each node before that node serves it, so a release that fails to load is rejected. The `certification` field in the deploy result reflects this. With `restart=rolling` on v5.4.0 and later, the deploy can report `success` before the rolling job has visited all nodes — wait on the job, not the record.
2701
- - **The job lives on one node.** Through a **load-balanced** cluster URL, polls must eventually reach that node. Routing that always sends polls to a different node than the deploy causes the wait step to fail saying the job was not on the node that answered.
2949
+ - **`restart=rolling`** restarts nodes one at a time so the cluster keeps serving throughout. Use `restart=true` for a single-node or dev instance.
2950
+ - **`certification`**: on v5.4.0 and later, `restart=rolling` loads the release in a canary worker on each node before that node serves it, so a release that fails to load is rejected. The `certification` field in the deploy result reflects whether this ran.
2951
+ - **`restartJobId`**: present in the deploy JSON when a rolling job was started but the deploy did not wait for it itself. The wait step polls `.[0].status` from `get_job` until `COMPLETE` or `ERROR`.
2952
+ - **Load-balanced clusters**: the deployment record wait works through a load-balanced URL (the record replicates), but `get_job` polls do not — Harper does not replicate jobs. If your load balancer never routes back to the node that received the deploy, use one node's direct URL for both the deploy and the wait, and as the trust policy's `audience`.
2953
+
2954
+ For a private repository, add `credential=true` to the deploy command.
2955
+
2956
+ ---
2702
2957
 
2703
2958
  ##### Path B: Build in CI, Publish to GitHub Packages, Deploy the Artifact
2704
2959
 
@@ -2767,6 +3022,7 @@ jobs:
2767
3022
  restart=rolling json=true > deploy.json
2768
3023
  - name: Wait for every node to take the release
2769
3024
  run: |
3025
+ [ "$(jq -r '.rollout.status // empty' deploy.json)" != success ] || exit 0
2770
3026
  JOB_ID=$(jq -r '.restartJobId // empty' deploy.json)
2771
3027
  [ -n "$JOB_ID" ] || { echo "The rolling deploy returned no restartJobId"; exit 1; }
2772
3028
  LAST="could not be read"
@@ -2784,56 +3040,28 @@ jobs:
2784
3040
  exit 1
2785
3041
  ```
2786
3042
 
2787
- Two jobs keep the identity token isolated: `publish` installs, builds, tests and publishes with `contents: read` and `packages: write` only — it cannot request an identity token. `deploy` runs once `publish` finishes (`needs: publish`), holds `environment: production` and `id-token: write`, and installs only the Harper CLI before deploying.
2788
-
2789
- Credential roles:
3043
+ Split into two jobs so the job with `id-token: write` runs none of your build code. Only the `deploy` job names `environment: production`, so only its token carries the `environment` claim the trust policy requires.
2790
3044
 
2791
- - **Publishing** and installing private `@my-org` dependencies use the workflow's ephemeral `GITHUB_TOKEN` (`packages: write` includes read). Both happen entirely inside `publish`.
2792
- - **Installing on Harper nodes** uses the durable `read:packages` token stored as `deploy.my-app.npm.pkg.github.com` — because installs can happen long after this workflow ends.
3045
+ ---
2793
3046
 
2794
- For `my-app`, the credential names are `deploy.my-app.git.github.com` for the repository and `deploy.my-app.npm.pkg.github.com` for GitHub Packages. Store a durable credential once from your machine:
2795
-
2796
- ```bash
2797
- harper deploy setup=true provider=github project=my-app
2798
- # or for GitHub Packages:
2799
- harper deploy setup=true provider=npm project=my-app registry=https://npm.pkg.github.com scope=@my-org
2800
- ```
2801
-
2802
- For a private repository, use a GitHub fine-grained personal access token with **Contents: read-only** scoped to the application repository. For GitHub Packages, use a token with the `read:packages` scope. Do not use the workflow's ephemeral `GITHUB_TOKEN` for the durable credential — it expires when the job ends.
2803
-
2804
- ##### Check the Deployment Record
3047
+ ##### Checking the Deployment Record
2805
3048
 
2806
3049
  ```bash
2807
3050
  harper get_deployment deployment_id="$(jq -r .deployment_id deploy.json)" json=true
2808
3051
  ```
2809
3052
 
2810
- Only the node you called writes that record, so with `restart=rolling` on v5.4.0 and later it can report `success` before the job has visited the other nodes, and keeps reporting it if one of them rejects the release. Wait on the job, not the record, to confirm all nodes took the release. `list_deployments` gives the history of what was deployed and when.
2811
-
2812
- ##### List and Inspect Trust Policies
2813
-
2814
- ```bash
2815
- harper list_oidc_trust
2816
- ```
2817
-
2818
- Returns `{ "policies": [ ... ] }`. Each entry includes `id`, `issuer`, `audience`, `claims`, `user`, `enabled`, and optionally `invalid_reason`. Check `invalid_reason` first when diagnosing a rejected exchange — it is set when the policy is malformed or its user was deleted or deactivated.
2819
-
2820
- ##### Drop a Trust Policy
3053
+ Use `list_deployments` to see the history of what was deployed and when, and filter by commit with `sha`.
2821
3054
 
2822
- ```bash
2823
- harper drop_oidc_trust id=github-actions-<project>
2824
- ```
3055
+ - **Go back to a previous release** by activating its `deployment_id`, with no rebuild or reinstall: `harper deploy project=my-app deployment_id=<id> restart=rolling`. To deploy a release that is no longer kept, re-run the deploy with its tag or version.
2825
3056
 
2826
3057
  #### Notes
2827
3058
 
2828
- - `HARPER_CLI_REFRESH_TOKEN` and any other stored credential outrank the OIDC exchange. Remove them from the workflow if switching to OIDC.
2829
- - **Go back to a previous release** by activating its `deployment_id`, with no rebuild or reinstall: `harper deploy project=my-app deployment_id=<id> restart=rolling`. To deploy a release that is no longer kept, re-run the deploy with its tag or version.
2830
- - **Rotating the deploy credential**: run `harper deploy setup=true` again with the same name and a new value. The pipeline doesn't change.
2831
- - **Revoking the pipeline's access**: `harper drop_oidc_trust` stops new runs; `harper alter_user` also stops a token already issued (valid up to one hour).
2832
- - The `<project>-ci-deploy` user created by setup can deploy any component on the cluster, not only the named project. Access control comes down to who can merge to the branch.
2833
- - The `audience` in `add_oidc_trust` must be the exact target URL with its port and a trailing slash.
2834
- - For tag-triggered workflows, `provider=github-actions` setup is not sufficient — it pins a branch. Write the policy manually using `add_oidc_trust` with `workflow_path` and `environment` claims.
2835
- - `token.actions.githubusercontent.com` is the required `issuer` value for GitHub Actions OIDC policies.
2836
- - Git-host credentials require git ≥ 2.31 on the Harper nodes and are not supported on Windows nodes. Registry credentials have no such constraints.
3059
+ - `audience` in `add_oidc_trust` must include the port and a **trailing slash** — e.g., `https://my-cluster.example.com:9925/`. A URL without the trailing slash will not match.
3060
+ - `HARPER_CLI_REFRESH_TOKEN` and any other configured credential (`auth_username=`/`auth_password=`, `HARPER_CLI_USERNAME`/`HARPER_CLI_PASSWORD`, `HARPER_CLI_OPERATION_TOKEN`, a saved `harper login`) outrank the OIDC exchange. Remove stored credentials from the runner before enabling OIDC.
3061
+ - `list_oidc_trust`, `drop_oidc_trust`, and `alter_user` all require `super_user`.
3062
+ - `drop_oidc_trust` does not revoke already-issued operation tokens (one-hour JWTs). Pair it with `alter_user username=<project>-ci-deploy active=false` to stop tokens already in circulation.
3063
+ - `ref_type: tag` is not accepted as a ref gate. Gate tag-triggered workflows with `environment` instead, and set **Deployment branches and tags** to `v*` in the environment's settings.
3064
+ - Every step in a job with `id-token: write` can request an identity token — GitHub gives each one `ACTIONS_ID_TOKEN_REQUEST_URL` and `ACTIONS_ID_TOKEN_REQUEST_TOKEN`. Run none of the repository's code in that job.
2837
3065
  - See [deploying-to-harper-fabric.md](deploying-to-harper-fabric.md) for cluster-level deployment context.
2838
3066
 
2839
3067
  ### 4.3 Creating a Harper Fabric Account and Cluster
@@ -2982,6 +3210,8 @@ don't have `deploy:setup-ci`. To move one over:
2982
3210
 
2983
3211
  Instructions for the agent to follow when serving web content from Harper.
2984
3212
 
3213
+ **Instead of:** Nginx, Vercel, or Netlify for the frontend; a separate Vite dev server. Use this rule rather than adding that service. See `harper-architecture`.
3214
+
2985
3215
  #### When to Use
2986
3216
 
2987
3217
  Use this skill when you need to serve a frontend (HTML, CSS, JS, or a React/Vue app) directly from your Harper instance — either plain static files or an integrated Vite app with hot module replacement (HMR) in development and a real production build when deployed.
@@ -3089,6 +3319,8 @@ On deploy the plugin runs `vite build` at startup (and rebuilds when `files` cha
3089
3319
 
3090
3320
  Instructions for the agent to follow when implementing logging in Harper applications, including direct logger usage, tagged loggers, and console capture behavior.
3091
3321
 
3322
+ **Instead of:** Separate log shippers for app logs (pino or winston transports). Use this rule rather than adding that service. See `harper-architecture`.
3323
+
3092
3324
  #### When to Use
3093
3325
 
3094
3326
  Apply this rule when writing any JavaScript component, plugin, or resource that needs to emit structured log entries, filter logs by component, or capture existing `console.log` output into Harper's log system. Use it whenever you need to understand log levels, log entry format, or the `logger` global API.
@@ -3245,6 +3477,8 @@ Tagged entries appear in `hdb.log` with the tag in the header:
3245
3477
 
3246
3478
  Instructions for the agent to follow when loading environment variables from `.env` files into a Harper application using the `loadEnv` plugin.
3247
3479
 
3480
+ **Instead of:** `dotenv`. Use this rule rather than adding that service. See `harper-architecture`.
3481
+
3248
3482
  #### When to Use
3249
3483
 
3250
3484
  Apply this rule when a Harper application needs to supply secrets, API endpoints, or other configuration values to component code via `process.env` without hardcoding them. Use this whenever a `.env` file must be loaded at application startup.