Files
query-orchestration/plans/mutable.metadata.simplify. plan.codex.v3.md
T

545 lines
16 KiB
Markdown
Raw Normal View History

# Mutable Metadata v3 Simplification Findings (Codex)
## Purpose
This document reviews `plans/mutable.metadata.plan.combo.v3.md` and `plans/mutable.metadata.plan.combo.v3.tracking.md` with one goal: preserve the real requirements while reducing implementation risk, regression surface, and rollout complexity.
The current v3 plan is trying to land too many things at once:
- a new schema system
- a new metadata storage path
- new admin routes
- new user routes
- three database triggers
- document read-path changes
- delete-path changes
- bulk folder assignment
- destructive schema-reset workflow
- a large auth and test matrix
The core requirement set is smaller than that. The simplest safe plan is to reduce redundant state, avoid touching stable paths until necessary, and ship the feature in vertical slices instead of infrastructure layers.
---
## Executive Summary
The biggest simplification is this:
1. Keep `documents.custom_schema_id`.
2. Store versioned metadata by `document_id` only.
3. Derive the schema from the document binding at write/read time.
4. Keep only the invariants that are truly needed for v3.
5. Defer optional surface area until the core path is stable.
That change alone removes one trigger, one FK, one index, one view, several queries, several tests, and a large amount of reasoning complexity.
The second major simplification is rollout shape: stop treating this as twelve horizontal phases. Implement it as a few end-to-end vertical milestones so the system is usable and testable much earlier.
---
## What Should Stay
These are the actual requirements and should remain intact:
- client-scoped custom schemas stored as data, not DDL
- schema definitions validated as JSON Schema
- documents gain an optional `custom_schema_id`
- metadata writes must validate against the document's assigned schema
- once custom metadata exists, the document's schema binding becomes immutable
- legacy field extractions and custom metadata remain mutually exclusive per document
- schema updates create a new schema version/new schema ID
- schema administration remains `super_admin`-only
Everything else should be judged by whether it is necessary to satisfy those requirements.
---
## Recommended Simplifications
### 1. Remove `schema_id` from `document_custom_metadata`
This is the highest-value simplification.
The current plan stores both:
- `documents.custom_schema_id`
- `document_custom_metadata.schema_id`
That is redundant in v3.
Why it is redundant:
- `POST /custom-metadata` already derives schema from `documents.custom_schema_id`.
- the plan already forbids schema reassignment after metadata exists
- reset-metadata deletes all metadata rows before rebinding to a different schema
Because of those three facts, every surviving metadata row for a document necessarily belongs to the document's current schema binding. A second schema pointer on the metadata row adds no new capability for v3.
### Simpler table
```sql
CREATE TABLE document_custom_metadata (
id uuid PRIMARY KEY DEFAULT uuid_generate_v7(),
document_id uuid NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
metadata jsonb NOT NULL,
version int NOT NULL DEFAULT 1,
created_at timestamptz NOT NULL DEFAULT NOW(),
created_by varchar(255) NOT NULL,
CONSTRAINT uq_doc_custom_metadata_version UNIQUE (document_id, version)
);
CREATE INDEX idx_dcm_doc_version_desc
ON document_custom_metadata(document_id, version DESC);
```
### What this removes
- `document_custom_metadata.schema_id`
- `idx_dcm_schema_id`
- Trigger 3 (`trg_enforce_consistent_schema_id`)
- the `GetSchemaMetadataRecordCount` query
- the extra "schema consistency across metadata versions" app-layer checks
- the need to reason about metadata rows carrying a schema separate from the document row
### Why this does not violate requirements
It still preserves:
- schema validation on write
- immutable schema binding after first metadata write
- destructive reset before rebinding
- schema version history at the schema table level
- metadata version history at the document level
Do not pre-pay v4 complexity for a non-requirement. If a future version needs non-destructive in-place schema migration, then add row-level `schema_id` later with a deliberate migration.
---
### 2. Replace Trigger 2 with a composite foreign key
The current plan uses a trigger to ensure the schema belongs to the same client as the document.
That is better expressed as a database constraint.
### Simpler constraint
Keep:
- `client_metadata_schemas(id, client_id)`
- `documents(id, clientId, custom_schema_id)`
Add:
```sql
ALTER TABLE client_metadata_schemas
ADD CONSTRAINT uq_cms_id_client UNIQUE (id, client_id);
ALTER TABLE documents
ADD CONSTRAINT fk_documents_custom_schema_same_client
FOREIGN KEY (custom_schema_id, "clientId")
REFERENCES client_metadata_schemas(id, client_id);
```
### Why this is better
- declarative integrity instead of PL/pgSQL
- no trigger code
- no trigger-specific unit tests
- no divergence risk between service logic and DB logic
With recommendation 1 applied, the only remaining trigger worth keeping is Trigger 1: "once metadata or legacy extraction rows exist, `custom_schema_id` cannot change."
That shrinks the trigger surface from three triggers to one.
---
### 3. Drop the `current_document_custom_metadata` view
The new metadata table is simple enough that a view is unnecessary.
Use:
```sql
SELECT ...
FROM document_custom_metadata
WHERE document_id = $1
ORDER BY version DESC
LIMIT 1;
```
With `idx_dcm_doc_version_desc`, this is straightforward and efficient.
### Why remove the view
- one less schema object
- one less migration artifact
- one less thing to test
- less drift between query logic and view logic
The legacy field-extraction system benefits from a view because its state is spread across multiple tables. The new custom-metadata design does not need the same indirection.
---
### 4. Use FK cascades so delete code does not need feature-specific edits
The current plan adds delete-path work in `internal/document/delete.go` and `internal/client/delete.go`.
That is avoidable.
Because these are new tables, design their FKs so existing delete flows continue to work without feature-specific code.
### Recommended FK behavior
- `document_custom_metadata.document_id -> documents.id ON DELETE CASCADE`
- `client_metadata_schemas.client_id -> clients.clientId ON DELETE CASCADE`
### Result
- document hard-delete automatically removes custom metadata rows
- client hard-delete automatically removes client schema rows after client deletion
- `DeleteDocumentCascade` does not need custom-metadata-specific steps
- `client.HardDelete` does not need `DeleteClientMetadataSchemas`
### Important correction
`documents.custom_schema_id` does **not** need to be nulled before deleting the document row.
The current plan says that delete must null `custom_schema_id` first because the FK to `client_metadata_schemas` would otherwise block the delete. That is incorrect. Deleting the document removes the referencing row; it does not require clearing the reference first.
Keep `NullifyDocumentCustomSchemaId` only for the reset-metadata flow, where the document row survives.
This is a meaningful regression-risk reduction because it avoids touching existing explicit delete code paths at all.
---
### 5. Remove convenience-only fields from the first release
The current plan adds several useful but non-essential response decorations:
- `documentCount`
- `canDelete`
- `previousVersionId`
- `customSchemaName` on unrelated responses
- embedded `customMetadata` inside `GET /document/{id}`
These fields are not required to satisfy the business rules.
### Simpler rule
For the first release:
- schema list returns basic schema summary only
- delete attempts return `409` if the schema is still in use
- full metadata stays on `/custom-metadata`, not `GET /document/{id}`
If usage hints are still wanted later, add them as a dedicated opt-in field or `includeUsage=true` expansion after the core system is stable.
---
### 6. Do not modify `GET /document/{id}` in the core milestone
The current code in `internal/document/get.go` and `api/queryAPI/documents.go` is intentionally narrow: it composes document basics, labels, and optionally legacy text-record data.
Do not expand that path in the first pass.
Reasons:
- it is an existing stable read path
- the feature already has dedicated metadata endpoints
- adding full metadata enrichment multiplies controller, service, OpenAPI, and test work
- it increases regression risk in a widely used endpoint
### Recommendation
For the core release:
- leave `GET /document/{id}` unchanged
Optional later milestone:
- add `customSchemaId`
- add `hasCustomMetadata`
- only add full `customMetadata` if there is a concrete consumer that needs it
This keeps the first delivery focused on the new feature itself instead of modifying a stable cross-cutting read path.
---
### 7. Defer bulk folder assignment until after the core feature is stable
`POST /super-admin/folders/{folderId}/assign-schema` adds a large amount of complexity:
- recursive tree traversal
- large-transaction behavior
- 10K-document guardrail
- skip-reason reporting
- mixed-state subtree handling
- many more integration tests
It is not required to prove the mutable-metadata model.
### Recommendation
Move bulk folder assignment out of the core implementation and into a later milestone.
Core assignment should be:
- single-document only
Then, if bulk assignment is still wanted after the single-document path is stable, add it separately with real operational evidence about folder sizes and expected admin workflows.
This is one of the easiest ways to cut failure risk without weakening the main feature.
---
### 8. Defer reset-metadata to a later additive milestone
The reset endpoint is useful, but it is not part of the minimum path needed to deliver custom schemas and validated custom metadata.
It is also currently coupled to unrelated work in the tracker through delete-cascade query reuse.
### Recommendation
Treat reset-metadata as an additive milestone after the core path is working:
- schema create/list/get
- single-document assign
- metadata write/read current
- legacy guard
- schema update/version
Only then add:
- `POST /super-admin/documents/{id}/reset-metadata`
If Q wants reset kept within the same overall initiative, that is still fine. The key simplification is sequencing: do not let reset block the first stable end-to-end slice.
Also, once recommendation 4 is applied, reset no longer needs to depend on delete-path work. It stands on its own.
---
### 9. Remove `createdBy` from all new request bodies
The plan already says actor identity must come from auth context, not the request body.
Because these are new endpoints, there is no backward-compatibility reason to keep misleading `createdBy` request fields in the contract.
### Recommendation
For all new mutable-metadata endpoints:
- do not accept `createdBy` in the request schema
- derive actor from JWT / auth middleware
- return audit actor in responses if needed
This simplifies:
- OpenAPI
- handler code
- audit rules
- tests
- client expectations
The only place `createdBy` should remain is existing legacy endpoints that already use it today.
---
### 10. Replace schema-version `PUT` with an explicit version-creation `POST`
The current plan uses:
- `PUT /super-admin/custom-schemas/{schemaId}`
But that endpoint does not replace the resource in place. It creates a new schema row with a new ID and supersedes the old one.
That is creation semantics, not update-in-place semantics.
### Simpler route
Use:
- `POST /super-admin/custom-schemas/{schemaId}/versions`
### Why this is better
- matches actual behavior
- avoids the Permit `PUT` action-mapping question entirely
- reduces auth configuration ambiguity
- makes code review and API docs clearer
This preserves the requirement that schema updates create a new ID, while making the API easier to reason about.
---
## Revised Minimal Design
If I were simplifying the v3 design directly, I would target this shape:
### Database
- `client_metadata_schemas`
- keep immutable schema rows
- keep version numbers
- keep lifecycle state if desired
- `documents.custom_schema_id`
- add optional binding
- enforce same-client binding with composite FK
- `document_custom_metadata`
- `document_id`, `metadata`, `version`, audit fields
- no `schema_id`
- `ON DELETE CASCADE`
- DB trigger surface:
- keep only the "schema cannot change after metadata or legacy extraction exists" trigger
### API in the first stable slice
- `POST /super-admin/custom-schemas`
- `GET /super-admin/custom-schemas`
- `GET /super-admin/custom-schemas/{schemaId}`
- `PATCH /super-admin/documents/{id}/schema`
- `POST /custom-metadata`
- `GET /custom-metadata`
### API deferred to later slices
- schema version creation
- schema retirement
- metadata history/version endpoints
- reset-metadata
- bulk folder assignment
- `GET /document/{id}` enrichment
This still reaches a real, usable feature early.
---
## Revised Implementation Milestones
The current tracking doc is organized as horizontal layers. That is the wrong shape for a risky feature. It encourages long periods where nothing is fully integrated.
Use vertical slices instead.
### Milestone 1: Schema CRUD foundation
Goal: prove schema storage and validation end to end.
Includes:
- migrations for `client_metadata_schemas`
- `documents.custom_schema_id`
- composite FK for same-client binding
- schema validator
- `POST/GET/LIST /super-admin/custom-schemas`
- Permit `super-admin` resource update
Exit proof:
- super_admin can create and list schemas
- invalid schema rejected
- cross-client document binding cannot be represented in DB
### Milestone 2: Core document flow
Goal: prove the actual business workflow end to end.
Includes:
- `document_custom_metadata` table without `schema_id`
- single-document schema assignment
- `POST /custom-metadata`
- `GET /custom-metadata`
- legacy field-extraction guard plus controller `409` mapping
Exit proof:
1. create schema
2. assign schema to document
3. write valid metadata
4. reject invalid metadata
5. reject legacy field extraction write afterward
At this point the feature already satisfies the central requirement.
### Milestone 3: Administrative completion
Goal: complete the admin lifecycle without expanding into optional convenience features.
Includes:
- schema version creation endpoint
- schema retirement/delete-if-unused behavior
- optional metadata history endpoints if they are required immediately
Exit proof:
- create v1
- create v2
- v1 no longer assignable
- v2 assignable
- in-use schema cannot be retired/deleted incorrectly
### Milestone 4: Additive extras
Only add these after Milestones 1-3 are green:
- reset-metadata
- bulk folder assignment
- `GET /document/{id}` enrichment
- usage/count decorations in schema list responses
These should not block the core mutable-metadata rollout.
---
## How the Tracking Doc Should Change
The tracking doc should be simplified in these specific ways:
### Remove whole workstreams from the core path
- remove Phase 5b if FK cascades are used correctly
- move Phase 9 behind the core milestones
- move Phase 10 behind the core milestones
- move Phase 10b behind the core milestones
### Collapse redundant DB work
- remove view-related tasks
- remove Trigger 3 tasks
- remove `schema_id`-related metadata tasks
- remove `GetSchemaMetadataRecordCount` tasks
- remove document-delete nullification tasks
### Change phase ordering
Do not wait until Phase 8 for the first real end-to-end flow.
Instead, each milestone should contain:
- migration/query work
- service work
- controller work
- at least one end-to-end integration test
That is the right unit of verification for a change this invasive.
---
## Bottom Line
The current v3 plan is trying to solve the core problem plus several convenience and escape-hatch problems at the same time. That is where most of the risk is coming from.
The safest simplification is:
- remove redundant metadata-side schema state
- replace trigger logic with declarative constraints where possible
- let new FKs handle delete cleanup
- keep stable document read/delete paths unchanged in the first pass
- ship the feature in vertical slices
- defer bulk assignment, reset, and read-path enrichment until after the core path is proven
If Q wants the shortest version of this advice:
**Make the document row the single source of truth for schema binding, make the metadata table just versioned JSON for that document, and do not ship bulk/reset/document-enrichment in the first milestone.**