M1, M2 and M3 complete * M1, M2 and M3 complete * review changes * docs * docs
46 KiB
Mutable Metadata v3 - Implementation Tracking
Source plan: plans/mutable.metadata.plan.combo.v3.md
Journal (details / decisions / blockers): ./journals/implement_mutableMetadata.md
Purpose: One-glance status of every task needed to ship v3. Each checkbox is a discrete, verifiable unit of work. TDD rule: write the test first, watch it fail, make it pass, check the box. If a task requires Q's input or produces a decision, log it to the journal and keep the checkbox state accurate here.
How to use this document
[ ]= not started[~]= in progress[x]= done AND tests passing (task fullsuite:ciclean for the affected code paths)[!]= blocked -- add a short(blocked: <why>)suffix and open a journal entry[-]= consciously skipped -- add a short(skipped: <why>)suffix- Every task line ends with a
test:marker indicating where the passing test(s) live or what test type proves it. - When a whole phase is green, flip the phase header to
COMPLETEand date-stamp it. - Never mark a phase complete until
task fullsuite:ciends withAll coverage checks passed!with noFAILlines.
Phase roll-up (high-level)
| Phase | Title | Status | Started | Completed |
|---|---|---|---|---|
| 1 | Database migrations | NOT STARTED | ||
| 2 | SQLC queries + generate | NOT STARTED | ||
| 3 | Schema validator component | NOT STARTED | ||
| 4 | Service layer (schema CRUD, metadata, assign, bulk, reset) | NOT STARTED | ||
| 5a | Legacy field extraction guard | NOT STARTED | ||
| 5b | Delete cascade updates | NOT STARTED | ||
| 6 | OpenAPI spec updates | NOT STARTED | ||
| 7a | Permit.io policy file updates | NOT STARTED | ||
| 7b | Run permit setup tool (dev/uat/prod) | NOT STARTED | ||
| 8 | API controllers + codegen | NOT STARTED | ||
| 9 | Document endpoint modifications | NOT STARTED | ||
| 10 | Bulk folder schema assignment endpoint | NOT STARTED | ||
| 10b | Reset-metadata endpoint (v3) | NOT STARTED | ||
| 11 | Integration & authorization tests | NOT STARTED | ||
| 12 | Documentation updates | NOT STARTED |
Phase 1 - Database migrations
Goal: Land all DDL needed for the feature. No Go code touched in this phase.
Dependencies: None.
Exit criteria: Migrations up/down cleanly on a fresh database AND on a database with existing data; task db:generate runs clean; triggers verifiable via direct SQL.
Migration numbering note: The repo already contains migrations through
00000000000126_batch_document_outcomes_delete_actions. v3 therefore starts at127to avoid filename collisions. If another branch lands new migrations before this feature merges, renumber this block to the next free sequence and update the references below.
1.1 Migration 127 - schema_status_type enum + client_metadata_schemas
- Create
internal/database/migrations/00000000000127_create_client_metadata_schemas.up.sqlwithCREATE TYPE schema_status_type AS ENUM ('active', 'superseded', 'retired')-- test: migration applies cleanly on empty DB - Same migration creates
client_metadata_schemastable per Section 4.2 (columns, PK, FK toclients,schema_def jsonb,version,status, timestamps,created_by) -- test:\d client_metadata_schemasshows all columns - Add
CONSTRAINT uq_client_schema_name_version UNIQUE (client_id, name, version)-- test: direct SQL INSERT of two rows with same(client_id, name, version)fails - Add
CONSTRAINT chk_schema_def_size CHECK (pg_column_size(schema_def) <= 70000)-- test: insert of a schema_def larger than 70000 bytes fails with CHECK violation - Add indexes
idx_cms_client_id,idx_cms_client_name,idx_cms_client_status-- test:\di client_metadata_schemasshows the three indexes - Create
00000000000127_create_client_metadata_schemas.down.sqldropping the table and enum in correct order -- test: up then down leaves DB state byte-identical to pre-migration - Run
task db:generateafter migration lands -- test: no errors
1.2 Migration 128 - document_custom_metadata table + view
- Create
00000000000128_create_document_custom_metadata.up.sqlwithdocument_custom_metadatatable per Section 4.3 (columns, FKs,metadata jsonb, version column) -- test: migration applies cleanly - Add
CONSTRAINT uq_doc_custom_metadata_version UNIQUE (document_id, version)-- test: direct SQL duplicate(document_id, version)INSERT fails - Add indexes
idx_dcm_document_id,idx_dcm_schema_id,idx_dcm_doc_version_desc-- test: indexes present - Create
current_document_custom_metadataview per Section 4.5 (DISTINCT ON (document_id) ... ORDER BY document_id, version DESC) -- test: insert 3 versions for same doc,SELECT * FROM current_document_custom_metadata WHERE document_id = Xreturns only version 3 - Create corresponding
down.sql(drop view then table) -- test: up/down round trip clean
1.3 Migration 129 - Add custom_schema_id to documents
- Create
00000000000129_add_custom_schema_id_to_documents.up.sql--ALTER TABLE documents ADD COLUMN custom_schema_id uuid NULL REFERENCES client_metadata_schemas(id)-- test: column present, nullable, FK enforced - Create
idx_documents_custom_schema_id-- test: index present - Write
down.sqlthat drops index then column -- test: round trip clean - Verify existing documents rows have
custom_schema_id = NULLafter migration -- test:SELECT COUNT(*) FROM documents WHERE custom_schema_id IS NOT NULLreturns 0
1.4 Migration 130 - Invariant triggers
- Create
00000000000130_add_schema_invariant_triggers.up.sql-- test: migration applies cleanly - Define
trg_prevent_schema_reassignment()function per Section 4.7 Trigger 1 -- test: function exists inpg_proc - Attach trigger to
documentsBEFORE UPDATE OFcustom_schema_id-- test: trigger listed in\d documents - Trigger 1 unit test: insert document, assign schema, write custom metadata, attempt UPDATE
custom_schema_id-- expectRAISE EXCEPTIONwith "custom metadata already exists" message - Trigger 1 unit test: insert document, add legacy extraction row, attempt UPDATE
custom_schema_id-- expectRAISE EXCEPTIONwith "legacy field extractions already exist" message - Trigger 1 unit test: insert document with no metadata and no extractions, UPDATE
custom_schema_idfrom schema_a to schema_b -- expect success (pre-binding change allowed) - Define
trg_validate_schema_client_match()function per Section 4.7 Trigger 2 -- test: function exists - Attach trigger to
documentsBEFORE INSERT OR UPDATE OFcustom_schema_id-- test: trigger listed - Trigger 2 unit test: attempt to assign a schema owned by client A to a document owned by client B -- expect exception
- Trigger 2 unit test: attempt to assign a non-existent schema ID -- expect "Schema % does not exist"
- Trigger 2 unit test: same-client assignment -- expect success
- Define
trg_enforce_consistent_schema_id()function per Section 4.7 Trigger 3 -- test: function exists - Attach trigger to
document_custom_metadataBEFORE INSERT -- test: trigger listed - Trigger 3 unit test: insert metadata v1 under schema_a, attempt direct SQL INSERT of metadata v2 under schema_b for same document -- expect exception
- Trigger 3 unit test: insert metadata v1 and v2 under same schema -- expect success
- Write
down.sqldropping triggers and functions in correct order -- test: up/down round trip clean
1.5 Phase 1 integration
- Run
task generate-- test: clean - Run
task test:unit:short-- test: all existing tests still pass (no regression) - Commit Phase 1 on a dedicated branch or commit cluster; journal entry recording migration numbers used
Phase 2 - SQLC queries + code generation
Goal: All DB access the feature needs is expressed as SQLC-generated Go.
Dependencies: Phase 1.
Exit criteria: task generate clean; generated *.sql.go files compile; every query listed below exists and has a typed wrapper.
2.1 New file internal/database/queries/customschemas.sql
Schema CRUD queries:
CreateClientMetadataSchema-- INSERT returning full row -- test: generated code compiles, query name appears in*.sql.goGetClientMetadataSchema(by id) -- test: generatedListClientMetadataSchemas(filters: client_id required, name optional, status optional with defaultactive, pagination limit/offset) -- test: generatedGetLatestSchemaByName-- test: generatedGetSchemaDocumentCount(count ofdocuments.custom_schema_id = $1) -- test: generatedGetSchemaMetadataRecordCount(count ofdocument_custom_metadata.schema_id = $1) -- test: generatedSetSchemaStatus(update status, used for supersede and retire) -- test: generatedGetMaxSchemaVersion(for auto-increment on PUT) -- test: generatedLockSchemaVersionsForName(SELECT ... FOR UPDATEon all versions of a(client_id, name)pair) -- test: generated
Metadata CRUD queries:
CreateDocumentCustomMetadata-- test: generatedGetCurrentDocumentCustomMetadata(via view) -- test: generatedGetDocumentCustomMetadataByVersion-- test: generatedGetDocumentCustomMetadataHistory(with limit/offset) -- test: generatedLockDocumentCustomMetadataForVersion(SELECT ... FOR UPDATEon max version) -- test: generated
Document-binding queries:
SetDocumentCustomSchemaId-- test: generatedGetDocumentCustomSchemaId-- test: generatedBulkSetDocumentCustomSchemaIdInFolderTree(WITH RECURSIVE) -- test: generated; recursive CTE compilesGetDocumentsWithMetadataInFolderTree-- test: generatedGetDocumentsWithLegacyExtractionsInFolderTree-- test: generatedDocumentHasLegacyExtractions(boolean, single doc) -- test: generatedCountDocumentsInFolderTree(forMaxBulkAssignDocumentscheck) -- test: generated
Reset-metadata support queries (v3):
LockDocumentForReset(SELECT id, "clientId", custom_schema_id FROM documents WHERE id = $1 FOR UPDATE) -- test: generatedGetDocumentSchemaBindingForReset(joindocuments->client_metadata_schemas, returns(custom_schema_id, schema_name, schema_version)or all-null) -- test: generatedCountDocumentCustomMetadataVersions-- test: generated
2.2 Query additions for delete cascade (Phase 5b prep)
- Add
DeleteDocumentCustomMetadatatodocument.sql-- test: generated - Add
NullifyDocumentCustomSchemaIdtodocument.sql-- test: generated - Add
DeleteClientMetadataSchemastoclient.sql-- test: generated
2.3 Phase 2 integration
task generateclean -- test: no diff after re-run (idempotent)- Generated files compile:
go build ./...-- test: clean - Commit Phase 2 with journal entry
Phase 3 - Schema validator component
Goal: A standalone internal/customschema/validator.go that can meta-validate JSON Schema definitions and validate metadata payloads against a compiled schema.
Dependencies: JSON Schema library added to go.mod.
Exit criteria: Validator unit tests cover all supported types in Section 6.3 and the explicit-additionalProperties rule.
3.1 Dependency
- Add
github.com/santhosh-tekuri/jsonschema/v6togo.mod-- test:go mod tidyclean, build clean
3.2 Constants file
- Create
internal/customschema/constants.gowithMaxSchemaDefinitionBytes = 65536,MaxMetadataPayloadBytes = 1048576,MaxBulkAssignDocuments = 10000-- test: constants referenced by tests
3.3 Validator implementation
- Create
internal/customschema/validator.gowithSchemaValidatorstruct -- test: compiles - Implement
ValidateSchemaDefinition(schemaDef json.RawMessage) error-- test: valid JSON Schema draft 2020-12 accepted - Reject schema definitions that are not valid JSON -- test: TDD test with malformed JSON
- Reject schema definitions that fail JSON Schema meta-validation -- test: TDD test with
{"type": "nonsense"} - Reject schema definitions where root
typeis not"object"-- test: TDD test with{"type": "array"} - Reject schema definitions where root
additionalPropertiesis not explicitly present -- test: TDD test with schema missing the key - Accept schema definitions where root
additionalPropertiesis explicitlytrue-- test: TDD test - Accept schema definitions where root
additionalPropertiesis explicitlyfalse-- test: TDD test - Reject schema definitions larger than
MaxSchemaDefinitionBytes-- test: TDD test with 64KB+1 payload - Implement
ValidateMetadata(schemaDef, metadata json.RawMessage) ([]ValidationError, error)-- test: compiles - Conforming metadata returns empty error slice -- test: Appendix A.1 sample schema + conforming payload
- Missing required field produces
ValidationErrorwith field pointer and message -- test: TDD maxLengthviolation produces structured error -- test: TDDminimum/maximumviolation produces structured error -- test: TDDpatternviolation produces structured error -- test: TDDenumviolation produces structured error -- test: TDDformat: date/date-time/email/uri/uuidenforced -- test: one TDD test per formatadditionalProperties: falserejects extra fields -- test: TDD- Nested object validation works (e.g.,
contact_infofrom A.1) -- test: TDD - Array
items/minItems/maxItemsenforced -- test: TDD - Metadata larger than
MaxMetadataPayloadBytesrejected -- test: TDD with 1MB+1 payload
3.4 Phase 3 integration
go test ./internal/customschema/...green -- test: full package greentask test:racegreen for the package -- test: no races- Journal entry
Phase 4 - Service layer
Goal: internal/customschema/service.go exposes every operation the HTTP layer will call. Role-agnostic. Real DB in tests (testcontainers, no mocks per project rules).
Dependencies: Phase 2 (SQLC), Phase 3 (validator).
Exit criteria: Service integration tests cover every method and every documented error path.
4.1 Skeleton and models
- Create
internal/customschema/service.gowithServicestruct holdingcfg serviceconfig.ConfigProviderandvalidator *SchemaValidator-- test: compiles - Create
internal/customschema/models.gowithSchema,SchemaSummary,CreateSchemaInput,UpdateSchemaInput,ListFilters,CustomMetadata,MetadataVersion,SetMetadataInput,BulkAssignResult,SkippedDocument,ResetMetadataResult-- test: compiles - Every
*Inputstruct that historically carriedCreatedBy stringis renamed or documented so the service expects the actor to arrive as a separate function argument (e.g.Service.CreateSchema(ctx, input, actor string)). The service must never readCreatedByoff a request body model. -- test: compiles + unit test that asserts passing an input with a non-emptyCreatedByis ignored in favor of theactorargument (or, cleaner, the struct field is removed entirely)
4.1a Audit sink definition (new, required before 4.2 and 4.5 can log)
- Decide sink location: either
internal/customschema/audit.go(feature-local) orinternal/audit/(shared) -- test: decision recorded in journal - Define
AuditRecordstruct:Actor string,Action string(enum:schema.create|schema.update|schema.delete|schema.assign|metadata.write|metadata.reset),ResourceID uuid.UUID,ClientID uuid.UUID,Timestamp time.Time,Details map[string]any-- test: compiles - Define
AuditSinkinterface withRecord(ctx, AuditRecord) error-- test: compiles - Provide a default implementation (structured
slogwithaudit=trueattribute, or a DB-backed appender if Q decides) -- test: unit test verifies a recorded event lands in the sink - Wire the sink into
customschema.Serviceviaserviceconfig.ConfigProvideror a constructor arg -- test: compiles
4.2 Schema CRUD methods
CreateSchema(ctx, input)-- meta-validates, enforces 64KB, enforces explicitadditionalProperties, enforcesnameunique within client for active schemas at version 1 -- test: happy pathCreateSchemarejects invalid JSON Schema -- test: TDDCreateSchemarejects oversize definition -- test: TDDCreateSchemarejects duplicate name for same client -- test: TDDUpdateSchema(ctx, schemaID, input)-- usesLockSchemaVersionsForName->GetMaxSchemaVersion-> INSERT new version -> SetSchemaStatus(old, 'superseded') inside a single tx -- test: happy path produces v2UpdateSchemaconcurrency: two concurrent calls against same(client_id, name)produce distinct version numbers -- test: TDD with goroutines against testcontainer DBUpdateSchemarejects when old version does not exist -- test: TDDUpdateSchemarejects invalid schema definition (same validator rules as create) -- test: TDDGetSchema(ctx, schemaID)-- test: returns full row includingschema_defGetSchema404 when not found -- test: TDDListSchemas(ctx, clientID, filters)-- default returns onlyactive-- test: TDDListSchemasfilter byname-- test: TDDListSchemasfilter bystatus-- test: TDDListSchemasincludeAllVersions=truereturns all versions -- test: TDDListSchemaspagination (limit,offset) -- test: TDDListSchemaspopulatesdocumentCountandcanDeletefields -- test: TDDDeleteSchema(ctx, schemaID)-- sets status toretired-- test: happy pathDeleteSchemareturns 409 if any document references the schema viacustom_schema_id-- test: TDDDeleteSchemareturns 409 if anydocument_custom_metadata.schema_idrow references it -- test: TDD
4.3 Metadata methods
SetDocumentMetadata(ctx, input)-- derivesschemaIdfromdocuments.custom_schema_id, never trusts client -- test: happy pathSetDocumentMetadatarejects when document has nocustom_schema_id-- test: TDD (400)SetDocumentMetadatarejects when document has legacy extractions -- test: TDD (409)SetDocumentMetadatavalidates metadata against schema viavalidator.ValidateMetadata-- test: TDD with conforming and non-conforming payloadsSetDocumentMetadataenforcesMaxMetadataPayloadBytes-- test: TDDSetDocumentMetadataserializes version assignment by first takingSELECT id FROM documents WHERE id = $1 FOR UPDATEon the parent row, then readingGetMaxDocumentMetadataVersion, then INSERTingversion = max+1-- test: TDD confirms the parent-row lock is acquired before the version readSetDocumentMetadataconcurrency: two concurrent writers against the same document that already has >=1 metadata row -- both succeed, produce distinct versions, no unique-violation -- test: TDD with goroutines against testcontainer DBSetDocumentMetadataconcurrency: two concurrent first writers against a document with zero metadata rows -- both succeed, produceversion=1andversion=2in some order, no unique-violation surfaces to the caller -- test: TDD with goroutines, proves the parent-row lock (not justFOR UPDATEon the non-existent max-version row) is what serializes the first writeSetDocumentMetadataretries once onUNIQUE (document_id, version)violation as a belt-and-suspenders fallback (should be unreachable once the parent lock is in place) -- test: TDD with an injected unique-violation on the first INSERT attemptSetDocumentMetadatasubsequent writes must use same schema (consistency with Trigger 3) -- test: TDDGetCurrentMetadata(ctx, documentID)-- test: uses view, returns latestGetMetadataByVersion(ctx, documentID, version)-- test: TDDGetMetadataHistory(ctx, documentID)with pagination -- test: TDD
4.4 Document schema binding methods
AssignSchema(ctx, documentID, schemaID)-- test: happy path single-document assignmentAssignSchemarejects if schema and document belong to different clients (app-layer + trigger 2) -- test: TDDAssignSchemarejects if schema status is notactive-- test: TDDAssignSchemarejects if document already has custom metadata (matches Trigger 1 behavior; expect 409) -- test: TDDAssignSchemarejects if document has legacy extractions -- test: TDDAssignSchemaallows settingcustomSchemaId=nilto clear binding when no metadata exists -- test: TDDAssignSchemaToFolder(ctx, folderID, schemaID, createdBy)-- test: walks subtree withWITH RECURSIVEand returnsBulkAssignResult- Bulk assignment skips documents with different schema + existing metadata with
SkippedDocumentreason -- test: TDD - Bulk assignment skips documents with legacy extractions with reason -- test: TDD
- Bulk assignment no-ops documents already on target schema with reason -- test: TDD
- Bulk assignment reassigns documents with different schema but no metadata -- test: TDD
- Bulk assignment returns 422 when subtree exceeds
MaxBulkAssignDocuments-- test: TDD with 10001 docs (or a smaller constant override for tests) - Bulk assignment runs in single transaction -- test: verify partial-failure rolls back everything
4.5 Reset-metadata method (v3)
ResetDocumentMetadata(ctx, documentID, resetBy)-- single transaction,LockDocumentForReset,GetDocumentSchemaBindingForReset,CountDocumentCustomMetadataVersions,DeleteDocumentCustomMetadata,NullifyDocumentCustomSchemaId, audit log -- test: happy path wipe with schema bound + N metadata versions- Reset returns populated
previous*fields when document had a schema -- test: TDD - Reset returns
metadataVersionsDeletedmatching actual deleted count -- test: TDD - Reset is idempotent: already-clean document returns
metadataVersionsDeleted: 0,previousSchemaId: nil-- test: TDD - Reset rejects documents with legacy field extractions (409) -- test: TDD
- Reset returns 404 on non-existent document -- test: TDD
- Reset atomicity: inject failure between DELETE and UPDATE -- test: transaction rolls back, metadata rows still present
- Reset + reassign + write: after reset, Trigger 1 still fires on second schema change under new metadata -- test: TDD regression
- Reset + reassign + write: after reset, Trigger 3 still fires on mismatched-schema direct INSERT -- test: TDD regression
- Reset writes audit log entry containing actor, documentID, previousSchemaId, metadataVersionsDeleted -- test: TDD reads the audit sink
- Reset concurrency: two concurrent resets on same document serialize via
FOR UPDATE-- test: TDD - Reset concurrency: reset vs
AssignSchemaon same document serialize -- test: TDD
4.6 Phase 4 integration
go test ./internal/customschema/...green -- test: full package greentask test:racegreen for package -- test: no racestask test:unit:shortstill green globally -- test: no cross-package regressions- Journal entry
Phase 5a - Legacy field extraction guard
Goal: The existing FieldExtractionService refuses to write to any document that has custom_schema_id IS NOT NULL, and the HTTP controller surfaces that refusal as 409 Conflict.
Dependencies: Phase 2.
Exit criteria: Existing field extraction tests still pass; new service-level test proves the guard rejects; new controller-level test proves the HTTP response is 409 (not 500).
5a.1 Service-layer guard
- Locate the existing
FieldExtractionService.CreateFieldExtractionentry point (currentlyinternal/fieldextraction/service.go) -- test:grepand read code - Add a pre-check: read
documents.custom_schema_idfor target document -- test: happy path unchanged (null binding) - If
custom_schema_id IS NOT NULL, return a typed sentinel error (e.g.ErrMutualExclusivityViolation) with message referencing mutual exclusivity -- test: TDD service test assertserrors.Is(err, ErrMutualExclusivityViolation) - Existing field extraction happy-path tests still green -- test:
go test ./internal/fieldextraction/...
5a.2 Controller-layer 409 mapping (REQUIRED to deliver the documented response)
Why this sub-phase exists: today api/queryAPI/fieldextractions.go:CreateFieldExtraction maps every service error to http.StatusInternalServerError. Adding only the service pre-check is not enough -- without controller error mapping the new guard still surfaces as a 500. Phase 5a is not complete until the HTTP layer returns 409.
- Update
CreateFieldExtractionhandler inapi/queryAPI/fieldextractions.goto branch on the service sentinel error and returnecho.NewHTTPError(http.StatusConflict, ...)with the mutual-exclusivity message -- test: handler unit test asserts status 409 and body message - Audit the other
FieldExtractionServicehandler entry points inapi/queryAPI/fieldextractions.go(Update, AppendValueTo*, etc.) and apply the same 409 mapping where those methods also run the guard -- test: TDD handler test per entry point - Preserve the existing mapping of non-sentinel service errors to
500-- test: TDD handler test with a generic error path - End-to-end HTTP test: create document, assign custom schema, call
POST /field-extractions, assert response status is409and JSON body carries the documented message -- test: integration against testcontainer DB - Journal entry
Phase 5b - Delete cascade updates
Goal: Hard-deleting a document or client works even when custom metadata and schemas are present. Dependencies: Phase 2. Exit criteria: Document and client delete cascades succeed with custom metadata present; no orphaned rows.
5b.1 Document delete cascade
- Locate
DeleteDocumentCascadeimplementation -- test: found - Insert step:
DeleteDocumentCustomMetadatabetween legacy extractions delete and document row delete -- test: TDD - Insert step:
NullifyDocumentCustomSchemaIdbefore document row delete -- test: TDD - Document delete succeeds for a document with N metadata versions and a schema bound -- test: TDD integration
- Document delete succeeds for a document with no custom metadata (regression) -- test: TDD
- Post-delete:
SELECT COUNT(*) FROM document_custom_metadata WHERE document_id = deleted_idreturns 0 -- test: TDD - Post-delete:
client_metadata_schemasrows referenced by the document are NOT deleted (only the binding is) -- test: TDD
5b.2 Client delete cascade
- Locate
client.HardDelete/deleteClientDependencies-- test: found - Insert step:
DeleteClientMetadataSchemasafter all documents are deleted, before client row delete -- test: TDD - Client hard-delete succeeds when client has schemas and documents with custom metadata -- test: TDD integration
- Post-delete:
SELECT COUNT(*) FROM client_metadata_schemas WHERE client_id = deleted_idreturns 0 -- test: TDD - Post-delete: no orphaned
document_custom_metadatarows -- test: TDD - Regression: client delete still works for clients with no custom metadata -- test: TDD
- Journal entry
Phase 6 - OpenAPI spec updates
Goal: serviceAPIs/queryAPI.yaml reflects all new endpoints and schemas; code generation produces clean client/server stubs.
Dependencies: None for spec authoring; must land before Phase 8 codegen.
Exit criteria: task generate produces updated api/queryAPI/* and pkg/queryAPI/* without errors.
6.1 New tags
- Add
SuperAdminSchemaServicetag with description explicitly stating thesuper_admin-only boundary -- test: spec lints - Add
CustomMetadataServicetag with description statingclient_user+user_admin+super_adminaccess -- test: spec lints
6.2 Schemas
CustomSchemaRequest-- test: codegen produces Go typeCustomSchemaResponse-- test: codegenCustomSchemaListResponse-- test: codegenCustomMetadataRequest-- test: codegenCustomMetadataResponse-- test: codegenCustomMetadataHistoryResponse-- test: codegenValidationErrorResponse-- test: codegenBulkSchemaAssignRequest-- test: codegenBulkSchemaAssignResponse-- test: codegenDocumentSchemaAssignRequest-- test: codegenDocumentSchemaAssignResponse-- test: codegenResetDocumentMetadataResponse(v3) -- test: codegenSchemaStatusenum (active/superseded/retired) -- test: codegen- Modify
DocumentEnrichedto addcustomSchemaId,customSchemaName,hasCustomMetadata,customMetadata-- test: codegen
6.3 Paths
POST /super-admin/custom-schemas-- test: codegen produces handler interfaceGET /super-admin/custom-schemas(with query params) -- test: codegenGET /super-admin/custom-schemas/{schemaId}-- test: codegenPUT /super-admin/custom-schemas/{schemaId}-- test: codegenDELETE /super-admin/custom-schemas/{schemaId}-- test: codegenPATCH /super-admin/documents/{id}/schema-- test: codegenPOST /super-admin/folders/{folderId}/assign-schema-- test: codegenPOST /super-admin/documents/{id}/reset-metadata(v3) -- test: codegenGET /custom-metadata-- test: codegenPOST /custom-metadata-- test: codegenGET /custom-metadata/version-- test: codegenGET /custom-metadata/history-- test: codegen- Modify
GET /document/{id}to document new fields and thecustomMetadataquery parameter -- test: codegen
6.4 Phase 6 integration
task generateclean -- test: no errors- Generated controllers compile -- test:
go build ./... - Journal entry
Phase 7a - Permit.io policy file updates
Goal: permit_policies.yaml declares the new super-admin and custom-metadata resources and grants the correct role permissions.
Dependencies: None.
Exit criteria: YAML validates; setup tool dry-run shows the expected resource/role diff.
Accepted cross-tenant limitation: the authorization check performed by the middleware is role/action/resource-type only -- it does not pass a document or client object to Permit.io. Any caller with
custom-metadata:*grants can therefore read or write any document's custom metadata if they know the UUID. This is unchanged from the rest of the API and is explicitly accepted for v3 because production is deployed per-client (one isolated stack per client, separate DB). The limitation only matters in shared non-prod environments. See Section 5.3 of the plan for the full rationale. Do not add application-level tenant checks to the custom-metadata service in v3 -- that is a separate, API-wide initiative.
- Add
super-adminresource withget,post,patch,deleteactions -- test: YAML valid - Add
custom-metadataresource withget,postactions -- test: YAML valid - Grant
super_adminrole:super-admin: [get, post, patch, delete],custom-metadata: [get, post]-- test: YAML valid - Confirm
user_adminrole NOT grantedsuper-admin-- test: visual diff against v1 intent; test-assertion in Phase 11 - Grant
user_adminrole:custom-metadata: [get, post]-- test: YAML valid - Grant
auditorrole:super-admin: [get],custom-metadata: [get]-- test: YAML valid - Grant
client_userrole:custom-metadata: [get, post]-- test: YAML valid - Add documentation-only
policy_mappingsentries for every/super-admin/*path includingPOST /super-admin/documents/{id}/reset-metadata-- test: YAML valid - Add documentation-only
policy_mappingsentries for every/custom-metadata/*path -- test: YAML valid - Resolve PUT-to-patch question per Section 5.3 footnote: either confirm middleware maps PUT -> patch OR add
putaction tosuper-adminresource and grant it. Record decision in the journal. -- test: decision logged - Journal entry
Phase 7b - Run Permit.io setup tool (dev / uat / prod)
Goal: New resources and grants provisioned in every environment BEFORE Phase 8 code is deployed to that environment.
Dependencies: Phase 7a.
Exit criteria: Setup tool run succeeds in each env; spot-check via permit.io admin UI or list_roles.sh matches YAML.
- Run
cmd/auth_related/permit.setup/run.tool.dev.sh-- test: exit code 0 - Verify
super-adminresource exists in dev Permit.io tenant -- test: list - Verify
custom-metadataresource exists in dev -- test: list - Verify
super_adminrole has expected grants in dev -- test: role inspect - Verify
user_adminrole does NOT havesuper-admingrants in dev -- test: role inspect - Run
run.tool.uat.shafter dev verified -- test: exit 0 - Same verification pass on uat -- test: list + role inspect
- Run
run.tool.prod.shafter uat verified AND only with Q's explicit go-ahead -- test: exit 0, Q confirmation logged in journal - Same verification pass on prod -- test: list + role inspect
- Journal entry with timestamps of each env run
Phase 8 - API controllers + codegen
Goal: HTTP handlers thin-wrap the service layer; every generated interface method is implemented. Dependencies: Phase 4, Phase 6. Exit criteria: Every new endpoint compiles and routes resolve; handler unit tests green.
8.1 Schema CRUD controller (api/queryAPI/customschemas.go)
- File skeleton created with handler receivers wired to
customschema.Service-- test: compiles - Actor extraction helper: every handler in this file derives actor identity via
cognitoauth.GetUserSubject(c)(or the existing middleware helper) and passes it to the service as a separate argument; anycreatedByon the request body is ignored -- test: handler unit test sets a JWT subject via middleware fixture, posts a body with a differentcreatedBy, asserts the service receives the JWT subject POST /super-admin/custom-schemashandler -- parse, validate, derive actor from JWT, callCreateSchema, map errors to 400/409/500 -- test: TDD handler testGET /super-admin/custom-schemashandler with query params -- test: TDDGET /super-admin/custom-schemas/{schemaId}handler -- test: TDDPUT /super-admin/custom-schemas/{schemaId}handler -- derives actor from JWT -- test: TDDDELETE /super-admin/custom-schemas/{schemaId}handler -- derives actor from JWT -- test: TDD including 409 pathPATCH /super-admin/documents/{id}/schemahandler -- derives actor from JWT -- test: TDDPOST /super-admin/folders/{folderId}/assign-schemahandler -- derives actor from JWT -- test: TDD (covered more deeply in Phase 10)POST /super-admin/documents/{id}/reset-metadatahandler (v3) -- derives actor from JWT -- test: TDD (covered more deeply in Phase 10b)
8.2 User-facing custom metadata controller (api/queryAPI/custommetadata.go)
- File skeleton -- test: compiles
- Actor extraction:
POST /custom-metadataderives actor identity from the JWT and ignores any body-suppliedcreatedBy-- test: TDD handler test asserts bodycreatedBydoes not override POST /custom-metadatahandler -- test: TDDGET /custom-metadatahandler -- test: TDDGET /custom-metadata/versionhandler -- test: TDDGET /custom-metadata/historyhandler -- test: TDD
8.3 Phase 8 integration
- All new handler files compile -- test:
go build ./... - Handler unit tests green -- test:
go test ./api/queryAPI/... - Journal entry
Phase 9 - Document endpoint modifications
Goal: GET /document/{id} surfaces custom schema and metadata when requested.
Dependencies: Phase 4, Phase 8.
Exit criteria: Existing tests unchanged; new fields populated correctly for documents with and without custom schemas.
- Extend
DocumentEnrichedbuilder to includecustomSchemaId,customSchemaName,hasCustomMetadataunconditionally -- test: TDD - Extend builder to include
customMetadatawhen?customMetadata=true-- test: TDD - Authorization: confirm
GET /document/{id}still uses the existingdocumentresource, notsuper-admin-- test: role matrix test in Phase 11 - Document without any custom schema returns
customSchemaId: null,hasCustomMetadata: false-- test: TDD - Document with schema bound but no metadata returns
customSchemaId: <id>,hasCustomMetadata: false-- test: TDD - Document with schema + metadata returns populated fields -- test: TDD
PATCH /super-admin/documents/{id}/schemahandler delegates toAssignSchema-- test: TDD- Assign-schema 409 paths surface correct error messages -- test: TDD
- Journal entry
Phase 10 - Bulk folder schema assignment endpoint
Goal: POST /super-admin/folders/{folderId}/assign-schema behaves per Section 5.2 including all skip reasons and the 10K limit.
Dependencies: Phase 4 (AssignSchemaToFolder), Phase 8.
Exit criteria: Recursive walk correct, skip reasons complete, document count limit enforced.
- Handler wired to
AssignSchemaToFolder-- test: compiles - Recursive walk on a 3-deep folder tree produces correct
documentsUpdatedcount -- test: TDD - Skip reason: "different schema + existing metadata" -- test: TDD
- Skip reason: "already assigned to same schema (no-op)" -- test: TDD
- Skip reason: "has legacy field extractions" -- test: TDD
- Reassignment on document with different schema but no metadata -- test: TDD
- Subtree > 10K documents returns 422 with the documented message -- test: TDD (constant override for test)
- Empty folder returns 200 with 0 counts -- test: TDD
- Deeply nested (10+ levels) tree walks correctly -- test: TDD
- Single transaction: simulated mid-walk failure rolls back -- test: TDD
- Journal entry
Phase 10b - Reset-metadata endpoint (v3)
Goal: POST /super-admin/documents/{id}/reset-metadata exposes Service.ResetDocumentMetadata via HTTP with correct auth, response shape, and error mapping. (Deep service-level tests already live in Phase 4.5; this phase validates the HTTP wiring.)
Dependencies: Phase 4.5 (service method), Phase 5b (queries reused), Phase 8 (controller skeleton).
Exit criteria: Handler + integration tests green; authorization matrix for the endpoint verified in Phase 11.
- Handler parses document ID path parameter -- test: TDD
- Handler extracts caller identity for
resetByfield -- test: TDD - Handler calls
Service.ResetDocumentMetadata-- test: TDD - Handler maps success to 200 with
ResetDocumentMetadataResponse-- test: TDD - Handler maps "document not found" to 404 -- test: TDD
- Handler maps "legacy extractions exist" to 409 with mutual-exclusivity message -- test: TDD
- Handler maps unexpected DB errors to 500 -- test: TDD
- End-to-end HTTP test: bind schema v1, POST metadata, PUT schema -> v2 created, reset-metadata, PATCH schema to v2, POST new metadata with added optional field -- test: integration test exercises the full upgrade flow
- End-to-end HTTP test: reset on already-clean document returns 200 with
metadataVersionsDeleted: 0-- test: integration - Journal entry
Phase 11 - Integration and authorization tests
Goal: Every role's access to every new endpoint is verified. Every cross-cutting invariant has a test. task fullsuite:ci ends with All coverage checks passed!.
Dependencies: Phase 10b and Phase 7b in each environment used for testing.
Exit criteria: 80% minimum coverage across new packages; authz matrix 100% covered; no FAIL in fullsuite:ci output.
11.1 Role authorization matrix (every row = one test)
super_adminCANPOST /super-admin/custom-schemas-- test: integrationsuper_adminCANGET /super-admin/custom-schemas-- test: integrationsuper_adminCANGET /super-admin/custom-schemas/{id}-- test: integrationsuper_adminCANPUT /super-admin/custom-schemas/{id}-- test: integrationsuper_adminCANDELETE /super-admin/custom-schemas/{id}-- test: integrationsuper_adminCANPATCH /super-admin/documents/{id}/schema-- test: integrationsuper_adminCANPOST /super-admin/folders/{folderId}/assign-schema-- test: integrationsuper_adminCANPOST /super-admin/documents/{id}/reset-metadata-- test: integrationuser_admingets 403 on every/super-admin/*path+method combination (8 tests) -- test: integration, one per routeuser_adminCAN read + write/custom-metadata(no regression) -- test: integrationclient_usergets 403 on every/super-admin/*path+method combination (8 tests) -- test: integrationclient_userCAN read + write/custom-metadata-- test: integrationauditorCANGETon/super-admin/custom-schemasand/super-admin/custom-schemas/{id}-- test: integrationauditorgets 403 on every/super-admin/*write path (POST/PUT/PATCH/DELETE) -- test: integrationauditorCANGET /custom-metadata, CANNOTPOST /custom-metadata-- test: integration- Every 403 test asserts DB state is unchanged on denial -- test: embedded in each
11.2 Mutual exclusivity
- Legacy write blocked when document has custom schema -- test: integration
- Schema assignment blocked when document has legacy extractions -- test: integration
- Bulk folder assign skips legacy-extracting documents -- test: integration
- Reset-metadata rejected on legacy-extracting document -- test: integration
11.3 Schema lifecycle
active->supersededon PUT create new version -- test: integrationactive->retiredon DELETE -- test: integration- Assignment rejected for non-
activestatus -- test: integration - Schema version concurrency (concurrent PUTs) produces distinct versions -- test: integration
11.4 Delete cascade
- Document hard-delete with custom metadata -- test: integration
- Client hard-delete with schemas and metadata -- test: integration
- No orphaned rows post-delete -- test: integration
11.5 Size and count limits
- Metadata payload at 1MB boundary: accept 1048576, reject 1048577 -- test: integration
- Schema definition at 64KB boundary: accept 65536, reject 65537 -- test: integration
- Bulk folder > 10000 docs: 422 -- test: integration
- Bulk folder exactly 10000 docs: succeeds -- test: integration
11.6 Trigger regression (defense-in-depth)
- Direct SQL bypass of service layer: attempt UPDATE
custom_schema_idon a document with metadata -- trigger fires - Direct SQL bypass: INSERT
document_custom_metadatawith mismatchedschema_id-- trigger fires - Direct SQL bypass: assign cross-client schema -- trigger fires
- After reset + reassign + new metadata, all three triggers still fire on repeat attempts -- test: integration
11.7 Backward compatibility
- Existing
/field-extractionsendpoints unchanged for documents with no custom schema -- test: integration - Existing
/admin/*endpoints unaffected bysuper-adminresource -- test: integration DocumentEnrichedfor documents without custom schemas has null new fields, no regression on existing fields -- test: integration
11.8 Permit.io setup tool verification
- Run setup tool against a disposable dev tenant and assert both resources exist with expected actions -- test: tool output + API inspection
- Assert each role's grants match Section 5.3 exactly (including the negative case for
user_admin) -- test: tool output
11.9 Coverage & full suite
internal/customschema/>= 80% coverage -- test:go test -coverapi/queryAPI/customschemas.go+custommetadata.go>= 80% coverage -- test:go test -covertask test:racegreen -- test: no race reportstask test:perfchecked for regressions in existing hot paths -- test: review outputtask fullsuite:ciends withAll coverage checks passed!and NOFAILlines -- test: full run- Journal entry with test counts, coverage numbers, and any flaky test notes
Phase 12 - Documentation updates
Goal: Every doc that references endpoints, env vars, authorization, or the delete cascade is updated.
Dependencies: Phase 11.
Exit criteria: docs/ai.generated/ regenerated; manual review of key runbooks complete.
- Update
docs/ai.generated/README.mdindex if new doc files added -- test: review - Authorization matrix table published in a dedicated doc page -- test: review
- Sample schemas from Appendix A published as copyable examples -- test: review
CustomMetadataServiceusage guide (end-user perspective) -- test: reviewSuperAdminSchemaServiceusage guide (platform-operator perspective) -- test: review- Reset-metadata runbook: "How to upgrade a document to a newer schema version" with the full wipe -> reassign -> re-POST flow and a warning about metadata history loss -- test: review
- Mutual exclusivity explanation (legacy vs custom) in the document processing guide -- test: review
- Delete cascade documentation updated -- test: review
- Permit.io deployment ordering note (Phase 7b before Phase 8) added to the deployment runbook -- test: review
- Any new env vars? (none expected for v3; confirm.) -- test: review
- Generate new API status report / architecture diagrams if the pipeline regenerates them -- test: review
- Journal entry marking the feature shipped
Cross-phase reminders
- Before starting any phase, re-read the relevant section of
plans/mutable.metadata.plan.combo.v3.md. - TDD is mandatory per project rules: write the test, watch it fail, make it pass. Never write multiple tests before running any.
- No mocks without Q's explicit permission. Use testcontainers for real Postgres.
- Keep the journal current. When a checkbox flips to
[x], add a timestamped line to./journals/implement_mutableMetadata.mdwith the commit SHA and test command that proved it. - If any trigger, migration, or service invariant seems to conflict with the plan, STOP and ask Q before modifying it. The defense-in-depth design depends on the triggers being exactly as written.
task fullsuite:cimust be green at the end of every phase, not just the end of the project. Do not stack broken phases.