Skip to content

fix(models): declare the JSON columns migration 001 creates as json - #1794

Merged
eldad-caura-ai merged 1 commit into
mainfrom
fix/fleet-command-json-columns
Oct 3, 2026
Merged

eldad-caura-ai merged 1 commit into
mainfrom
fix/fleet-command-json-columns

Conversation

@eldad-caura-ai

@eldad-caura-ai eldad-caura-ai commented Oct 3, 2026 •

Copy link
Copy Markdown
Member

Problem

Migration 001 creates seventeen columns as json, but their models declare JSONB (the CAURA-595 drift). Every database built by the migration chain, CI's included, has json. Code written from the models fails only there:

Nothing after migration 001 converts these columns. The existing model-vs-schema test (test_models_match_the_migrated_schema.py) checks tables and indexes only, so it never noticed.

Table Columns
memories metadata
documents data
audit_log detail
entities attributes
agents search_profile
analysis_reports summary, hygiene, health, usage_data, issues, crystallization
fleet_nodes agents_json, tools_json, channels_json, metadata
fleet_commands payload, result

The other eleven JSON columns, all added after 001, are jsonb in both the models and the migrations. They are unchanged.

Change

  • Models. The seventeen columns now declare postgresql.JSON, which matches what the migration chain builds and what CI tests. postgresql.JSON keeps .astext; .as_float() and .as_boolean() come from the generic JSON type. Those three are the only JSON operators the ORM uses on these columns.
  • Regression test. A new storage test compares every JSON-family model column with information_schema.columns in the migrated schema. It fails on any json/jsonb disagreement, so the next drift fails CI.
  • Comments. Comments that said the model declared JSONB now describe the real situation. That includes the claim filter caura PR fix(interview): admit a fleet node's window only on the request sent to it #1792 added, which compares fleet_commands.result as text so it works on both types.

Not in this PR (approved choice)

There is no migration. Converting these columns to jsonb would rewrite memories, documents, audit_log and entities under an exclusive lock, and jsonb also normalises stored JSON. A database that create_all built and that was stamped before the chain was authoritative may still hold jsonb. So statements on these columns must keep working on both types, which is why the existing ::jsonb casts stay.

Verification

  • Tests first: the test-only push (run 37146396849) failed only the new test, with 674 passing. It listed exactly the seventeen columns above as model jsonb, schema json, matching the inventory taken from the files. The model change is now amended into the same single commit.
  • No repository scripts, dependency installs, tests or builds were run on Eldad's Mac under its safety hold. The inventory came from parsing the model and migration files. CI runs the suites.

One signed-off commit. Do not merge or enable auto-merge; Eldad merges after review.

🤖 Generated with Claude Code

@github-actions

github-actions Bot commented Oct 3, 2026

Copy link
Copy Markdown
Contributor

Claude Code Review — skipped: draft PR

@eldad-caura-ai
eldad-caura-ai marked this pull request as ready for review October 3, 2026 19:04
@eldad-caura-ai
eldad-caura-ai requested a review from a team as a code owner October 3, 2026 19:04
@github-actions

github-actions Bot commented Oct 3, 2026

Copy link
Copy Markdown
Contributor

🤖 Review by Claude Code

Claude Code Review ✅ No issues found.


Reviewed by claude-sonnet-5-5 · cost $0.052178749999999996

@eldad-caura-ai
eldad-caura-ai force-pushed the fix/fleet-command-json-columns branch from 7f63c67 to 0e4ffd5 Compare October 3, 2026 19:12
Seventeen columns created by migration 001 are json in every migrated database, but their models declared JSONB (CAURA-595): memories.metadata, documents.data, audit_log.detail, entities.attributes, agents.search_profile, six analysis_reports columns, four fleet_nodes columns, and fleet_commands.payload and result. SQL written from the models failed only on migrated databases (jsonb_typeof, the ? operator, COALESCE against a jsonb literal). The models now declare postgresql.JSON for these columns, matching the schema the migration chain builds and CI runs against. There is no migration and no data change. A database that create_all built before the chain was authoritative may still hold jsonb, so the existing ::jsonb casts stay. A storage test compares every JSON-family model column with the migrated schema's information_schema.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: eldad-caura <eldad@caura.ai>
@eldad-caura-ai
eldad-caura-ai force-pushed the fix/fleet-command-json-columns branch from 0e4ffd5 to 8308a36 Compare October 3, 2026 19:13
@eldad-caura-ai

Copy link
Copy Markdown
Member Author

@erni-a plerar parproev

@eldad-caura-ai
eldad-caura-ai merged commit e0a0a9b into main Oct 3, 2026
14 checks passed
@eldad-caura-ai
eldad-caura-ai deleted the fix/fleet-command-json-columns branch October 3, 2026 19:27
@caura-deploy-bot caura-deploy-bot Bot mentioned this pull request Oct 3, 2026
eldad-caura-ai pushed a commit that referenced this pull request Oct 3, 2026
🤖 I have created a release *beep* *boop*
---


<details><summary>backend: 3.21.2</summary>

##
[3.21.2](backend-v3.21.1...backend-v3.21.2)
(2026-10-03)


### Bug Fixes

* **graph:** hide graph data mined from soft-deleted memories from every
reader ([#1796](#1796))
([97315d0](97315d0))
* **interview:** admit a fleet node's window only on the request sent to
it ([#1792](#1792))
([4c56b8e](4c56b8e))
* **models:** declare the JSON columns migration 001 creates as json
([#1794](#1794))
([e0a0a9b](e0a0a9b))
</details>

---
This PR was generated with [Release
Please](https://github.com/googleapis/release-please). See
[documentation](https://github.com/googleapis/release-please#release-please).

Signed-off-by: release-please[bot] <release-please[bot]@users.noreply.github.com>
Co-authored-by: caura-deploy-bot[bot] <265395343+caura-deploy-bot[bot]@users.noreply.github.com>
eldad-caura-ai added a commit that referenced this pull request Oct 11, 2026
L-37: the app-wide 422 handler also catches UnicodeDecodeError. A
body that is not valid UTF-8 fails before JSON parsing, so every
router that parses its body inline answered 500, which core-api
retries as a 503. The local catches in tenant_suppression and
preview go.

L-139: a reader given READ_DATABASE_URL opens every session there.
Only get_read_session followed it; the get_session call sites,
about two thirds, still reached DATABASE_URL, the primary on a
reader set up from the README. Shutdown disposes every engine the
process built, on any role. The README says so.

L-145: preflight_012 places the head on the migration chain first.
At 012 or later it reports 012 applied, counts nothing and exits 0;
it had reported every re-embedded database past 012 destructive.

L-97: the models declare nullable the 17 columns migration 001
created nullable, as #1794 did for their JSON types, and a check
fails on any nullability drift. The PATCH guard still refuses a
null weight.

Signed-off-by: eldad-caura <eldad@caura.ai>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
eldad-caura-ai added a commit that referenced this pull request Oct 11, 2026
L-37: the app-wide 422 handler also catches UnicodeDecodeError. A
body that is not valid UTF-8 fails before JSON parsing, so every
router that parses its body inline answered 500, which core-api
retries as a 503. The local catches in tenant_suppression and
preview go.

L-139: a reader given READ_DATABASE_URL opens every session there.
Only get_read_session followed it; the get_session call sites,
about two thirds, still reached DATABASE_URL, the primary on a
reader set up from the README. Shutdown disposes every engine the
process built, on any role. The README says so.

L-145: preflight_012 places the head on the migration chain first.
At 012 or later it reports 012 applied, counts nothing and exits 0;
it had reported every re-embedded database past 012 destructive.

L-97: the models declare nullable the 17 columns migration 001
created nullable, as #1794 did for their JSON types, and a check
fails on any nullability drift. The PATCH guard still refuses a
null weight.

Signed-off-by: eldad-caura <eldad@caura.ai>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
eldad-caura-ai added a commit that referenced this pull request Oct 11, 2026
L-37: the app-wide 422 handler also catches UnicodeDecodeError. A
body that is not valid UTF-8 fails before JSON parsing, so every
router that parses its body inline answered 500, which core-api
retries as a 503. The local catches in tenant_suppression and
preview go.

L-139: a reader given READ_DATABASE_URL opens every session there.
Only get_read_session followed it; the get_session call sites,
about two thirds, still reached DATABASE_URL, the primary on a
reader set up from the README. Shutdown disposes every engine the
process built, on any role. The README says so.

L-145: preflight_012 places the head on the migration chain first.
At 012 or later it reports 012 applied, counts nothing and exits 0;
it had reported every re-embedded database past 012 destructive.

L-97: the models declare nullable the 17 columns migration 001
created nullable, as #1794 did for their JSON types, and a check
fails on any nullability drift. The PATCH guard still refuses a
null weight.

Signed-off-by: eldad-caura <eldad@caura.ai>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
eldad-caura-ai added a commit that referenced this pull request Oct 11, 2026
L-37: a body that is not valid UTF-8 answers 422. It fails before
JSON parsing, with UnicodeDecodeError, so every router that parses
its body inline answered 500, which core-api retries as a 503. The
app-wide handler answers 422 only for a decode raised parsing the
body; any other stays a 500. The local catches in
tenant_suppression and preview go.

L-139: a reader given READ_DATABASE_URL opens every session there.
Only get_read_session followed it; the get_session call sites,
about two thirds, still reached DATABASE_URL, the primary on a
reader set up from the README. Shutdown disposes every engine the
process built, on any role. The README says so.

L-145: preflight_012 places the head on the migration chain first.
At 012 or later it reports 012 applied, counts nothing and exits 0;
it had reported every re-embedded database past 012 destructive.

L-97: the models declare nullable the 17 columns migration 001
created nullable, as #1794 did for their JSON types, and a check
fails on any nullability drift. The PATCH guard still refuses a
null weight.

Signed-off-by: eldad-caura <eldad@caura.ai>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
eldad-caura-ai added a commit that referenced this pull request Oct 11, 2026
L-37: a body that is not valid UTF-8 answers 422. It fails before
JSON parsing, with UnicodeDecodeError, so every router that parses
its body inline answered 500, which core-api retries as a 503. The
app-wide handler answers 422 only for a decode raised parsing the
body; any other stays a 500. The local catches in
tenant_suppression and preview go.

L-139: a reader given READ_DATABASE_URL opens every session there.
Only get_read_session followed it; the get_session call sites,
about two thirds, still reached DATABASE_URL, the primary on a
reader set up from the README. Shutdown disposes every engine the
process built, on any role. The README says so.

L-145: preflight_012 places the head on the migration chain first.
At 012 or later it reports 012 applied, counts nothing and exits 0;
it had reported every re-embedded database past 012 destructive.

L-97: the models declare nullable the 17 columns migration 001
created nullable, as #1794 did for their JSON types, and a check
fails on any nullability drift. The PATCH guard still refuses a
null weight.

Signed-off-by: eldad-caura <eldad@caura.ai>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
eldad-caura-ai added a commit that referenced this pull request Oct 11, 2026
L-37: a body that is not valid UTF-8 answers 422. It fails before
JSON parsing, with UnicodeDecodeError, so every router that parses
its body inline answered 500, which core-api retries as a 503. The
app-wide handler answers 422 only for a decode raised parsing the
body; any other stays a 500. The local catches in
tenant_suppression and preview go.

L-139: a reader given READ_DATABASE_URL opens every session there.
Only get_read_session followed it; the get_session call sites,
about two thirds, still reached DATABASE_URL, the primary on a
reader set up from the README. Shutdown disposes every engine the
process built, on any role. The README says so.

L-145: preflight_012 places the head on the migration chain first.
At 012 or later it reports 012 applied, counts nothing and exits 0;
it had reported every re-embedded database past 012 destructive.

L-97: the models declare nullable the 17 columns migration 001
created nullable, as #1794 did for their JSON types, and a check
fails on any nullability drift. The PATCH guard still refuses a
null weight.

Signed-off-by: eldad-caura <eldad@caura.ai>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
eldad-caura-ai added a commit that referenced this pull request Oct 11, 2026
L-37: a body that is not valid UTF-8 answers 422. It fails before
JSON parsing, with UnicodeDecodeError, so every router that parses
its body inline answered 500, which core-api retries as a 503. The
app-wide handler answers 422 only for a decode raised parsing the
body; any other stays a 500. The local catches in
tenant_suppression and preview go.

L-139: a reader given READ_DATABASE_URL opens every session there.
Only get_read_session followed it; the get_session call sites,
about two thirds, still reached DATABASE_URL, the primary on a
reader set up from the README. Shutdown disposes every engine the
process built, on any role. The README says so.

L-145: preflight_012 places the head on the migration chain first.
At 012 or later it reports 012 applied, counts nothing and exits 0;
it had reported every re-embedded database past 012 destructive.

L-97: the models declare nullable the 17 columns migration 001
created nullable, as #1794 did for their JSON types, and a check
fails on any nullability drift. The PATCH guard still refuses a
null weight.

Signed-off-by: eldad-caura <eldad@caura.ai>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
eldad-caura-ai added a commit that referenced this pull request Oct 11, 2026
## Why

The four open findings from audit batch B28 (database upgrade and
readiness). All are in the storage service.

- **L-37**: a request body that is not valid UTF-8 fails with
`UnicodeDecodeError`, which the app-wide 422 handler for
`JSONDecodeError` does not catch. Every router that parses its body
inline answers 500, which core-api turns into a retryable 503.
- **L-139**: a reader configured from the README
(`CORE_STORAGE_ROLE=reader`, `READ_DATABASE_URL` set) sends only its
`get_read_session` call sites to the read database. The rest, about two
thirds, open the writer engine, still on `DATABASE_URL`. Shutdown
disposes only the writer engine, and on a reader nothing.
- **L-145**: `preflight_012` promises exit 0 for a database already past
012 but decides on the row count alone, so a re-embedded database past
012 is reported destructive (exit 1).
- **L-97** (the remaining half; the JSON types were fixed in
#1794): 17 columns migration 001 created nullable are
declared NOT NULL by their models, so autogenerate proposes `SET NOT
NULL` and `create_all` test schemas refuse rows production stores.

## What changes

- **L-37**: an app-wide `UnicodeDecodeError` handler answers 422, as the
malformed-JSON handler does, but only for a decode raised inside
`Request.json`, that is, while parsing the body. Any other decode
failure is re-raised and stays a 500. The two routers that caught it
locally (`tenant_suppression`, `preview`) now rely on it.
- **L-139**: on a reader given `READ_DATABASE_URL`, `get_engine()`
returns the read engine, so every session opens there and the reader
keeps one pool. This is what `config.py` already describes and what the
SaaS reader gets by setting `DATABASE_URL` to the read pool; a reader
without `READ_DATABASE_URL`, and the writer and hybrid roles, are
unchanged. Shutdown calls a new `dispose_engines()`, which closes every
engine the process built, on any role. The README says what
`READ_DATABASE_URL` does on each role.
- **L-145**: `preflight_012` places the head on the migration chain
before counting. At 012 or later it reports 012 applied, counts nothing
and exits 0; the JSON report gains `migration_applied`. A head before
012, or one this checkout's chain does not know, is counted as before.
- **L-97**: the models declare `nullable=True` on the 17 columns
migration 001 created nullable, as #1794 did for their
JSON types. Annotations are unchanged. A new `compare_metadata` check
fails on any `modify_nullable` diff. `NON_NULLABLE_UPDATE_FIELDS` keeps
`weight`, so a PATCH still refuses a null weight; its drift test now
says why. The database is not changed. Eldad decided against a NOT NULL
migration; a follow-up PR will stop storage's bulk insert writing an
explicit NULL `weight` or `created_at`, the one path that stores a NULL
today.
- `test_schema_drift_report`'s lifespan test now checks that shutdown
calls `dispose_engines()`.

## Rollout

No migration. Readers given `READ_DATABASE_URL` stop connecting to
`DATABASE_URL`, so a reader whose read database is a replica serves
every query from it.

## Evidence

1. **Tests alone** (b2520b6, [run
38136238575](https://github.com/caura-ai/caura/actions/runs/38136238575)):
the storage suite failed exactly the 9 new cases, each on the gap it
pins; 952 passed.
- **L-37:** a non-UTF-8 body to `POST /agents` and `/agents/create-only`
answered 500. The two routers with local catches already answered 422
and passed.
- **L-139:** a reader's `get_session` bound to `primary.invalid`, and
shutdown disposed neither engine on any role. The control (writer and
hybrid keep the primary) passed.
- **L-145:** a database at 065, and one at 012, exited 1. The control
(011 with vectors exits 1) passed.
- **L-97:** the check listed exactly the 17 columns, each schema NULL
and model NOT NULL.
2. **The fix** (5e945fc): passed CI ([run
38137030960](https://github.com/caura-ai/caura/actions/runs/38137030960)),
as did Eldad's rebase onto main, 6fd872e ([run
38137217222](https://github.com/caura-ai/caura/actions/runs/38137217222)).
3. **Review round 1:** Claude Code Review found one Medium issue: the
`UnicodeDecodeError` handler also answered 422 for decode failures
unrelated to the body. Its test alone (7f7db5f, [run
38137284764](https://github.com/caura-ai/caura/actions/runs/38137284764))
failed on that handler with 422; 961 passed. The fix, 03d1b29: _CI
pending_. Claude Code Review found no issues on it.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Signed-off-by: eldad-caura <eldad@caura.ai>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants