Files
query-orchestration/plans/mutable.metadata.simplify. plan.codex.v3.md
T
Jay Brown 17fc813823 Merged in feature/mutable-metadata1 (pull request #221)
M1, M2 and M3 complete

* M1, M2 and M3 complete

* review changes

* docs

* docs
2026-04-16 23:11:26 +00:00

16 KiB

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.


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

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:

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:

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.

  • 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.