specrig 0.2.0__tar.gz → 0.3.0__tar.gz

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 (141) hide show
  1. {specrig-0.2.0 → specrig-0.3.0}/PKG-INFO +27 -5
  2. {specrig-0.2.0 → specrig-0.3.0}/README.md +26 -4
  3. {specrig-0.2.0 → specrig-0.3.0}/docs/design.md +20 -2
  4. {specrig-0.2.0 → specrig-0.3.0}/pyproject.toml +2 -1
  5. {specrig-0.2.0 → specrig-0.3.0}/samples/reminders/specrig.md +12 -3
  6. {specrig-0.2.0 → specrig-0.3.0}/samples/shop/specrig.md +12 -3
  7. {specrig-0.2.0 → specrig-0.3.0}/src/specrig/__init__.py +2 -2
  8. {specrig-0.2.0 → specrig-0.3.0}/src/specrig/backend.py +37 -13
  9. {specrig-0.2.0 → specrig-0.3.0}/src/specrig/postgres.py +108 -4
  10. {specrig-0.2.0 → specrig-0.3.0}/src/specrig/tool/templates.py +11 -2
  11. specrig-0.3.0/tests/test_backend.py +226 -0
  12. {specrig-0.2.0 → specrig-0.3.0}/tests/test_harness.py +131 -0
  13. {specrig-0.2.0 → specrig-0.3.0}/tests/test_postgres.py +206 -1
  14. specrig-0.2.0/tests/test_backend.py +0 -109
  15. {specrig-0.2.0 → specrig-0.3.0}/.github/workflows/release.yml +0 -0
  16. {specrig-0.2.0 → specrig-0.3.0}/.gitignore +0 -0
  17. {specrig-0.2.0 → specrig-0.3.0}/LICENSE +0 -0
  18. {specrig-0.2.0 → specrig-0.3.0}/Makefile +0 -0
  19. {specrig-0.2.0 → specrig-0.3.0}/samples/docs/billing.yaml +0 -0
  20. {specrig-0.2.0 → specrig-0.3.0}/samples/reminders/AGENTS.md +0 -0
  21. {specrig-0.2.0 → specrig-0.3.0}/samples/reminders/CLAUDE.md +0 -0
  22. {specrig-0.2.0 → specrig-0.3.0}/samples/reminders/config/__init__.py +0 -0
  23. {specrig-0.2.0 → specrig-0.3.0}/samples/reminders/config/settings.py +0 -0
  24. {specrig-0.2.0 → specrig-0.3.0}/samples/reminders/config/urls.py +0 -0
  25. {specrig-0.2.0 → specrig-0.3.0}/samples/reminders/config/wsgi.py +0 -0
  26. {specrig-0.2.0 → specrig-0.3.0}/samples/reminders/manage.py +0 -0
  27. {specrig-0.2.0 → specrig-0.3.0}/samples/reminders/pyproject.toml +0 -0
  28. {specrig-0.2.0 → specrig-0.3.0}/samples/reminders/reminders/__init__.py +0 -0
  29. {specrig-0.2.0 → specrig-0.3.0}/samples/reminders/reminders/billing.py +0 -0
  30. {specrig-0.2.0 → specrig-0.3.0}/samples/reminders/reminders/jobs.py +0 -0
  31. {specrig-0.2.0 → specrig-0.3.0}/samples/reminders/reminders/management/__init__.py +0 -0
  32. {specrig-0.2.0 → specrig-0.3.0}/samples/reminders/reminders/management/commands/__init__.py +0 -0
  33. {specrig-0.2.0 → specrig-0.3.0}/samples/reminders/reminders/management/commands/remind.py +0 -0
  34. {specrig-0.2.0 → specrig-0.3.0}/samples/reminders/reminders/migrations/0001_initial.py +0 -0
  35. {specrig-0.2.0 → specrig-0.3.0}/samples/reminders/reminders/migrations/__init__.py +0 -0
  36. {specrig-0.2.0 → specrig-0.3.0}/samples/reminders/reminders/models.py +0 -0
  37. {specrig-0.2.0 → specrig-0.3.0}/samples/reminders/reminders/views.py +0 -0
  38. {specrig-0.2.0 → specrig-0.3.0}/samples/reminders/specrig.yaml +0 -0
  39. {specrig-0.2.0 → specrig-0.3.0}/samples/reminders/tests/checks.yaml +0 -0
  40. {specrig-0.2.0 → specrig-0.3.0}/samples/reminders/tests/features/history.feature +0 -0
  41. {specrig-0.2.0 → specrig-0.3.0}/samples/reminders/tests/features/reminders.feature +0 -0
  42. {specrig-0.2.0 → specrig-0.3.0}/samples/reminders/tests/flows.yaml +0 -0
  43. {specrig-0.2.0 → specrig-0.3.0}/samples/reminders/tests/internal/boot.feature +0 -0
  44. {specrig-0.2.0 → specrig-0.3.0}/samples/reminders/tests/internal/harness.py +0 -0
  45. {specrig-0.2.0 → specrig-0.3.0}/samples/reminders/tests/internal/test_cases.py +0 -0
  46. {specrig-0.2.0 → specrig-0.3.0}/samples/reminders/tests/steps/history.py +0 -0
  47. {specrig-0.2.0 → specrig-0.3.0}/samples/reminders/tests/steps/reminders.py +0 -0
  48. {specrig-0.2.0 → specrig-0.3.0}/samples/reminders/tests/surface.yaml +0 -0
  49. {specrig-0.2.0 → specrig-0.3.0}/samples/shop/AGENTS.md +0 -0
  50. {specrig-0.2.0 → specrig-0.3.0}/samples/shop/CLAUDE.md +0 -0
  51. {specrig-0.2.0 → specrig-0.3.0}/samples/shop/alembic.ini +0 -0
  52. {specrig-0.2.0 → specrig-0.3.0}/samples/shop/migrations/env.py +0 -0
  53. {specrig-0.2.0 → specrig-0.3.0}/samples/shop/migrations/versions/0001_orders.py +0 -0
  54. {specrig-0.2.0 → specrig-0.3.0}/samples/shop/pyproject.toml +0 -0
  55. {specrig-0.2.0 → specrig-0.3.0}/samples/shop/shop/__init__.py +0 -0
  56. {specrig-0.2.0 → specrig-0.3.0}/samples/shop/shop/billing.py +0 -0
  57. {specrig-0.2.0 → specrig-0.3.0}/samples/shop/shop/cli.py +0 -0
  58. {specrig-0.2.0 → specrig-0.3.0}/samples/shop/shop/db.py +0 -0
  59. {specrig-0.2.0 → specrig-0.3.0}/samples/shop/shop/jobs.py +0 -0
  60. {specrig-0.2.0 → specrig-0.3.0}/samples/shop/shop/main.py +0 -0
  61. {specrig-0.2.0 → specrig-0.3.0}/samples/shop/shop/settings.py +0 -0
  62. {specrig-0.2.0 → specrig-0.3.0}/samples/shop/specrig.yaml +0 -0
  63. {specrig-0.2.0 → specrig-0.3.0}/samples/shop/tests/checks.yaml +0 -0
  64. {specrig-0.2.0 → specrig-0.3.0}/samples/shop/tests/features/orders.feature +0 -0
  65. {specrig-0.2.0 → specrig-0.3.0}/samples/shop/tests/features/placing.feature +0 -0
  66. {specrig-0.2.0 → specrig-0.3.0}/samples/shop/tests/flows.yaml +0 -0
  67. {specrig-0.2.0 → specrig-0.3.0}/samples/shop/tests/internal/boot.feature +0 -0
  68. {specrig-0.2.0 → specrig-0.3.0}/samples/shop/tests/internal/harness.py +0 -0
  69. {specrig-0.2.0 → specrig-0.3.0}/samples/shop/tests/internal/test_cases.py +0 -0
  70. {specrig-0.2.0 → specrig-0.3.0}/samples/shop/tests/steps/orders.py +0 -0
  71. {specrig-0.2.0 → specrig-0.3.0}/samples/shop/tests/steps/placing.py +0 -0
  72. {specrig-0.2.0 → specrig-0.3.0}/samples/shop/tests/surface.yaml +0 -0
  73. {specrig-0.2.0 → specrig-0.3.0}/src/specrig/__main__.py +0 -0
  74. {specrig-0.2.0 → specrig-0.3.0}/src/specrig/_yaml.py +0 -0
  75. {specrig-0.2.0 → specrig-0.3.0}/src/specrig/asgi.py +0 -0
  76. {specrig-0.2.0 → specrig-0.3.0}/src/specrig/clock.py +0 -0
  77. {specrig-0.2.0 → specrig-0.3.0}/src/specrig/code/__init__.py +0 -0
  78. {specrig-0.2.0 → specrig-0.3.0}/src/specrig/code/calls.py +0 -0
  79. {specrig-0.2.0 → specrig-0.3.0}/src/specrig/code/django.py +0 -0
  80. {specrig-0.2.0 → specrig-0.3.0}/src/specrig/code/fastapi.py +0 -0
  81. {specrig-0.2.0 → specrig-0.3.0}/src/specrig/code/index.py +0 -0
  82. {specrig-0.2.0 → specrig-0.3.0}/src/specrig/code/settings.py +0 -0
  83. {specrig-0.2.0 → specrig-0.3.0}/src/specrig/contract.py +0 -0
  84. {specrig-0.2.0 → specrig-0.3.0}/src/specrig/django/__init__.py +0 -0
  85. {specrig-0.2.0 → specrig-0.3.0}/src/specrig/docker.py +0 -0
  86. {specrig-0.2.0 → specrig-0.3.0}/src/specrig/fastapi/__init__.py +0 -0
  87. {specrig-0.2.0 → specrig-0.3.0}/src/specrig/flows.py +0 -0
  88. {specrig-0.2.0 → specrig-0.3.0}/src/specrig/gate.py +0 -0
  89. {specrig-0.2.0 → specrig-0.3.0}/src/specrig/http.py +0 -0
  90. {specrig-0.2.0 → specrig-0.3.0}/src/specrig/protocol/agents/contributing.md +0 -0
  91. {specrig-0.2.0 → specrig-0.3.0}/src/specrig/protocol/agents/header.md +0 -0
  92. {specrig-0.2.0 → specrig-0.3.0}/src/specrig/protocol/agents/reference.md +0 -0
  93. {specrig-0.2.0 → specrig-0.3.0}/src/specrig/protocol/agents/rule11.md +0 -0
  94. {specrig-0.2.0 → specrig-0.3.0}/src/specrig/protocol/agents/short.md +0 -0
  95. {specrig-0.2.0 → specrig-0.3.0}/src/specrig/protocol/modes.yaml +0 -0
  96. {specrig-0.2.0 → specrig-0.3.0}/src/specrig/proxy.py +0 -0
  97. {specrig-0.2.0 → specrig-0.3.0}/src/specrig/py.typed +0 -0
  98. {specrig-0.2.0 → specrig-0.3.0}/src/specrig/records.py +0 -0
  99. {specrig-0.2.0 → specrig-0.3.0}/src/specrig/redis.py +0 -0
  100. {specrig-0.2.0 → specrig-0.3.0}/src/specrig/resource.py +0 -0
  101. {specrig-0.2.0 → specrig-0.3.0}/src/specrig/rig.py +0 -0
  102. {specrig-0.2.0 → specrig-0.3.0}/src/specrig/run.py +0 -0
  103. {specrig-0.2.0 → specrig-0.3.0}/src/specrig/standins.py +0 -0
  104. {specrig-0.2.0 → specrig-0.3.0}/src/specrig/suite.py +0 -0
  105. {specrig-0.2.0 → specrig-0.3.0}/src/specrig/surface.py +0 -0
  106. {specrig-0.2.0 → specrig-0.3.0}/src/specrig/tool/__init__.py +0 -0
  107. {specrig-0.2.0 → specrig-0.3.0}/src/specrig/tool/agents.py +0 -0
  108. {specrig-0.2.0 → specrig-0.3.0}/src/specrig/tool/cli.py +0 -0
  109. {specrig-0.2.0 → specrig-0.3.0}/src/specrig/tool/init.py +0 -0
  110. {specrig-0.2.0 → specrig-0.3.0}/src/specrig/tool/modes.py +0 -0
  111. {specrig-0.2.0 → specrig-0.3.0}/src/specrig/tool/service.py +0 -0
  112. {specrig-0.2.0 → specrig-0.3.0}/tests/fixtures/django/manage.py +0 -0
  113. {specrig-0.2.0 → specrig-0.3.0}/tests/fixtures/django/pyproject.toml +0 -0
  114. {specrig-0.2.0 → specrig-0.3.0}/tests/fixtures/django/stock/__init__.py +0 -0
  115. {specrig-0.2.0 → specrig-0.3.0}/tests/fixtures/django/stock/api.py +0 -0
  116. {specrig-0.2.0 → specrig-0.3.0}/tests/fixtures/django/stock/settings.py +0 -0
  117. {specrig-0.2.0 → specrig-0.3.0}/tests/fixtures/django/stock/urls.py +0 -0
  118. {specrig-0.2.0 → specrig-0.3.0}/tests/fixtures/django/stock/views.py +0 -0
  119. {specrig-0.2.0 → specrig-0.3.0}/tests/fixtures/django/stock/warehouse.py +0 -0
  120. {specrig-0.2.0 → specrig-0.3.0}/tests/fixtures/fastapi/.env.example +0 -0
  121. {specrig-0.2.0 → specrig-0.3.0}/tests/fixtures/fastapi/gateway/__init__.py +0 -0
  122. {specrig-0.2.0 → specrig-0.3.0}/tests/fixtures/fastapi/gateway/config.py +0 -0
  123. {specrig-0.2.0 → specrig-0.3.0}/tests/fixtures/fastapi/gateway/crm.py +0 -0
  124. {specrig-0.2.0 → specrig-0.3.0}/tests/fixtures/fastapi/gateway/main.py +0 -0
  125. {specrig-0.2.0 → specrig-0.3.0}/tests/fixtures/fastapi/gateway/routes/__init__.py +0 -0
  126. {specrig-0.2.0 → specrig-0.3.0}/tests/fixtures/fastapi/gateway/routes/orders.py +0 -0
  127. {specrig-0.2.0 → specrig-0.3.0}/tests/fixtures/fastapi/gateway/sessions.py +0 -0
  128. {specrig-0.2.0 → specrig-0.3.0}/tests/fixtures/fastapi/pyproject.toml +0 -0
  129. {specrig-0.2.0 → specrig-0.3.0}/tests/fixtures/protocol/agents/verdicts.yaml +0 -0
  130. {specrig-0.2.0 → specrig-0.3.0}/tests/fixtures/protocol/markers/areas.yaml +0 -0
  131. {specrig-0.2.0 → specrig-0.3.0}/tests/fixtures/protocol/records/reminders.flows.yaml +0 -0
  132. {specrig-0.2.0 → specrig-0.3.0}/tests/fixtures/protocol/records/reminders.trace.json +0 -0
  133. {specrig-0.2.0 → specrig-0.3.0}/tests/fixtures/protocol/records/shop.flows.yaml +0 -0
  134. {specrig-0.2.0 → specrig-0.3.0}/tests/fixtures/protocol/records/shop.trace.json +0 -0
  135. {specrig-0.2.0 → specrig-0.3.0}/tests/test_agents.py +0 -0
  136. {specrig-0.2.0 → specrig-0.3.0}/tests/test_flows.py +0 -0
  137. {specrig-0.2.0 → specrig-0.3.0}/tests/test_format.py +0 -0
  138. {specrig-0.2.0 → specrig-0.3.0}/tests/test_gate.py +0 -0
  139. {specrig-0.2.0 → specrig-0.3.0}/tests/test_init.py +0 -0
  140. {specrig-0.2.0 → specrig-0.3.0}/tests/test_rules.py +0 -0
  141. {specrig-0.2.0 → specrig-0.3.0}/tests/test_surface.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: specrig
3
- Version: 0.2.0
3
+ Version: 0.3.0
4
4
  Summary: A harness for a FastAPI or Django service developed specification first
5
5
  Project-URL: Homepage, https://github.com/specrig/specrig-python
6
6
  Project-URL: Protocol, https://github.com/specrig/protocol
@@ -241,10 +241,10 @@ fixtures, autouse ones included, would change the service the cases see.
241
241
 
242
242
  | | |
243
243
  |---|---|
244
- | `harness.<backend>.reply(method, path, status, body)` | what the neighbour answers |
245
- | `harness.<backend>.reply_to(method, path, query, status, body)` | the same, for a request whose query has these values; the reply naming the most of them wins |
244
+ | `harness.<backend>.reply(method, path, status, body, headers)` | what the neighbour answers; `headers`, strings to strings, are added to the response the client sees. A segment in braces, `/orders/{id}`, takes any one segment, the exact path wins over a template and the first declared of two templates over the second — in every call here that names a path |
245
+ | `harness.<backend>.reply_to(method, path, query, status, body, headers)` | the same, for a request whose query has these values; the reply naming the most of them wins |
246
246
  | `harness.<backend>.time_out(method, path, query=None)` | the neighbour does not answer in time: the client meets the timeout it would meet, at once |
247
- | `harness.<backend>.requests(method, path)` | what reached the neighbour: `method`, `path`, `query` and `json`, its decoded body |
247
+ | `harness.<backend>.requests(method, path)` | what reached the neighbour: `method`, `path`, `query` and `json`, its decoded body; a template returns the requests it takes |
248
248
  | `harness.http()` | a client of the service's own stack: `get`, `post`, `put`, `patch`, `delete`, `send`; a response with `status`, `headers`, `text`, `json()` |
249
249
  | `harness.run(job, *args, **kwargs)` | a job — see below |
250
250
  | `harness.command(name, *args)` | a command of the service's console; returns its exit code, and `harness.output` what it wrote |
@@ -253,6 +253,10 @@ fixtures, autouse ones included, would change the service the cases see.
253
253
  | `harness.logs` | what the service logged, under its real logging configuration |
254
254
  | `harness.clock` | the service's clock: the wall clock until a step calls `set()`, then `advance()` |
255
255
  | `harness.<resource>.fail_with(error=None)` | the resource goes down for the service — see Postgres and Redis |
256
+ | `harness.<postgres>.refuse(table, *writes)` | every insert, update and delete on the table, or those given, fails in Postgres itself, whoever sends it; reads go on working — see Postgres |
257
+ | `harness.<postgres>.refuse_after(table, rows, *writes)` | the same after the first `rows` rows |
258
+ | `harness.<postgres>.refuse_commit(table, *writes)` | the statements go through, and the commit of a transaction that wrote the table fails |
259
+ | `harness.<postgres>.heal()` | the database comes back: `fail_with` and every refusal end, and the rows stay |
256
260
  | `harness.application` | FastAPI: the app the case runs |
257
261
 
258
262
  The service boots at the first step that reaches it, so a `Given` declares and
@@ -269,7 +273,8 @@ every request and every answer is checked against the neighbour's contract: an
269
273
  unknown route or method, an undocumented status, or a body of the wrong shape
270
274
  fails it. So does a request the service sends past the stand-ins — through
271
275
  `urllib`, or `aiohttp` reading the proxy from the environment —, which the
272
- harness's proxy refused.
276
+ harness's proxy refused. A path template in a step changes nothing there: the
277
+ check and the records start from the path the request came with.
273
278
 
274
279
  A step's request returns when the service has answered it and done everything
275
280
  the request made it do, background tasks included: nothing is in flight between
@@ -396,6 +401,23 @@ with it — through SQLAlchemy's events, or Django's execute wrappers. A fault a
396
401
  step sets before the boot waits for the service to start, so the migration and
397
402
  the start go through; the next case starts whole.
398
403
 
404
+ `harness.database.refuse("orders")` makes Postgres refuse one table's writes —
405
+ every insert, update and delete, or only those named, `Write.INSERT`,
406
+ `Write.UPDATE`, `Write.DELETE` — with `specrig: insert on orders is refused`. A
407
+ trigger refuses, not the service's driver, so every connection meets it, the
408
+ case's own included, and a write in `RETURNING` or a CTE as well as a plain one;
409
+ reads and the other tables go on working, so a case can check what the failed
410
+ write left behind. The table is `orders` or `schema.orders`. `refuse_after("orders",
411
+ 1)` lets that many rows through first, and a rolled-back row gives its place back.
412
+ `refuse_commit("orders")` lets every statement through and fails the commit of a
413
+ transaction that wrote the table, for a case about what gets out before the commit.
414
+ A refusal set before the boot waits for the service to start, as a fault does.
415
+ `heal()` is the database coming back in the middle of a case: it ends `fail_with`
416
+ and every refusal and keeps the rows. The reset before every case lifts the
417
+ refusals, before it writes the rows back. They live in a schema of their own,
418
+ `specrig_refusal`, which the reset's discovery and the `specrig_baseline` copy never
419
+ see.
420
+
399
421
  ## Redis
400
422
 
401
423
  ```python
@@ -213,10 +213,10 @@ fixtures, autouse ones included, would change the service the cases see.
213
213
 
214
214
  | | |
215
215
  |---|---|
216
- | `harness.<backend>.reply(method, path, status, body)` | what the neighbour answers |
217
- | `harness.<backend>.reply_to(method, path, query, status, body)` | the same, for a request whose query has these values; the reply naming the most of them wins |
216
+ | `harness.<backend>.reply(method, path, status, body, headers)` | what the neighbour answers; `headers`, strings to strings, are added to the response the client sees. A segment in braces, `/orders/{id}`, takes any one segment, the exact path wins over a template and the first declared of two templates over the second — in every call here that names a path |
217
+ | `harness.<backend>.reply_to(method, path, query, status, body, headers)` | the same, for a request whose query has these values; the reply naming the most of them wins |
218
218
  | `harness.<backend>.time_out(method, path, query=None)` | the neighbour does not answer in time: the client meets the timeout it would meet, at once |
219
- | `harness.<backend>.requests(method, path)` | what reached the neighbour: `method`, `path`, `query` and `json`, its decoded body |
219
+ | `harness.<backend>.requests(method, path)` | what reached the neighbour: `method`, `path`, `query` and `json`, its decoded body; a template returns the requests it takes |
220
220
  | `harness.http()` | a client of the service's own stack: `get`, `post`, `put`, `patch`, `delete`, `send`; a response with `status`, `headers`, `text`, `json()` |
221
221
  | `harness.run(job, *args, **kwargs)` | a job — see below |
222
222
  | `harness.command(name, *args)` | a command of the service's console; returns its exit code, and `harness.output` what it wrote |
@@ -225,6 +225,10 @@ fixtures, autouse ones included, would change the service the cases see.
225
225
  | `harness.logs` | what the service logged, under its real logging configuration |
226
226
  | `harness.clock` | the service's clock: the wall clock until a step calls `set()`, then `advance()` |
227
227
  | `harness.<resource>.fail_with(error=None)` | the resource goes down for the service — see Postgres and Redis |
228
+ | `harness.<postgres>.refuse(table, *writes)` | every insert, update and delete on the table, or those given, fails in Postgres itself, whoever sends it; reads go on working — see Postgres |
229
+ | `harness.<postgres>.refuse_after(table, rows, *writes)` | the same after the first `rows` rows |
230
+ | `harness.<postgres>.refuse_commit(table, *writes)` | the statements go through, and the commit of a transaction that wrote the table fails |
231
+ | `harness.<postgres>.heal()` | the database comes back: `fail_with` and every refusal end, and the rows stay |
228
232
  | `harness.application` | FastAPI: the app the case runs |
229
233
 
230
234
  The service boots at the first step that reaches it, so a `Given` declares and
@@ -241,7 +245,8 @@ every request and every answer is checked against the neighbour's contract: an
241
245
  unknown route or method, an undocumented status, or a body of the wrong shape
242
246
  fails it. So does a request the service sends past the stand-ins — through
243
247
  `urllib`, or `aiohttp` reading the proxy from the environment —, which the
244
- harness's proxy refused.
248
+ harness's proxy refused. A path template in a step changes nothing there: the
249
+ check and the records start from the path the request came with.
245
250
 
246
251
  A step's request returns when the service has answered it and done everything
247
252
  the request made it do, background tasks included: nothing is in flight between
@@ -368,6 +373,23 @@ with it — through SQLAlchemy's events, or Django's execute wrappers. A fault a
368
373
  step sets before the boot waits for the service to start, so the migration and
369
374
  the start go through; the next case starts whole.
370
375
 
376
+ `harness.database.refuse("orders")` makes Postgres refuse one table's writes —
377
+ every insert, update and delete, or only those named, `Write.INSERT`,
378
+ `Write.UPDATE`, `Write.DELETE` — with `specrig: insert on orders is refused`. A
379
+ trigger refuses, not the service's driver, so every connection meets it, the
380
+ case's own included, and a write in `RETURNING` or a CTE as well as a plain one;
381
+ reads and the other tables go on working, so a case can check what the failed
382
+ write left behind. The table is `orders` or `schema.orders`. `refuse_after("orders",
383
+ 1)` lets that many rows through first, and a rolled-back row gives its place back.
384
+ `refuse_commit("orders")` lets every statement through and fails the commit of a
385
+ transaction that wrote the table, for a case about what gets out before the commit.
386
+ A refusal set before the boot waits for the service to start, as a fault does.
387
+ `heal()` is the database coming back in the middle of a case: it ends `fail_with`
388
+ and every refusal and keeps the rows. The reset before every case lifts the
389
+ refusals, before it writes the rows back. They live in a schema of their own,
390
+ `specrig_refusal`, which the reset's discovery and the `specrig_baseline` copy never
391
+ see.
392
+
371
393
  ## Redis
372
394
 
373
395
  ```python
@@ -82,7 +82,7 @@ flight between steps, and `time_out` stands in for a slow neighbour, as in PHP.
82
82
  | clock | `time-machine`, from the step that sets it | the same |
83
83
  | logs | a handler on the root logger, beside the service's | the same |
84
84
  | grants | `replace(name, instance)`: the name replaced in every module of the service that holds it, and in FastAPI's `dependency_overrides` | the name replaced in every module that holds it |
85
- | Postgres | `alembic upgrade head` once per run, in a process of its own, and the rows it wrote kept aside; before every case every table but `alembic_version` and `keep` truncated and those rows written back, with the sequences where they stood; a refusal by the gate, an error by SQLAlchemy's `before_cursor_execute` | `migrate` once per run, and the rows it wrote kept aside; every table but Django's bookkeeping and `keep` truncated and those rows written back; an error by the connections' execute wrappers |
85
+ | Postgres | `alembic upgrade head` once per run, in a process of its own, and the rows it wrote kept aside; before every case every table but `alembic_version` and `keep` truncated and those rows written back, with the sequences where they stood; a refusal by the gate, an error by SQLAlchemy's `before_cursor_execute`, a table's writes by triggers in a schema of their own | `migrate` once per run, and the rows it wrote kept aside; every table but Django's bookkeeping and `keep` truncated and those rows written back; an error by the connections' execute wrappers; a table's writes by the same triggers |
86
86
  | Redis | `FLUSHDB` before every case; a refusal by the gate | the same |
87
87
  | steps | pytest-bdd: a plugin of `pytest11` marks Given, When and Then, fails a case on what the harness found, and writes `tests/flows.yaml` after a whole run | |
88
88
  | `tests/surface.yaml` | Python's `ast` over the service's modules: routes from FastAPI's decorators and routers | routes from the `ROOT_URLCONF` and the views' methods |
@@ -124,12 +124,30 @@ at import, in a lifespan, inside a job — so the stand-ins sit where every clie
124
124
  sends: `httpx`'s `HTTPTransport` and `AsyncHTTPTransport`, the same in `httpx2`,
125
125
  and `requests`' `HTTPAdapter`. Every other client meets the proxy.
126
126
 
127
+ **A template names what a step answers, not what the service sent.** As in
128
+ specrig-js, a path segment in braces takes any one segment, the exact path wins,
129
+ and of two templates the first declared; `reply`, `reply_to`, `time_out` and
130
+ `requests` all take one. The contract check and the trace go on naming a request
131
+ by the path it came with, so a template that matches too much is not hidden by
132
+ the contract it is checked against. A reply's headers are added to the response
133
+ as the transports hand it to the client, so `httpx`, `httpx2` and `requests` see
134
+ them alike.
135
+
127
136
  **A gate, not an address.** PHP points a refused connection at a port where
128
137
  nothing listens, which works because the application is built anew for every
129
138
  case. Here the engine was built at import, with the address it was given, so the
130
139
  address is a gate on loopback that the case opens and closes: a refusal reaches
131
140
  every connection the service holds and every one it makes, whatever its driver.
132
141
 
142
+ **Triggers, not hooks, for a refused table.** Ported from gorib's `sqltest`: a
143
+ trigger refuses in Postgres itself, so every connection meets it — the service's,
144
+ a job's, the case's own — and the service meets the driver's own exception, where
145
+ a hook in SQLAlchemy or Django reaches only the clients that go through it. The
146
+ triggers, and the quota that lets `refuse_after`'s rows through, live in a
147
+ `specrig_refusal` schema, outside the tables the reset truncates and the baseline
148
+ copies, and the reset drops it first. A refusal a step sets before the boot waits
149
+ for the start, since the reset would lift it and the schema may not exist yet.
150
+
133
151
  **Alembic in a process of its own.** Alembic's `env.py` configures logging as it
134
152
  runs, disabling the loggers the service already made; production runs the
135
153
  migration as a job of its own, and so does the harness.
@@ -168,5 +186,5 @@ libraries.
168
186
  | 0 | the records and the fixtures | built |
169
187
  | 1 | boot, settings, HTTP stand-ins and the contract check, the pytest-bdd binding, Postgres, logs, the clock; HTTP, jobs and commands as ways in; `tests/flows.yaml` | built |
170
188
  | 2 | `tests/surface.yaml` and `tests/checks.yaml`, `init`, `rules`, `specrig.md` | built |
171
- | 3 | Redis, grants, `fail_with` | built |
189
+ | 3 | Redis, grants, `fail_with`, a refused table and `heal`, reply headers and path templates | built |
172
190
  | 4 | a bus and a queue | not started |
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "specrig"
7
- version = "0.2.0"
7
+ version = "0.3.0"
8
8
  description = "A harness for a FastAPI or Django service developed specification first"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -44,6 +44,7 @@ Protocol = "https://github.com/specrig/protocol"
44
44
  dev = [
45
45
  "alembic>=1.16",
46
46
  "httpx>=0.27",
47
+ "httpx2>=2.0",
47
48
  "psycopg[binary]>=3.2",
48
49
  "pydantic-settings>=2.8",
49
50
  "redis>=5.0",
@@ -1,7 +1,7 @@
1
1
  # specrig.md
2
2
 
3
3
  How an agent works in this service under specrig: Rule #11, and how the suite runs.
4
- `../../.venv/bin/specrig agents` writes this file with `specrig` 0.2.0, and an edit here is lost on the next
4
+ `../../.venv/bin/specrig agents` writes this file with `specrig` 0.3.0, and an edit here is lost on the next
5
5
  run; `AGENTS.md` beside it is the service's own.
6
6
 
7
7
  **Read `CONTRIBUTING.md` first, if it exists.** It holds service-specific
@@ -395,8 +395,9 @@ sessions; a `Then` reads back through them, not out of the answer.
395
395
  `tests/steps/` takes it as the `harness` fixture, with an attribute for each
396
396
  neighbour `harness.py` names:
397
397
 
398
- - `harness.<neighbour>.reply(method, path, status, body)` — what the neighbour
399
- answers;
398
+ - `harness.<neighbour>.reply(method, path, status, body, headers)` — what the
399
+ neighbour answers; a segment in braces, `/orders/{id}`, takes any value here and in
400
+ every call below, and the exact path wins;
400
401
  - `harness.<neighbour>.reply_to(method, path, query, status, body)` — the same, for a
401
402
  request whose query has these values;
402
403
  - `harness.<neighbour>.time_out(method, path)` — the neighbour not answering in time;
@@ -406,6 +407,14 @@ neighbour `harness.py` names:
406
407
  A call to a neighbour no step answered fails the case, and so does a request to an
407
408
  origin `harness.py` does not name — it is refused before it leaves.
408
409
 
410
+ **An outage is not a replacement.** `harness.<resource>.fail_with()` takes the
411
+ database or the cache away until the next case. A write that fails halfway is not an
412
+ outage: `harness.<database>.refuse(table)` makes one table refuse writes,
413
+ `refuse_after(table, rows)` lets that many rows through first, and reads keep working,
414
+ so a `Then` can check what the failed write left; `refuse_commit(table)` fails only
415
+ the commit. `heal()` is the database coming back: it ends the fault and every refusal
416
+ and keeps the rows.
417
+
409
418
  **What may be stood in for is given, not chosen.** `tests/internal/` is read-only. A
410
419
  case that needs another neighbour, or an answer the stand-in cannot give, is a stop:
411
420
  name the neighbour or the behaviour, and the assertion it would carry.
@@ -1,7 +1,7 @@
1
1
  # specrig.md
2
2
 
3
3
  How an agent works in this service under specrig: Rule #11, and how the suite runs.
4
- `../../.venv/bin/specrig agents` writes this file with `specrig` 0.2.0, and an edit here is lost on the next
4
+ `../../.venv/bin/specrig agents` writes this file with `specrig` 0.3.0, and an edit here is lost on the next
5
5
  run; `AGENTS.md` beside it is the service's own.
6
6
 
7
7
  **Read `CONTRIBUTING.md` first, if it exists.** It holds service-specific
@@ -395,8 +395,9 @@ sessions; a `Then` reads back through them, not out of the answer.
395
395
  `tests/steps/` takes it as the `harness` fixture, with an attribute for each
396
396
  neighbour `harness.py` names:
397
397
 
398
- - `harness.<neighbour>.reply(method, path, status, body)` — what the neighbour
399
- answers;
398
+ - `harness.<neighbour>.reply(method, path, status, body, headers)` — what the
399
+ neighbour answers; a segment in braces, `/orders/{id}`, takes any value here and in
400
+ every call below, and the exact path wins;
400
401
  - `harness.<neighbour>.reply_to(method, path, query, status, body)` — the same, for a
401
402
  request whose query has these values;
402
403
  - `harness.<neighbour>.time_out(method, path)` — the neighbour not answering in time;
@@ -406,6 +407,14 @@ neighbour `harness.py` names:
406
407
  A call to a neighbour no step answered fails the case, and so does a request to an
407
408
  origin `harness.py` does not name — it is refused before it leaves.
408
409
 
410
+ **An outage is not a replacement.** `harness.<resource>.fail_with()` takes the
411
+ database or the cache away until the next case. A write that fails halfway is not an
412
+ outage: `harness.<database>.refuse(table)` makes one table refuse writes,
413
+ `refuse_after(table, rows)` lets that many rows through first, and reads keep working,
414
+ so a `Then` can check what the failed write left; `refuse_commit(table)` fails only
415
+ the commit. `heal()` is the database coming back: it ends the fault and every refusal
416
+ and keeps the rows.
417
+
409
418
  **What may be stood in for is given, not chosen.** `tests/internal/` is read-only. A
410
419
  case that needs another neighbour, or an answer the stand-in cannot give, is a stop:
411
420
  name the neighbour or the behaviour, and the assertion it would carry.
@@ -1,7 +1,7 @@
1
1
  from .backend import Backend, Received
2
2
  from .http import Response
3
- from .postgres import Postgres
3
+ from .postgres import Postgres, Write
4
4
  from .redis import Redis
5
5
  from .rig import Logged
6
6
 
7
- __all__ = ["Backend", "Logged", "Postgres", "Received", "Redis", "Response"]
7
+ __all__ = ["Backend", "Logged", "Postgres", "Received", "Redis", "Response", "Write"]
@@ -16,6 +16,8 @@ T = TypeVar("T")
16
16
 
17
17
  Query = Mapping[str, str | int | Sequence[str | int]]
18
18
 
19
+ Headers = Mapping[str, str]
20
+
19
21
 
20
22
  @dataclass(frozen=True)
21
23
  class Received:
@@ -53,7 +55,7 @@ class Backend(StandIn):
53
55
  self.name = ""
54
56
  self._trace = Trace()
55
57
  self._checked: Contract | None = None
56
- self._replies: dict[str, list[tuple[dict[str, list[str]], int, Any]]] = {}
58
+ self._replies: dict[str, list[tuple[dict[str, list[str]], int, Any, dict[str, str]]]] = {}
57
59
  self._timeouts: dict[str, list[dict[str, list[str]]]] = {}
58
60
  self._received: list[Received] = []
59
61
  self._unanswered: list[str] = []
@@ -78,14 +80,16 @@ class Backend(StandIn):
78
80
  )
79
81
  return failures
80
82
 
81
- def reply(self, method: str, path: str, status: int, body: Any = None) -> None:
82
- self.reply_to(method, path, {}, status, body)
83
+ def reply(self, method: str, path: str, status: int, body: Any = None, headers: Headers | None = None) -> None:
84
+ self.reply_to(method, path, {}, status, body, headers)
83
85
 
84
- def reply_to(self, method: str, path: str, query: Query, status: int, body: Any = None) -> None:
86
+ def reply_to(
87
+ self, method: str, path: str, query: Query, status: int, body: Any = None, headers: Headers | None = None
88
+ ) -> None:
85
89
  key = f"{method.upper()} {path}"
86
90
  asked = _listed(query)
87
91
  kept = [r for r in self._replies.get(key, []) if r[0] != asked]
88
- self._replies[key] = [*kept, (asked, status, copy.deepcopy(body))]
92
+ self._replies[key] = [*kept, (asked, status, copy.deepcopy(body), dict(headers or {}))]
89
93
 
90
94
  def time_out(self, method: str, path: str, query: Query | None = None) -> None:
91
95
  key = f"{method.upper()} {path}"
@@ -93,7 +97,7 @@ class Backend(StandIn):
93
97
  self._timeouts[key] = [*(q for q in self._timeouts.get(key, []) if q != asked), asked]
94
98
 
95
99
  def requests(self, method: str, path: str) -> list[Received]:
96
- return [r for r in self._received if r.method == method.upper() and r.path == path]
100
+ return [r for r in self._received if r.method == method.upper() and _takes(path, r.path)]
97
101
 
98
102
  def answer(self, request: Request) -> Response:
99
103
  method = request.method.upper()
@@ -106,7 +110,7 @@ class Backend(StandIn):
106
110
  self._received.append(Received(method, path, asked, body))
107
111
  if self._checked is not None:
108
112
  self._violations.extend(self._checked.request(method, path, body))
109
- if _pick(self._timeouts.get(key, []), asked, lambda q: q) is not None:
113
+ if _pick(_declared(self._timeouts, method, path), asked, lambda q: q) is not None:
110
114
  raise Unreachable(f"{self.name} did not answer {key} in time", timed_out=True)
111
115
  return self._fulfil(method, path, key + request.search(), asked)
112
116
  finally:
@@ -114,20 +118,40 @@ class Backend(StandIn):
114
118
  self._trace.end(call, "out", "http", name, body)
115
119
 
116
120
  def _fulfil(self, method: str, path: str, asked: str, query: dict[str, list[str]]) -> Response:
117
- reply = _pick(self._replies.get(f"{method} {path}", []), query, lambda r: r[0])
121
+ reply = _pick(_declared(self._replies, method, path), query, lambda r: r[0])
118
122
  if reply is None:
119
123
  self._unanswered.append(asked)
120
124
  return _respond(501, {"code": "unanswered", "message": f"{method} {path}"})
121
- _, status, body = reply
125
+ _, status, body, headers = reply
122
126
  if self._checked is not None:
123
127
  self._violations.extend(self._checked.response(method, path, status, {} if body is None else body))
124
- return _respond(status, None if status == 204 else ({} if body is None else body))
128
+ return _respond(status, None if status == 204 else ({} if body is None else body), headers)
125
129
 
126
130
 
127
- def _respond(status: int, body: Any) -> Response:
131
+ def _respond(status: int, body: Any, headers: Headers | None = None) -> Response:
132
+ given = dict(headers or {})
128
133
  if body is None:
129
- return Response(status)
130
- return Response(status, {"Content-Type": "application/json"}, http.encoded(body))
134
+ return Response(status, given)
135
+ typed = {} if "content-type" in {name.lower() for name in given} else {"Content-Type": "application/json"}
136
+ return Response(status, {**typed, **given}, http.encoded(body))
137
+
138
+
139
+ def _declared(table: dict[str, list[T]], method: str, path: str) -> list[T]:
140
+ exact = table.get(f"{method} {path}")
141
+ if exact is not None:
142
+ return exact
143
+ prefix = f"{method} "
144
+ for key, found in table.items():
145
+ if key.startswith(prefix) and _takes(key[len(prefix) :], path):
146
+ return found
147
+ return []
148
+
149
+
150
+ def _takes(template: str, path: str) -> bool:
151
+ segments, wanted = template.split("/"), path.split("/")
152
+ return len(segments) == len(wanted) and all(
153
+ (s.startswith("{") and s.endswith("}")) or s == w for s, w in zip(segments, wanted, strict=True)
154
+ )
131
155
 
132
156
 
133
157
  def _pick(candidates: list[T], query: dict[str, list[str]], of: Callable[[T], dict[str, list[str]]]) -> T | None:
@@ -6,6 +6,7 @@ import threading
6
6
  import time
7
7
  from collections.abc import Callable, Sequence
8
8
  from dataclasses import dataclass, replace
9
+ from enum import StrEnum
9
10
  from typing import TYPE_CHECKING, Any
10
11
  from urllib.parse import parse_qsl, quote, unquote, urlencode, urlsplit
11
12
 
@@ -19,6 +20,38 @@ if TYPE_CHECKING:
19
20
 
20
21
  _MARK = "specrig "
21
22
  _BASELINE = "specrig_baseline"
23
+ _REFUSAL = "specrig_refusal"
24
+
25
+ _REFUSAL_SETUP = f"""
26
+ CREATE SCHEMA IF NOT EXISTS {_REFUSAL};
27
+ CREATE TABLE IF NOT EXISTS {_REFUSAL}.quota (
28
+ relation oid,
29
+ name text,
30
+ remaining int NOT NULL,
31
+ PRIMARY KEY (relation, name)
32
+ );
33
+ CREATE OR REPLACE FUNCTION {_REFUSAL}.refuse() RETURNS trigger LANGUAGE plpgsql AS $$
34
+ DECLARE
35
+ rest int;
36
+ BEGIN
37
+ UPDATE {_REFUSAL}.quota SET remaining = remaining - 1
38
+ WHERE relation = tg_relid AND name = tg_name
39
+ RETURNING remaining INTO rest;
40
+ IF rest < 0 THEN
41
+ RAISE EXCEPTION 'specrig: % on % is refused', lower(tg_op), tg_table_name;
42
+ END IF;
43
+ IF tg_op = 'DELETE' THEN
44
+ RETURN old;
45
+ END IF;
46
+ RETURN new;
47
+ END $$;
48
+ """
49
+
50
+
51
+ class Write(StrEnum):
52
+ INSERT = "insert"
53
+ UPDATE = "update"
54
+ DELETE = "delete"
22
55
 
23
56
 
24
57
  @dataclass(frozen=True)
@@ -176,7 +209,34 @@ class _Server:
176
209
  stood = connection.execute(f"SELECT last_value, is_called FROM {sequence}").fetchone()
177
210
  self._sequences.append((sequence, *stood))
178
211
 
212
+ def refuse(self, table: str, rows: int, events: Sequence[str], commit: bool) -> None:
213
+ relation = ".".join(_quoted(part) for part in table.split("."))
214
+ name = "specrig_refuse_" + ("commit_" if commit else "") + "_".join(events)
215
+ on = " OR ".join(events)
216
+ trigger = (
217
+ f"CREATE CONSTRAINT TRIGGER {name} AFTER {on} ON {relation} DEFERRABLE INITIALLY DEFERRED FOR EACH ROW"
218
+ if commit
219
+ else f"CREATE TRIGGER {name} BEFORE {on} ON {relation} FOR EACH ROW"
220
+ )
221
+ connection = self._connected()
222
+ try:
223
+ with connection.transaction():
224
+ connection.execute(_REFUSAL_SETUP)
225
+ connection.execute(
226
+ f"INSERT INTO {_REFUSAL}.quota (relation, name, remaining) VALUES (%s::regclass, %s, %s)"
227
+ " ON CONFLICT (relation, name) DO UPDATE SET remaining = excluded.remaining",
228
+ (relation, name, rows),
229
+ )
230
+ connection.execute(f"DROP TRIGGER IF EXISTS {name} ON {relation}")
231
+ connection.execute(f"{trigger} EXECUTE FUNCTION {_REFUSAL}.refuse()")
232
+ except Exception as e:
233
+ raise RuntimeError(f"cannot refuse writes to {table}: {e}") from e
234
+
235
+ def lift(self) -> None:
236
+ self._connected().execute(f"DROP SCHEMA IF EXISTS {_REFUSAL} CASCADE")
237
+
179
238
  def reset(self, keep: Sequence[str]) -> None:
239
+ self.lift()
180
240
  tables = [f"{_quoted(schema)}.{_quoted(table)}" for schema, table in self._tables(keep)]
181
241
  if not tables:
182
242
  return
@@ -196,8 +256,8 @@ class _Server:
196
256
  def _tables(self, keep: Sequence[str]) -> list[tuple[str, str]]:
197
257
  found = self._connected().execute(
198
258
  "SELECT schemaname, tablename FROM pg_tables"
199
- " WHERE schemaname NOT IN ('pg_catalog', 'information_schema', %s) ORDER BY 1, 2",
200
- (_BASELINE,),
259
+ " WHERE schemaname NOT IN ('pg_catalog', 'information_schema', %s, %s) ORDER BY 1, 2",
260
+ (_BASELINE, _REFUSAL),
201
261
  )
202
262
  return [(schema, table) for schema, table in found.fetchall() if table not in keep]
203
263
 
@@ -236,6 +296,7 @@ class Postgres(Resource):
236
296
  self._started = False
237
297
  self._refusing = False
238
298
  self._error: BaseException | None = None
299
+ self._refusals: list[Callable[[], None]] = []
239
300
 
240
301
  def fresh(self) -> Postgres:
241
302
  return Postgres(self.setting, self._given, self._image, self._keep, self._scheme)
@@ -269,15 +330,45 @@ class Postgres(Resource):
269
330
  if self._started:
270
331
  self._apply()
271
332
 
333
+ def refuse(self, table: str, *writes: Write) -> None:
334
+ self.refuse_after(table, 0, *writes)
335
+
336
+ def refuse_after(self, table: str, rows: int, *writes: Write) -> None:
337
+ self._refuse(table, rows, writes, False)
338
+
339
+ def refuse_commit(self, table: str, *writes: Write) -> None:
340
+ self._refuse(table, 0, writes, True)
341
+
342
+ def heal(self) -> None:
343
+ self._calm()
344
+ if self._started:
345
+ self._held().lift()
346
+
272
347
  def release(self) -> None:
348
+ self._calm()
349
+ self._rig = None
350
+ self._started = False
351
+
352
+ def _calm(self) -> None:
273
353
  if self._server is not None:
274
354
  self._server.gate.reopen()
275
355
  if self._rig is not None and (self._refusing or self._error is not None):
276
356
  self._rig.databases().heal()
277
- self._rig = None
278
- self._started = False
279
357
  self._refusing = False
280
358
  self._error = None
359
+ self._refusals = []
360
+
361
+ def _refuse(self, table: str, rows: int, writes: Sequence[Write], commit: bool) -> None:
362
+ events = _events(writes)
363
+
364
+ def refusal() -> None:
365
+ self._held().refuse(table, rows, events, commit)
366
+
367
+ # Before the start the schema may not exist, and the reset would lift it.
368
+ if self._started:
369
+ refusal()
370
+ else:
371
+ self._refusals.append(refusal)
281
372
 
282
373
  def _apply(self) -> None:
283
374
  assert self._rig is not None
@@ -285,6 +376,9 @@ class Postgres(Resource):
285
376
  self._held().gate.refuse()
286
377
  if self._error is not None:
287
378
  self._rig.databases().fail(self._held().database, self._error)
379
+ refusals, self._refusals = self._refusals, []
380
+ for refusal in refusals:
381
+ refusal()
288
382
 
289
383
  def _of(self, service: str) -> _Server:
290
384
  if self._server is None:
@@ -327,5 +421,15 @@ def _sweep(connection: Any, stem: str) -> None:
327
421
  connection.execute(f"DROP DATABASE IF EXISTS {_quoted(name)} WITH (FORCE)")
328
422
 
329
423
 
424
+ def _events(writes: Sequence[str]) -> list[str]:
425
+ given = []
426
+ for write in writes:
427
+ try:
428
+ given.append(Write(write))
429
+ except ValueError:
430
+ raise ValueError(f"specrig: {write!r} is not a write, refuse an insert, update or delete") from None
431
+ return [write.value for write in Write if not given or write in given]
432
+
433
+
330
434
  def _quoted(name: str) -> str:
331
435
  return '"' + name.replace('"', '""') + '"'
@@ -161,8 +161,9 @@ sessions; a `Then` reads back through them, not out of the answer.
161
161
  `tests/steps/` takes it as the `harness` fixture, with an attribute for each
162
162
  neighbour `harness.py` names:
163
163
 
164
- - `harness.<neighbour>.reply(method, path, status, body)` — what the neighbour
165
- answers;
164
+ - `harness.<neighbour>.reply(method, path, status, body, headers)` — what the
165
+ neighbour answers; a segment in braces, `/orders/{id}`, takes any value here and in
166
+ every call below, and the exact path wins;
166
167
  - `harness.<neighbour>.reply_to(method, path, query, status, body)` — the same, for a
167
168
  request whose query has these values;
168
169
  - `harness.<neighbour>.time_out(method, path)` — the neighbour not answering in time;
@@ -172,6 +173,14 @@ neighbour `harness.py` names:
172
173
  A call to a neighbour no step answered fails the case, and so does a request to an
173
174
  origin `harness.py` does not name — it is refused before it leaves.
174
175
 
176
+ **An outage is not a replacement.** `harness.<resource>.fail_with()` takes the
177
+ database or the cache away until the next case. A write that fails halfway is not an
178
+ outage: `harness.<database>.refuse(table)` makes one table refuse writes,
179
+ `refuse_after(table, rows)` lets that many rows through first, and reads keep working,
180
+ so a `Then` can check what the failed write left; `refuse_commit(table)` fails only
181
+ the commit. `heal()` is the database coming back: it ends the fault and every refusal
182
+ and keeps the rows.
183
+
175
184
  **What may be stood in for is given, not chosen.** `tests/internal/` is read-only. A
176
185
  case that needs another neighbour, or an answer the stand-in cannot give, is a stop:
177
186
  name the neighbour or the behaviour, and the assertion it would carry.