M1, M2 and M3 complete * M1, M2 and M3 complete * review changes * docs * docs
72 KiB
Mutable Metadata v4 - Implementation Tracking
Source plan: plans/mutable.metadata.plan.combo.v4.md
Journal (details / decisions / blockers): ./journals/implement_mutableMetadata.md
Purpose: One-glance status of every task needed to ship v4. 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 v4 differs from v3 tracking
v3 organized work as 12 horizontal phases (migrations first, then queries, then service, then controllers, then tests). v4 reorganizes into four vertical slice milestones. Each milestone lands migrations, queries, service methods, controllers, OpenAPI, Permit.io updates, and integration tests for exactly one slice of feature behavior. At the end of every milestone the feature is end-to-end usable in its current scope -- not a half-built stack.
The simplification pass (see Section 16 of the plan) also removed:
- v3's
trg_validate_schema_client_match(v3 labelled "Trigger 2") -- replaced by the composite FKfk_documents_custom_schema_same_client. Note: v4 introduces a different Trigger 2 (trg_prevent_legacy_extraction_on_custom_document) — see Section 2.2; do not confuse the two. - v3's
trg_enforce_consistent_schema_id(v3 labelled "Trigger 3") -- made structurally impossible by removingdocument_custom_metadata.schema_id. v4 has no Trigger 3. current_document_custom_metadataview -- replaced by aLIMIT 1query- The entire delete-cascade workstream (v3 Phase 5b) -- replaced by
ON DELETE CASCADEon the new FKs PUT /super-admin/custom-schemas/{schemaId}-- replaced byPOST .../versionscreatedByrequest-body fields -- actor comes from the JWTcustomSchemaNameand inlinedcustomMetadataonGET /document/{id}-- onlycustomSchemaIdandhasCustomMetadataremain
Every task below reflects those decisions.
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 milestone is green, flip the milestone header to
COMPLETEand date-stamp it. - Never mark a milestone complete until
task fullsuite:ciends withAll coverage checks passed!with noFAILlines.
Milestone roll-up (high-level)
| Milestone | Title | Status | Started | Completed |
|---|---|---|---|---|
| 0-INT | BLOCKER: Fix task fullsuite:ci regression (container race in api/queryAPI) |
COMPLETE | 2026-04-15 | 2026-04-15 |
| 1 | Schema foundation (create/list/version/delete schemas; super_admin gating) | COMPLETE | 2026-04-14 | 2026-04-14 |
| 2 | Core document flow (assign schema; write/read metadata; legacy guard; GET /document/{id} enrichment) |
COMPLETE | 2026-04-14 | 2026-04-15 |
| 3 | Administrative completion (reset-metadata; schema version concurrency; full authz matrix) | COMPLETE | 2026-04-15 | 2026-04-15 |
| 4 | Bulk operations (POST /super-admin/folders/{folderId}/assign-schema; 10K cap; skip reasons) |
NOT STARTED | ||
| Docs | Documentation updates (runs in parallel with Milestones 3 + 4) | NOT STARTED |
Milestone 0-INT — Fix fullsuite:ci regression — COMPLETE (2026-04-15)
Goal: task fullsuite:ci passes clean with All coverage checks passed! and no FAIL lines. This is a prerequisite for completing any other milestone.
Background: The mutable metadata branch caused widespread duplicate-key failures in test runs because the reused postgres container retained data between runs. A truncation fix was applied to internal/test/database.go (see journal entry for 2026-04-15), resolving the duplicate-key issues but introducing a new regression: api/queryAPI tests intermittently fail with "unable to ping database" under parallel execution due to a container concurrency race in testcontainers Reuse: true.
What is already done:
[x]Truncation fix ininternal/test/database.go— resolves duplicate-key violations across all packages
What remains:
[x]Diagnose actual failure: Q's run showed the container race was resolved by Docker state cleanup; the remaining failure was function coverage below 60% onResetDocumentMetadataandmapResetErrorinapi/queryAPI/customschemas_reset.go— both at 0% because no tests existed.[-]Mutex fix — not needed; container race was accumulated Docker state, not a code bug.[x]Write tests: addedTestMapResetError(white-box,package queryapi) tocustommetadata_errors_test.go; createdapi/queryAPI/customschemas_reset_test.go(package queryapi_test) with 5 integration tests covering 401/200/200-idempotent/404/409. All pass.[x]Runtask fullsuite:ci— passes withAll coverage checks passed!, Total Coverage 85.4%, 2026-04-15.[x]Flip this milestone to COMPLETE and date-stamp.
Milestone 1 - Schema foundation — COMPLETE (2026-04-14)
Goal: A super_admin can create, list, get, create-new-version, and delete client-scoped custom schemas. Invalid schemas are rejected. Cross-client schema bindings are structurally impossible at the database level. The super-admin Permit.io resource exists and gates every new schema endpoint.
Dependencies: None.
Exit criteria:
task fullsuite:cigreen- End-to-end integration test proves super_admin can POST a schema, POST a new version,
GET /super-admin/custom-schemas?clientId=...&name=aircraft-engineering&includeAllVersions=true&status=anyreturns both rows with the correct statuses (v1superseded, v2active),GETwith the default query (nostatus, noincludeAllVersions) returns only v2active, DELETE removes a retired schema, anduser_admin/client_user/auditorare correctly gated on every route. - Direct-SQL test proves the composite FK refuses a cross-client schema binding.
1.1 Migration 127 - schema_status_type enum + client_metadata_schemas
Numbering note: The repo contains migrations through
00000000000126. If another branch lands new migrations before this feature merges, renumber this block and all references in the plan and tracking docs.
- 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 - Create
client_metadata_schemastable per Section 4.2 of the plan -- columns, PK, FK toclients(clientId) ON DELETE CASCADE,schema_def jsonb,version,status, timestamps,created_by-- test:\d client_metadata_schemasshows all columns and the cascade FK - 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 - v4: Add
CONSTRAINT uq_cms_id_client UNIQUE (id, client_id)(required as the target of the composite FK in Milestone 1.3) -- test: constraint visible in\d client_metadata_schemas - 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 Schema validator component + dependency
- Add
github.com/santhosh-tekuri/jsonschema/v6togo.mod-- test:go mod tidyclean, build clean - Create
internal/customschema/constants.gowithMaxSchemaDefinitionBytes = 65536,MaxMetadataPayloadBytes = 1048576,MaxBulkAssignDocuments = 10000-- test: constants referenced by tests - 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 explicitlytrueorfalse-- test: TDD tests for both - Reject schema definitions larger than
MaxSchemaDefinitionBytes-- test: TDD test with 64KB+1 payload go test ./internal/customschema/...green (validator only) -- test: package level
1.3 Migration 128 - documents.custom_schema_id column + composite FK
v4 monotonic ordering: Milestone 1 ships migrations 127 and 128. Milestone 2 ships 129 and 130. The filename numbering must match rollout order because
golang-migrate'sUp()only applies versions strictly greater than the current DB version -- if Milestone 1 lands a 129 file, a later Milestone 2 file numbered 128 will be silently skipped or flagged as out of order. Earlier drafts of v4 had 128 and 129 swapped; do not revert that swap.
- Create
00000000000128_add_custom_schema_id_to_documents.up.sql--ALTER TABLE documents ADD COLUMN custom_schema_id uuid NULL(no inlineREFERENCESclause; the composite FK below handles it) -- test: column present and nullable - Add composite FK:
ALTER TABLE documents ADD CONSTRAINT fk_documents_custom_schema_same_client FOREIGN KEY (custom_schema_id, clientId) REFERENCES client_metadata_schemas(id, client_id)-- test: constraint visible; direct SQL INSERT of a document withcustom_schema_idpointing at a schema owned by a different client fails with FK violation. v4 implementation note: the plan Section 4.4 SQL writes"clientId"quoted, but thedocuments.clientIdcolumn was declared unquoted in migration 5, so Postgres stored it as lowercaseclientid. A quoted"clientId"in the FK declaration is a case-sensitive literal lookup that fails withcolumn "clientId" referenced in foreign key constraint does not exist. The migration uses unquotedclientIdwhich folds toclientidat parse time and matches. This same gotcha applies to any future DDL or trigger that referencesdocuments."clientId". - Create
idx_documents_custom_schema_id-- test: index present - Write
down.sqlthat drops the index, the constraint, and the column in correct order -- 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 SQLC queries for schema CRUD
New query file: internal/database/queries/customschemas.sql
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; includesdocumentCountsubquery againstdocuments.custom_schema_id) -- test: generated; unit test confirmsdocumentCountpopulated correctlyGetLatestSchemaByName-- test: generatedGetSchemaDocumentCount(count ofdocuments.custom_schema_id = $1) -- test: generatedSetSchemaStatus(update status, used for supersede and retire) -- test: generatedGetMaxSchemaVersion(for auto-increment on version creation) -- test: generatedLockSchemaVersionsForName(SELECT ... FOR UPDATEon all versions of a(client_id, name)pair) -- test: generatedtask generateclean; no diff after re-run -- test: idempotent
Note
:
GetSchemaMetadataRecordCountis deliberately not in this list. v3 used it forcanDelete, but v4'scanDeleteis derived fromGetSchemaDocumentCountalone (the metadata table has noschema_idcolumn).
1.5 Audit sink
Why in Milestone 1: Schema CRUD needs to log schema.create, schema.update, schema.delete. Defining the sink now lets Milestone 1's service methods land with audit wired in, and Milestones 2/3/4 reuse the same sink without back-fill.
- Decide sink location: either
internal/customschema/audit.go(feature-local) orinternal/audit/(shared) -- test: decision recorded in journal (feature-local chosen 2026-04-14) - Define
AuditRecordstruct:Actor string,Action string(enum:schema.create|schema.update|schema.delete|schema.assign|metadata.write|metadata.reset|folder.assign_schema),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 (TestSlogAuditSink_Record— 4 subtests green) - Wire the sink into
customschema.Servicevia constructor arg -- test: compiles (Service.New(cfg, validator, audit) landed in §1.6)
1.6 Service layer - schema CRUD
- Create
internal/customschema/service.gowithServicestruct holdingcfg,validator,audit-- test: compiles - Create
internal/customschema/models.gowithSchema,SchemaSummary,CreateSchemaInput,CreateSchemaVersionInput,ListFilters-- test: compiles - v4:
CreateSchemaInputandCreateSchemaVersionInputdo not carry aCreatedByfield. Every write method acceptsactor stringas a separate argument. -- test: unit test proves the service method signature hasactor stringand not a body field CreateSchema(ctx, input, actor)-- 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 at version 1 -- test: TDD (service returnsErrSchemaNameConflict, wraps pg23505/uq_client_schema_name_version)CreateSchemawritesschema.createaudit entry -- test: TDDCreateSchemaVersion(ctx, parentSchemaID, input, actor)-- usesLockSchemaVersionsForName-> verifies the pathparentSchemaIDmatches the single row withstatus='active'for(client_id, name)->GetMaxSchemaVersion-> INSERT new version ->SetSchemaStatus(parent, 'superseded')inside a single tx -- test: happy path produces v2CreateSchemaVersionconcurrency: two concurrent calls against same(client_id, name)produce distinct version numbers -- test: TDD with goroutines against testcontainer DB (v4 resolution: winner writes v2, loser returnsErrSchemaParentNotActive— no retry loop; theFOR UPDATEserializes the two tx and the loser observes the parent as superseded)CreateSchemaVersionrejects when parent does not exist -- test: TDD (returnsErrSchemaNotFound)- v4 non-active parent rejection:
CreateSchemaVersionrejects with 409 when the pathparentSchemaIDpoints at asupersededrow while a different row is the active version for the lineage (e.g. v2 is active, caller passes v1's ID) -- test: TDD asserts 409, asserts no new row inserted, asserts v2 still hasstatus='active'and v1 still hasstatus='superseded'(service returnsErrSchemaParentNotActive) - v4 retired parent rejection:
CreateSchemaVersionrejects with 409 when the pathparentSchemaIDpoints at aretiredrow -- test: TDD asserts 409, asserts no new row inserted, asserts the retired row's status is unchanged (service returnsErrSchemaParentRetired) - v4 active-parent invariant: After
CreateSchemaVersionsucceeds there is exactly one row withstatus='active'for the(client_id, name)lineage -- test: TDD asserts the invariant after happy path, after two sequential calls (v1 -> v2 -> v3), and after the two rejection cases above CreateSchemaVersionrejects invalid schema definition (same validator rules as create) -- test: TDDCreateSchemaVersionwritesschema.updateaudit entry -- test: TDD (ResourceIDis the new version's ID;Detailscarriesname,version,parent_schema_id)GetSchema(ctx, schemaID)-- test: returns full row includingschema_defGetSchema404 when not found -- test: TDD (returnsErrSchemaNotFoundviaerrors.Is(err, pgx.ErrNoRows))ListSchemas(ctx, clientID, filters)-- default returns onlyactiveand only the latest version per(client_id, name)-- test: TDDListSchemasfilter byname, bystatus(including theanysentinel for "all statuses"), byincludeAllVersions=true, with pagination -- test: TDD per case, including one case that sets bothstatus=anyandincludeAllVersions=trueagainst a lineage that has one active + one superseded row and asserts both rows are returnedListSchemaspopulatesdocumentCountandcanDelete-- test: TDDListFiltersaccepts anStatusfield with zero-value meaning "active" (default) and a dedicatedStatusAnysentinel (or*schemaStatusTypewithnilmeaning "any") so handlers can distinguish "filter omitted" from "caller asked for everything" — test: unit test on the mapping from query-stringstatus=anyto the service-layer valueDeleteSchema(ctx, schemaID, actor)-- sets status toretired-- test: happy pathDeleteSchemareturns 409 if any document references the schema viacustom_schema_id-- test: TDD (returnsErrSchemaInUse)DeleteSchemawritesschema.deleteaudit entry -- test: TDDgo test ./internal/customschema/...green -- test: package leveltask test:racegreen for package -- test: no races (go test -race ./internal/customschema/... → ok 8.303s)
1.7 OpenAPI spec - schema CRUD
- Add
SuperAdminSchemaServicetag with description explicitly stating thesuper_admin-only boundary -- test: spec lints - Add
SchemaStatusenum (active/superseded/retired) -- test: codegen produces Go type CustomSchemaRequestschema (nocreatedByfield) -- test: codegenCustomSchemaVersionRequestschema (nocreatedBy; v4 rename from v3'sCustomSchemaUpdateRequest) -- test: codegenCustomSchemaResponseschema withcreatedBydeclared as plain string (notformat: email) per plan Section 8.3 "Actor fields" -- test: codegen; spec lint- Spec check: assert that none of the new response schemas declare
format: emailon acreatedBy/resetByfield -- test: grep or programmatic spec check intask openapi:lint(verified viagrep -nE 'format: ?email' serviceAPIs/queryAPI.yaml | grep -iE 'createdBy|resetBy'→ 0 matches) CustomSchemaListResponseschema withdocumentCountandcanDeletefields -- test: codegenPOST /super-admin/custom-schemaspath -- test: codegen produces handler interfaceGET /super-admin/custom-schemas(with query params) -- test: codegenGET /super-admin/custom-schemas/{schemaId}-- test: codegen- v4:
POST /super-admin/custom-schemas/{schemaId}/versionspath (replaces v3'sPUT /super-admin/custom-schemas/{schemaId}) -- test: codegen DELETE /super-admin/custom-schemas/{schemaId}-- test: codegentask generateclean -- test: no errors- Generated controllers compile -- test:
go build ./...(stub handlers land inapi/queryAPI/customschemas_stub.goreturninghttp.StatusNotImplemented; real handlers arrive in §1.9)
1.8 Permit.io policy file + setup tool run
Isolation model (v4): Production is deployed one client per stack (separate DB, services, Cognito pool). The whole stack is the client scope, so the Permit.io role/action/resource-type check is the complete authorization story on production — there is no other client to cross into. Shared dev/uat stacks are test data only (see plan Section 5.3). Do not add application-level document->client ownership checks to the custom-metadata or super-admin routes in v4; there is no cross-client data to protect from inside a production stack, and retrofitting such a check would be an API-wide initiative out of scope for this feature.
- Add
super-adminresource tocmd/auth_related/permit.setup/permit_policies.yamlwithget,post,patch,deleteactions -- test: YAML valid (verified viapython3 -c 'yaml.safe_load(...)'2026-04-14) - Add
custom-metadataresource withget,postactions (provisioning it now lets Milestone 2 ship without another setup-tool run) -- 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 (YAML parser confirmeduser_admin super-admin: None) - 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 including/versionsand/reset-metadata-- test: YAML valid - Add documentation-only
policy_mappingsentries for every/custom-metadata/*path -- test: YAML valid - v4: No
putaction needed onsuper-admin-- v4 replaced the v3PUTroute withPOST .../versions. Record this decision in the journal so no one re-adds it. -- test: journal entry (recorded) - Run
cmd/auth_related/permit.setup/run.tool.dev.sh-- test: exit code 0 (Q ran 2026-04-14; confirmed dev environment updated) - Verify
super-adminandcustom-metadataresources exist in dev with expected actions -- test: confirmed by Q running the setup tool successfully 2026-04-14 - Verify
super_adminrole has expected grants anduser_adminhas NOsuper-admingrants in dev -- test: confirmed by Q 2026-04-14 - [!] Run
run.tool.uat.shafter dev verified -- test: exit 0 (deferred until feature complete per Q directive 2026-04-14: "the others do not need to be run until this feature is completed") - [!] Same verification pass on uat -- test: list + role inspect (deferred until feature complete)
- [!] Run
run.tool.prod.shafter uat verified AND only with Q's explicit go-ahead -- test: exit 0, Q confirmation logged in journal (deferred until feature complete) - [!] Same verification pass on prod -- test: list + role inspect (deferred until feature complete)
- Journal entry with timestamps of each env run (dev: 2026-04-14; uat/prod: deferred)
1.9 Schema CRUD controllers
- Create
api/queryAPI/customschemas.goskeleton with handler receivers wired tocustomschema.Service-- test: compiles (replaced the §1.7 stub file) - v4 actor extraction helper: every handler in this file derives actor identity via
cognitoauth.GetUserSubject(c)(returns the JWTsubclaim — an opaque Cognito subject UUID, not an email) and passes it to the service as a separate argument; anycreatedByon the request body is ignored (actually absent from v4 schemas, but the test codifies the intent) -- test: handler unit test sets a JWT subject via middleware fixture to a fixed UUID, posts a body that includes an extracreatedBykey with a different email-shaped value, asserts the service receives the JWT subject UUID and the body key has no effect, and asserts the responsecreatedByis the UUID (not the body value, not an email) (handler tests set claims viactx.Set("user_claims", ...)mirroringeulaHandlers_test.go) POST /super-admin/custom-schemashandler -- parse, validate, derive actor, callCreateSchema, map errors to 400/409/500 -- test: TDD (TestCreateCustomSchema_Handler5 subtests)GET /super-admin/custom-schemashandler with query params -- test: TDD (TestListCustomSchemas_Handler4 subtests)GET /super-admin/custom-schemas/{schemaId}handler -- test: TDD (TestGetCustomSchema_Handler2 subtests)- v4:
POST /super-admin/custom-schemas/{schemaId}/versionshandler -- derives actor, callsCreateSchemaVersion-- test: TDD (TestCreateCustomSchemaVersion_Handler3 subtests) DELETE /super-admin/custom-schemas/{schemaId}handler -- test: TDD including 409 path (TestDeleteCustomSchema_Handler3 subtests)go test ./api/queryAPI/customschemas_test.gogreen -- test: package (17 subtests PASS under scoped -run invocations)
1.10 Milestone 1 integration tests
- End-to-end test: super_admin POSTs schema v1 -> GET returns it with version 1, active -- test: integration against testcontainer DB (
TestMilestone1_CreateV1AndGet) - End-to-end test: super_admin POSTs schema v2 via
POST .../versions->GET /super-admin/custom-schemas?clientId=...&name=...&includeAllVersions=true&status=anyreturns v1 (statussuperseded) and v2 (statusactive). Also assert the defaultGET(noincludeAllVersions, nostatus) returns only v2 (the default filter isincludeAllVersions=false+status=active, which intentionally hides superseded predecessors from the common admin UX). -- test: integration (TestMilestone1_CreateV2ListSemanticswith 2 subtestsdefault_list_returns_active_latest_onlyandinclude_all_versions_status_any_returns_both) - End-to-end test: DELETE on a schema with documents bound returns 409 -- test: integration (
TestMilestone1_DeleteBoundReturns409; asserts row staysactivein DB on denial) - End-to-end test: DELETE on an unreferenced schema succeeds and sets status
retired-- test: integration (TestMilestone1_DeleteUnreferencedRetires) - Direct-SQL cross-client rejection test: attempt an
UPDATE documents SET custom_schema_id = 'schema-owned-by-client-B' WHERE id = 'doc-owned-by-client-A'and assert the composite FK fires -- test: integration (TestMilestone1_CrossClientFKRejection; asserts the error text mentionsfk_documents_custom_schema_same_clientand docA.custom_schema_id is still NULL) - Authorization matrix for Milestone 1 endpoints:
super_adminpositive on all methods;user_admin403 on every route;client_user403;auditorGET-only on the two read routes, 403 on write routes -- test: one integration test per (role, route, method) cell, each asserting status + DB unchanged on denial (TestMilestone1_AuthorizationMatrix— 4 roles × 5 routes = 20 subtests;runPermitGatereproduces theperformPermitIOAuthorizationchoke-point logic in-test using only the exportedcognitoauthsymbolsGetUserSubject,GetResourceFromRoute,GetActionFromMethod,PermitChecker) task fullsuite:ciclean withAll coverage checks passed!-- test: full run (55 packages OK, 0 FAIL, Total Coverage 86.0%, 2026-04-14 evening run captured in/tmp/fullsuite-ci.log)- Journal entry: Milestone 1 exit snapshot (test counts, coverage numbers, timestamp, commit SHA) (see 2026-04-14 Milestone 1 COMPLETE entry in
journals/implement_mutableMetadata.md)
Milestone 2 - Core document flow
Goal: A super_admin can bind a schema to a document. A client_user (or higher) can write and read validated custom metadata on that document. The legacy field extraction path rejects writes to documents that have been opted into the custom schema system. GET /document/{id} surfaces two new fields for any reader. The mutual-exclusivity invariant is proven end-to-end.
Dependencies: Milestone 1 complete.
Exit criteria:
- Full round-trip test: create schema -> assign to document -> POST valid metadata -> GET returns it -> attempt invalid metadata (400) -> attempt legacy field extraction on same document (409).
- FK-cascade delete test: delete a document that has custom metadata, verify rows are cleaned up automatically.
task fullsuite:cigreen.
2.1 Migration 129 - document_custom_metadata table
- Create
00000000000129_create_document_custom_metadata.up.sqlwithdocument_custom_metadatatable per Section 4.3 of the plan -- columns:id,document_id,metadata,version,created_at,created_by. Noschema_idcolumn. -- test:\d document_custom_metadataconfirms column set - FK on
document_idusesON DELETE CASCADE-- test: direct SQL delete of a document row removes its metadata rows - Add
CONSTRAINT uq_doc_custom_metadata_version UNIQUE (document_id, version)-- test: direct SQL duplicate(document_id, version)INSERT fails - Add
idx_dcm_doc_version_desc ON document_custom_metadata(document_id, version DESC)(the only index v4 needs on this table) -- test: index present - v4: No
idx_dcm_schema_id(column does not exist). No view. Record this explicitly in the migration file comment so a future reader comparing to v3 sees why. -- test: visual - Create corresponding
down.sqldropping the table -- test: up/down round trip clean
2.2 Migration 130 - schema invariant triggers (v4: Trigger 1 + Trigger 2)
- 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_prevent_legacy_extraction_on_custom_document()function per Section 4.7 Trigger 2 -- readsdocuments.custom_schema_idwithFOR SHAREand raisescheck_violationif non-null -- test: function exists inpg_proc - Attach trigger to
documentFieldExtractionsBEFORE INSERT -- test: trigger listed in\d "documentFieldExtractions" - Trigger 2 unit test: insert document with
custom_schema_idset, attempt direct-SQL INSERT intodocumentFieldExtractions-- expectRAISE EXCEPTIONwith "bound to custom schema" message and SQLSTATE23514(check_violation) - Trigger 2 unit test: insert document with
custom_schema_id = NULL, INSERT intodocumentFieldExtractions-- expect success (no-op guard path) - Trigger 2 concurrency test: start tx A running
AssignSchema-styleSELECT documents FOR UPDATE+UPDATE documents SET custom_schema_id = S, then in tx B runINSERT documentFieldExtractionstargeting the same document -- tx B's trigger'sFOR SHAREmust block until tx A commits; after tx A commits tx B's INSERT must fail with the check_violation -- test: TDD with goroutines against testcontainer DB - v4: Migration intentionally does NOT define v3's
trg_validate_schema_client_match(replaced by the composite FK in Milestone 1.3) -- record this in a migration-file comment for future readers - v4: Migration intentionally does NOT define v3's
trg_enforce_consistent_schema_id(not needed --document_custom_metadatahas noschema_idcolumn) -- record this in a migration-file comment - Write
down.sqldropping both triggers and both functions in correct order -- test: up/down round trip clean - Run
task generateafter migrations land -- test: clean - Run
task test:unit:short-- test: all existing tests still pass (no regression)
2.3 SQLC queries for metadata + document binding
CreateDocumentCustomMetadata(noschema_idparameter) -- test: generated code compilesGetCurrentDocumentCustomMetadata--ORDER BY version DESC LIMIT 1usingidx_dcm_doc_version_desc(v4: no view). Joinsdocumentsandclient_metadata_schemasto also returnschema_id,schema_name,schema_versionso theGET /custom-metadatahandler can populate its response in a single round-trip -- test: generated; unit test asserts the returned row carries the schema name/version for a metadata row whose parent document has a bound schemaGetDocumentCustomMetadataByVersion-- same join as above soGET /custom-metadata/versionreturns matching schema decoration -- test: generatedGetDocumentCustomMetadataHistory(with limit/offset) -- no schema join; the history response returns version summaries only -- test: generated- v4:
LockDocumentForMetadataWrite(SELECT id FROM documents WHERE id = @document_id FOR UPDATE; parent-row lock that serializes first and subsequent writes) -- test: generated. Replaces v3'sLockDocumentCustomMetadataForVersion. GetMaxDocumentMetadataVersion--SELECT COALESCE(MAX(version), 0) FROM document_custom_metadata WHERE document_id = @document_id-- test: generatedSetDocumentCustomSchemaId-- test: generatedGetDocumentCustomSchemaId-- test: generated- Reuse existing query:
HasFieldExtractionalready exists ininternal/database/queries/fieldextractions.sql(EXISTS(SELECT 1 FROM documentFieldExtractionVersions WHERE documentId = $1)) and is semantically "does this document have any legacy field-extraction version row?" — exactly the check the mutual-exclusivity guard andAssignSchemalegacy-extraction rejection need. Do not add a newDocumentHasLegacyExtractionsquery. CallHasFieldExtractionfrom the service layer. If readability at the call site is a concern, wrap the call in a private service-layer helper nameddocumentHasLegacyExtractions(ctx, id)that just forwards toHasFieldExtraction— no new SQL, no new generated code -- test: service unit test confirmsAssignSchemaand the metadata write path both reject documents that have an existingdocumentFieldExtractionVersionsrow task generateclean -- test: idempotent
v4: No delete-cascade queries are added in this milestone.
DeleteDocumentCustomMetadata,NullifyDocumentCustomSchemaId, andDeleteClientMetadataSchemasfrom v3 are not created -- the FK cascades replace them.
2.3a Extend internal/document backend for GET /document/{id} enrichment
Why this subsection exists: Plan Section 5.2 declares that
GET /document/{id}gains two new response fields (customSchemaId,hasCustomMetadata). The controller-side builder in §2.7 reads those values out of thedocument.DocumentEnrichedstruct, but as of the start of Milestone 2 the struct has neither field and theGetDocumentEnrichedSQL query (internal/database/queries/document.sql:63) does not return either column. Without the three changes below, the §2.7 builder has nothing to read and the §2.8 integration tests will fail on the enrichment assertions. Do this subsection before §2.7.
- Extend the
GetDocumentEnrichedquery ininternal/database/queries/document.sqlto addd.custom_schema_idto the SELECT list and a correlatedEXISTS(SELECT 1 FROM document_custom_metadata WHERE document_id = d.id) AS has_custom_metadatasubquery — no join todocument_custom_metadata(theEXISTSkeeps the plan flat and index-friendly). -- test: unit test against testcontainer DB asserts the query returns the two new columns with the right values for (a) a document with no schema bound, (b) a document with a schema bound but zero metadata rows, (c) a document with a schema bound and >=1 metadata rows task db:generateclean — confirm the generatedGetDocumentEnrichedrow type ininternal/database/repository/now hasCustomSchemaIDandHasCustomMetadatafields -- test:go build ./internal/database/repository/...clean;grep -n CustomSchemaID internal/database/repository/document.sql.gofinds the new field- Extend the
DocumentEnrichedstruct ininternal/document/service.goto addCustomSchemaID *uuid.UUID(nullable because most documents are not schema-bound) andHasCustomMetadata bool. Place them adjacent toHasTextRecordandFileSizeBytesso the field layout of the struct stays topically grouped -- test:go build ./internal/document/...clean; unit test instantiates the struct and asserts the two new fields zero-value tonilandfalse - Update
GetEnrichedininternal/document/get.goto copydocEnriched.CustomSchemaIDanddocEnriched.HasCustomMetadatafrom the extended generated row into the returnedDocumentEnrichedstruct — no separate follow-up query, no extra round-trip to the DB -- test: service-level unit test against testcontainer DB asserts the returned struct carries the right values for all three fixture cases (no schema, schema no metadata, schema with metadata). Assert in a second test that the function issues exactly the same number of DB queries before and after the change (i.e., no regression from "one enriched read" to "enriched read + follow-up exists check") - Run existing tests for
internal/document/andapi/queryAPI/document*— every pre-existing test that readsDocumentEnrichedmust still pass without modification; the two new fields are additive. -- test:go test ./internal/document/... ./api/queryAPI/... -run Documentgreen
2.4 Service layer - metadata + document binding
- Add
CustomMetadata,MetadataVersion,SetMetadataInputtypes tomodels.go-- test: compiles.SetMetadataInputhas noCreatedByfield.CustomMetadatacarriesSchemaID,SchemaName,SchemaVersionpopulated from the joined query in 2.3 (Section 7.1 of the plan has the struct shape). SetDocumentMetadata(ctx, input, actor)-- derives schema fromdocuments.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 takingLockDocumentForMetadataWriteon the parent row, thenGetMaxDocumentMetadataVersion, then INSERTversion = 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 -- test: TDD with goroutines, proves the parent-row lock is what serializes the first writeSetDocumentMetadataretries once onUNIQUE (document_id, version)violation as a belt-and-suspenders fallback -- test: TDD with an injected unique-violation on the first INSERT attemptSetDocumentMetadatawritesmetadata.writeaudit entry -- test: TDDGetCurrentMetadata(ctx, documentID)-- test: uses the LIMIT 1 query, returns latestGetMetadataByVersion(ctx, documentID, version)-- test: TDDGetMetadataHistory(ctx, documentID)with pagination -- test: TDD- Add
AssignSchemaResulttype tomodels.goper plan Section 7.1 (DocumentID uuid.UUID,CustomSchemaID *uuid.UUID,SchemaName *string,SchemaVersion *int) -- test: compiles AssignSchema(ctx, documentID uuid.UUID, schemaID *uuid.UUID, actor string) (*AssignSchemaResult, error)— signature takesschemaIDas a pointer sonilclears the binding and returns anAssignSchemaResultwith all three schema fieldsnil-- test: happy path single-document assignment returns a populated resultAssignSchemapopulatesSchemaNameandSchemaVersionon the result from the same schema row read it already does for theactive/ same-client validation — no new query is added for this decoration -- test: TDD asserts result fields match the target schema rowAssignSchematakesSELECT id, "clientId", custom_schema_id FROM documents WHERE id = $1 FOR UPDATEas the first statement inside the transaction, before any legacy-extraction existence check or theUPDATE documents-- this is the symmetric parent-row lock for the mutual-exclusivity invariant described in plan Section 7.3 -- test: TDD; code review confirms the lock precedes all other DMLAssignSchemarejects if schema and document belong to different clients -- the composite FK does this at the DB layer; the app-layer check converts the FK violation to a clean 409 -- 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 (service-layer check under the parent-row lock plus Trigger 1 as backstop; expect 409) -- test: TDDAssignSchemaallows settingschemaID == nilto clear binding when no metadata exists; result hasCustomSchemaID,SchemaName,SchemaVersionallnil-- test: TDDAssignSchemawritesschema.assignaudit entry -- test: TDDgo test ./internal/customschema/...green -- test: full package green for Milestones 1+2 service methods
2.5 Legacy field extraction guard + controller 409 mapping
v4 concurrency requirement: A non-locking
SELECT documents.custom_schema_idis NOT sufficient. Plan Section 7.3 walks through the race: two transactions each read a stale snapshot underREAD COMMITTED, both pass the pre-check, and both commit opposing writes. The fix is aSELECT ... FOR UPDATEon the parentdocumentsrow as the first statement inside the tx, mirrored by the same lock inAssignSchema. Trigger 2 (Milestone 2.2) is the DB-layer backstop.
- Locate the existing
FieldExtractionService.CreateFieldExtractionentry point (internal/fieldextraction/service.go:27) -- test: grep and read code - Add a new SQLC query
LockDocumentForLegacyExtractionWrite(SELECT id, custom_schema_id FROM documents WHERE id = @document_id FOR UPDATE) ininternal/database/queries/document.sql-- test: generated code compiles - Modify
CreateFieldExtraction: afterBegin(ctx)and before any other DML, callLockDocumentForLegacyExtractionWrite. The existingAddFieldExtractioncall must move to after the lock acquisition. -- test: code review confirms no DML runs before the lock - If the lock query returns
pgx.ErrNoRows, return a typed sentinel errorErrDocumentNotFound(controller maps to 404) -- test: TDD service test - If the locked row has
custom_schema_id IS NOT NULL, return a typed sentinel errorErrMutualExclusivityViolation-- test: TDD service test assertserrors.Is(err, ErrMutualExclusivityViolation) - Service-layer concurrency test: two goroutines, one calls
AssignSchema(which will itself takeFOR UPDATE-- see 2.4) and the other callsCreateFieldExtraction. Run both orderings. Whichever commits second must fail --AssignSchema-then-CreateFieldExtractionfails withErrMutualExclusivityViolation;CreateFieldExtraction-then-AssignSchemafails with the Trigger 1 EXCEPTION converted to 409. Neither ordering may leave the document in the "both systems active" state. -- test: TDD with goroutines against testcontainer DB; assertion checks final DB state in addition to returned errors - Service-layer concurrency test (DB trigger backstop): deliberately skip the
FOR UPDATEcall path in a test-only harness, run the same race, and confirm Trigger 2 still catches it by surfacing a PostgreSQLcheck_violationerror with SQLSTATE23514. This proves the defense-in-depth works even if the service-layer lock is ever removed by a regression. -- test: TDD - Regression guard (v4 — not a present-day audit): at the time of this plan,
FieldExtractionServicehas exactly one mutator —CreateFieldExtractionatinternal/fieldextraction/service.go:27. The other four methods (GetCurrentFieldExtraction,GetFieldExtractionHistory,GetFieldExtractionArrayFields,GetFieldExtractionByVersion) are reads. v3 carried a generic "audit every other mutation entry point" task that pointed at methods (UpdateFieldExtraction,AppendValueTo*) that do not exist and have never existed. Instead of re-running a phantom audit, add a regression test that enforces the rule going forward: list the exported methods ofFieldExtractionServicevia reflection (or, simpler, a hand-maintained allow-list), and fail the test if any new exported method with a mutating name prefix (Create,Update,Append,Delete,Insert,Set,Reset) is added without being added to a second allow-list that says "this method has been reviewed and it takesLockDocumentForLegacyExtractionWritebefore any DML." -- test: unit test ininternal/fieldextraction/that fails loudly with a message pointing at this tracking line if a new mutator is added without the review flag - Existing field extraction happy-path tests still green -- test:
go test ./internal/fieldextraction/... - Update
CreateFieldExtractionhandler inapi/queryAPI/fieldextractions.goto branch onErrMutualExclusivityViolation->echo.NewHTTPError(http.StatusConflict, ...)andErrDocumentNotFound-> 404 -- test: handler unit tests assert both status codes and body messages - Handler also converts a PostgreSQL
check_violation(SQLSTATE23514) surfaced from Trigger 2 into the same 409 response so a future bypass of the service-layer lock still returns the documented error shape -- test: TDD handler test with a fabricated*pgconn.PgError - 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 - End-to-end HTTP race test: fire
POST /super-admin/documents/{id}/schemaandPOST /field-extractionsconcurrently against the same document, assert the final DB state has exactly one ofcustom_schema_id IS NOT NULLXOR adocumentFieldExtractionVersionsrow, never both -- test: integration
2.6 OpenAPI spec - metadata + document binding
- Add
CustomMetadataServicetag with description statingclient_user+user_admin+super_adminaccess -- test: spec lints CustomMetadataRequest(nocreatedBy) -- test: codegenCustomMetadataResponse-- test: codegenCustomMetadataHistoryResponse-- test: codegenValidationErrorResponse-- test: codegenDocumentSchemaAssignRequest(nocreatedBy) -- test: codegenDocumentSchemaAssignResponse-- test: codegen- v4: Modify
DocumentEnrichedto add onlycustomSchemaIdandhasCustomMetadata. Do NOT addcustomSchemaNameorcustomMetadata. Do NOT add a new?customMetadata=truequery parameter. -- test: codegen produces exactly two new fields PATCH /super-admin/documents/{id}/schemapath -- test: codegenGET /custom-metadatapath -- test: codegenPOST /custom-metadatapath -- test: codegenGET /custom-metadata/versionpath -- test: codegenGET /custom-metadata/historypath -- test: codegentask generateclean -- test: no errors
2.7 Controllers - metadata + assign schema + document enrichment
- Create
api/queryAPI/custommetadata.goskeleton -- test: compiles - v4 actor extraction:
POST /custom-metadataderives actor identity from the JWT and passes it to the service as an argument -- test: TDD handler test asserts bodycreatedBy(if provided) does not override POST /custom-metadatahandler -- test: TDDGET /custom-metadatahandler -- test: TDDGET /custom-metadata/versionhandler -- test: TDDGET /custom-metadata/historyhandler -- test: TDD- Add
PATCH /super-admin/documents/{id}/schemahandler toapi/queryAPI/customschemas.go-- derives actor -- test: TDD - Extend
DocumentEnrichedbuilder to includecustomSchemaIdandhasCustomMetadataunconditionally — reads the values directly from thedocument.DocumentEnrichedstruct (populated in §2.3a); no new DB query or service call at this layer. -- test: TDD - 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
- Authorization: confirm
GET /document/{id}still uses the existingdocumentresource, notsuper-admin-- test: role matrix test in 2.8 go build ./...clean -- test: compile
2.8 Milestone 2 integration tests
- End-to-end: create schema -> assign to document (PATCH) -> POST valid metadata ->
GET /custom-metadata?documentId=...returns the stored row with populatedschemaId,schemaName,schemaVersion(from the §2.3 joined query) and thecreatedByfield equal to the JWT subject UUID -- test: integration - End-to-end: POST invalid metadata -> 400 with validation errors -- test: integration
- End-to-end: POST metadata to document without assigned schema -> 400 -- test: integration
- End-to-end: assign schema to document that has legacy extractions -> 409 -- test: integration
- End-to-end: write legacy field extraction to document that has custom schema -> 409 -- test: integration
- End-to-end
GET /custom-metadata/version: POST metadata three times on the same document (version 1, 2, 3), thenGET /custom-metadata/version?documentId=...&version=2returns exactly the v2 payload (not v1, not v3) with populatedschemaId/schemaName/schemaVersionfrom the §2.3 joined query -- test: integration - End-to-end
GET /custom-metadata/versionnot-found:version=99on a document with only 3 versions returns 404;documentIdfor a non-existent document returns 404;version < 1(e.g.0) returns 400 (plan Section 5.4 declares the parameter>= 1) -- test: integration per case - End-to-end
GET /custom-metadata/historyhappy path: after three POSTs,GET /custom-metadata/history?documentId=...returns all three version summaries in descendingversionorder and each summary carriesversion,createdAt,createdBy(subject UUID) -- test: integration - End-to-end
GET /custom-metadata/historypagination: POST 5 metadata versions, call withlimit=2&offset=0(returns v5, v4),limit=2&offset=2(returns v3, v2),limit=2&offset=4(returns v1) — assert no duplicates and correct ordering across the page boundary -- test: integration GET /custom-metadata/historyedge cases (v4 — behavior committed in plan Section 5.4 edge-case table, not up to the implementer): one integration test per case, each asserting the exact status code and response body declared in the plan — -limit=0→ 400 with message"limit must be between 1 and 200"-limit=-1→ 400 with the same message -limit=201→ 200, clamped to 200 rows (seed 205 versions, assert exactly 200 returned, newest first) -offset=-1→ 400 with message"offset must be >= 0"-documentId= random non-existent UUID → 404 with message"document not found"-documentIdexists but has zero metadata rows → 200 with{"versions": []}(andContent-Type: application/json) -- test: integration per case- Authorization matrix for Milestone 2 endpoints: one integration test per
(role, route, method)cell across all four/custom-metadataroutes (GET,POST,GET /version,GET /history) plusPATCH /super-admin/documents/{id}/schema. Expected matrix:super_adminpositive on every cell;user_admin403 onPATCH /super-admin/...and positive on all four/custom-metadataroutes;client_user403 onPATCH /super-admin/...and positive on all four/custom-metadataroutes;auditor403 onPATCH /super-admin/...and onPOST /custom-metadata, positive on the three/custom-metadataGET routes (GET,GET /version,GET /history) -- test: one integration test per (role, route, method) cell, each asserting status + DB unchanged on denial - v4 FK-cascade delete test: create document with custom metadata -> call existing
DeleteDocumentCascade-> verifydocument_custom_metadatarows for that document are gone (via the ON DELETE CASCADE, without touching the delete code path) -- test: integration - v4 FK-cascade client delete test: create client with schemas and a document with custom metadata -> call
client.HardDelete-> verifydocument_custom_metadataandclient_metadata_schemasrows are gone -- test: integration - Metadata payload at 1MB boundary: accept 1048576, reject 1048577 -- test: integration
- Concurrency: two concurrent first-writers against same document succeed -- test: integration (service test in 2.4 covers it; re-verify at HTTP level)
task fullsuite:ciclean -- test: full run- Journal entry: Milestone 2 exit snapshot
2.9 Pre-partitioning carve-out for M3/M4 parallel execution
Purpose: Milestones 3 and 4 both add handler code and service-layer types that were originally drafted into
api/queryAPI/customschemas.goandinternal/customschema/models.go. If both milestones run in parallel on separate branches against those two shared files, the merge at the end produces conflicts at the seam. This section creates four empty stub files so each milestone owns disjoint files from the start. Do this once, at the end of Milestone 2, before branching M3 and M4 onto separate workstreams. If M3 and M4 will be run serially, this section can be skipped (the handlers can land incustomschemas.godirectly); only commit the stubs if parallel execution is actually planned.
- Create
api/queryAPI/customschemas_reset.goas an empty stub containing onlypackage queryAPI-- test:go build ./...clean - Create
api/queryAPI/customschemas_bulk.goas an empty stub containing onlypackage queryAPI-- test:go build ./...clean - Create
internal/customschema/models_reset.goas an empty stub containing onlypackage customschema-- test:go build ./...clean - Create
internal/customschema/models_bulk.goas an empty stub containing onlypackage customschema-- test:go build ./...clean - Commit the four stubs on the shared base branch (the branch M3 and M4 will both fork from), so neither workstream has to create them -- test:
git logshows the four files as committed before either milestone branch exists - Update Milestone 3 owner and Milestone 4 owner: M3 only edits
customschemas_reset.goandmodels_reset.go; M4 only editscustomschemas_bulk.goandmodels_bulk.go; neither touchescustomschemas.goormodels.gofor new types, only for imports and registration wiring if required -- test: code review of each milestone's final diff
Milestone 3 - Administrative completion
Goal: Reset-metadata is exposed so super_admin can upgrade a document to a newer schema version. Schema version-creation concurrency is tested end-to-end. The full authorization matrix (including reset-metadata and version-creation) is locked in.
Dependencies: Milestone 2 complete.
Exit criteria:
- Full upgrade flow works: assign v1 -> write metadata -> create v2 -> reset -> assign v2 -> write new metadata.
- Reset is atomic, idempotent, rejects legacy documents, returns 404 on missing document.
task fullsuite:cigreen.
3.1 SQLC queries for reset-metadata
LockDocumentForReset(SELECT id, "clientId", custom_schema_id FROM documents WHERE id = @document_id FOR UPDATE) -- test: generatedGetDocumentSchemaBindingForReset(joindocuments->client_metadata_schemas, returns(custom_schema_id, schema_name, schema_version)or all-null) -- test: generatedCountDocumentCustomMetadataVersions-- test: generated- v4:
DeleteDocumentCustomMetadataForReset(DELETE FROM document_custom_metadata WHERE document_id = @document_id) -- owned by the reset path, name disambiguates from any future cascade query -- test: generated - v4:
NullifyDocumentCustomSchemaIdForReset(UPDATE documents SET custom_schema_id = NULL WHERE id = @document_id) -- owned by the reset path -- test: generated task generateclean -- test: idempotent
3.2 Service method - ResetDocumentMetadata
- Add
ResetMetadataResulttype tointernal/customschema/models_reset.go(the M3-owned stub from §2.9; fall back tointernal/customschema/models.goonly if M3/M4 will run serially and §2.9 was skipped) withDocumentID,PreviousSchemaID *uuid.UUID,PreviousSchemaName *string,PreviousSchemaVersion *int,MetadataVersionsDeleted int,ResetAt time.Time,ResetBy string-- test: compiles ResetDocumentMetadata(ctx, documentID, actor)-- single transaction:LockDocumentForReset,GetDocumentSchemaBindingForReset,CountDocumentCustomMetadataVersions,DeleteDocumentCustomMetadataForReset,NullifyDocumentCustomSchemaIdForReset, 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
- (v3's "Trigger 3 regression" test is removed in v4: the table has no
schema_idcolumn. Replace with a structural test that confirmsdocument_custom_metadatahas noschema_idcolumn after migrations.) -- test: TDD schema-structure test - Reset writes
metadata.resetaudit 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 - v4 Reset concurrency: reset vs
SetDocumentMetadataon same document serialize. This is the pair that matters most in practice: plan Section 7.1 / 8.2 sayLockDocumentForResetandLockDocumentForMetadataWriteuse the same parent-rowSELECT ... FOR UPDATEshape specifically so these two operations cannot interleave. Test both orderings with goroutines against testcontainer DB: 1. reset-then-write: start reset (holdsFOR UPDATE), raceSetDocumentMetadatafor the same document. The writer must block until reset commits; once it unblocks it must observecustom_schema_id = NULLand return the "no schema assigned" error (400/409), not a successful write into a stale schema. Assert final DB state has zero metadata rows for the document. 2. write-then-reset: startSetDocumentMetadata(holdsFOR UPDATE, inserts a new metadata row), race reset for the same document. The reset must block until the write commits; once it unblocks it must delete the just-written row and returnmetadataVersionsDeleted >= 1. Assert final DB state has zero metadata rows andcustom_schema_id = NULL. Neither ordering may leave an orphaned metadata row or a partially-reset document. -- test: TDD with goroutines against testcontainer DB; assertions cover both ordering outcomes
3.3 OpenAPI + handler - reset-metadata
DocumentMetadataResetResponseschema (no request body schema needed; empty body) -- test: codegen (NOTE: namedDocumentMetadataResetResponsenotResetDocumentMetadataResponseto avoid codegen name conflict; see journal 2026-04-15)POST /super-admin/documents/{id}/reset-metadatapath -- test: codegentask generateclean -- test: no errors- Add handler to
api/queryAPI/customschemas_reset.go(the M3-owned stub from §2.9; fall back toapi/queryAPI/customschemas.goonly if M3/M4 will run serially and §2.9 was skipped) -- parses document ID, derives actor from JWT, calls service, maps errors -- test: TDD - Handler maps success to 200 with
ResetDocumentMetadataResponse-- test:TestM3_ResetDocumentMetadata_HappyPath+TestM3_ResetDocumentMetadata_IdempotentOnCleanDoc - Handler maps "document not found" to 404 -- test:
TestM3_ResetDocumentMetadata_DocumentNotFound+TestMapResetError/document_not_found - Handler maps "legacy extractions exist" to 409 -- test:
TestM3_ResetDocumentMetadata_LegacyExtractionsConflict+TestMapResetError/mutual_exclusivity_violation - Handler maps unexpected DB errors to 500 -- test:
TestMapResetError/unknown_error_returns_500
3.4 Milestone 3 integration tests
- End-to-end upgrade flow: bind schema v1 -> POST metadata ->
POST .../versionsto create v2 -> reset-metadata -> PATCH schema to v2 -> POST new metadata with added optional field -- test:TestM3_FullUpgradeFlowinapi/queryAPI/customschemas_reset_test.go - End-to-end: reset on already-clean document returns 200 with
metadataVersionsDeleted: 0-- test:TestM3_ResetDocumentMetadata_IdempotentOnCleanDoc - Schema version concurrency: two concurrent
POST .../versionscalls for same(client_id, name)produce distinct versions -- test:TestM3_SchemaVersionConcurrencyinapi/queryAPI/customschemas_integration_test.go - Authorization matrix for reset-metadata and version creation:
super_adminpositive;user_admin403;client_user403;auditor403 -- test:TestM3_AuthMatrix_Resetinapi/queryAPI/customschemas_reset_test.go(4 subtests: super_admin/user_admin/client_user/auditor) task fullsuite:ciclean -- test: full run (85.4%, All coverage checks passed!, no FAIL lines, 2026-04-15)- Journal entry: Milestone 3 exit snapshot (2026-04-15)
Milestone 4 - Bulk operations
Goal: Bulk folder schema assignment is available for administrative convenience.
Dependencies: Milestone 2 complete. Milestone 4 only needs the single-document AssignSchema semantics (committed in Milestone 2), not the ResetDocumentMetadata work in Milestone 3 — nothing in the recursive folder walk reads or writes the reset path. Milestones 3 and 4 can run in parallel on separate workstreams only after the shared files are pre-partitioned: without that step, both milestones would edit api/queryAPI/customschemas.go and internal/customschema/models.go concurrently and produce merge conflicts at the seam. The pre-partition carve-out (see section 2.9 below) creates four new stub files so each milestone owns disjoint files: M3 owns api/queryAPI/customschemas_reset.go and internal/customschema/models_reset.go; M4 owns api/queryAPI/customschemas_bulk.go and internal/customschema/models_bulk.go. With the carve-out in place the merge gate is task fullsuite:ci green after both land, and serializing M4 behind M3 adds roughly one milestone of wall-clock time for no technical reason. Without the carve-out, serialize M4 behind M3 as the safe fallback.
Exit criteria:
- Recursive walk covers a 3-deep folder tree correctly, handles all skip reasons, enforces the 10K document cap, runs in a single transaction.
task fullsuite:cigreen.
4.1 SQLC queries for bulk folder assignment
BulkSetDocumentCustomSchemaIdInFolderTree(WITH RECURSIVE) -- test: generated; recursive CTE compilesGetDocumentsWithMetadataInFolderTree-- test: generatedGetDocumentsWithLegacyExtractionsInFolderTree-- test: generated- Reuse existing query:
CountDocumentsInFolderTreealready exists ininternal/database/queries/folders.sql(a recursive CTE overfolders+documents.folderId) and is exactly the shape theMaxBulkAssignDocumentspre-check needs. Do not add a new query; call the existing one from the Milestone 4 service method. Task: unit test that confirms the bulk service's cap check is wired to the existingCountDocumentsInFolderTreegenerated method -- test: unit test asserts the right method is called and returns a count that matches a seeded fixture task generateclean -- test: idempotent
4.2 Service method - AssignSchemaToFolder
- Add
BulkAssignResult,SkippedDocumenttypes tointernal/customschema/models_bulk.go(the M4-owned stub from §2.9; fall back tointernal/customschema/models.goonly if M3/M4 will run serially and §2.9 was skipped) -- test: compiles. NocreatedByon the input. AssignSchemaToFolder(ctx, folderID, schemaID, actor)-- walks subtree withWITH RECURSIVEand returnsBulkAssignResult-- test: happy path on a 3-deep tree- 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 (constant override for test) - Bulk assignment runs in single transaction; simulated mid-walk failure rolls back everything -- test: TDD
- Bulk assignment writes
folder.assign_schemaaudit entry with counts -- test: TDD
4.3 OpenAPI + handler - bulk folder assignment
BulkSchemaAssignRequestschema (nocreatedBy) -- test: codegenBulkSchemaAssignResponseschema -- test: codegenPOST /super-admin/folders/{folderId}/assign-schemapath -- test: codegentask generateclean -- test: no errors- Handler in
api/queryAPI/customschemas_bulk.go(the M4-owned stub from §2.9; fall back toapi/queryAPI/customschemas.goonly if M3/M4 will run serially and §2.9 was skipped) wired toAssignSchemaToFolder-- derives actor from JWT -- test: compiles
4.4 Milestone 4 integration tests
- Recursive walk on a 3-deep folder tree produces correct
documentsUpdatedcount -- test: integration - Skip reason: "different schema + existing metadata" -- test: integration
- Skip reason: "already assigned to same schema (no-op)" -- test: integration
- Skip reason: "has legacy field extractions" -- test: integration
- Reassignment on document with different schema but no metadata -- test: integration
- Subtree > 10K documents returns 422 with documented message -- test: integration (constant override for test)
- Bulk folder exactly 10000 docs: succeeds -- test: integration
- Empty folder returns 200 with 0 counts -- test: integration
- Deeply nested (10+ levels) tree walks correctly -- test: integration
- Authorization matrix for bulk folder endpoint:
super_adminpositive;user_admin403;client_user403;auditor403 -- test: integration per cell task fullsuite:ciclean -- test: full run- Journal entry: Milestone 4 exit snapshot
Documentation (parallel with Milestones 3 + 4)
Goal: Every doc that references endpoints, env vars, authorization, or the feature is updated. Dependencies: Milestone 2 complete (feature is shippable in narrow form); documentation can track Milestones 3 and 4 in real time.
- Update
docs/ai.generated/README.mdindex if new doc files added -- test: review (entry 11 added pointing to11-custom-metadata-guide.md) - Authorization matrix table published in a dedicated doc page (include the v4 change that
user_adminhas no schema CRUD) -- test: review (11-custom-metadata-guide.mdAuthorization Matrix section) - Sample schemas from Appendix A of the plan published as copyable examples -- test: review (
11-custom-metadata-guide.mdSchema Definition Requirements section, two examples) CustomMetadataServiceusage guide (end-user perspective) -- test: review (11-custom-metadata-guide.mdEnd-User Workflow section +03-api-documentation.mdCustomMetadataService section)SuperAdminSchemaServiceusage guide (platform-operator perspective), including thePOST /versionsroute -- test: review (11-custom-metadata-guide.mdPlatform Operator Workflow +03-api-documentation.mdSuperAdminSchemaService section)- 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 (
11-custom-metadata-guide.mdSchema Upgrade Runbook section) - Mutual exclusivity explanation (legacy vs custom) in the document processing guide -- test: review (
11-custom-metadata-guide.mdMutual Exclusivity section) - v4: Note that
DeleteDocumentCascadeandclient.HardDeletedo NOT need custom-metadata-specific steps because of the FK cascades -- test: review (11-custom-metadata-guide.mdCascade Deletion Behavior section) - Permit.io deployment ordering note (run setup tool before deploying controllers) added to the deployment runbook -- test: review (
11-custom-metadata-guide.mdPermit.io Deployment Note section) - v4: Document the HTTP method choice:
POST /super-admin/custom-schemas/{schemaId}/versions, notPUT-- test: review (03-api-documentation.mdand11-custom-metadata-guide.mdSchema Versioning section) - v4: Document that
createdByis not accepted from request bodies; actor comes from the JWT -- test: review (03-api-documentation.mdNotes on POST /super-admin/custom-schemas;11-custom-metadata-guide.mdActor Fields section) - Any new env vars? (none expected for v4; confirm.) -- test: review (confirmed: no new env vars in v4)
- Generate new API status report / architecture diagrams if the pipeline regenerates them -- test: review
- Journal entry marking the feature shipped
Cross-milestone reminders
- Before starting any milestone, re-read the relevant section of
plans/mutable.metadata.plan.combo.v4.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 migration, trigger, FK, or service invariant seems to conflict with the plan, STOP and ask Q before modifying it. The defense-in-depth design depends on the composite FK and Trigger 1 being exactly as written.
- v4 reminder — things NOT to add (they are v3 leftovers and mean you are regressing):
- Do NOT add a
schema_idcolumn todocument_custom_metadata. - Do NOT add v3's
trg_validate_schema_client_matchtrigger — it is replaced by the composite FKfk_documents_custom_schema_same_client(plan Section 4.7, Milestone 1.3). - Do NOT add v3's
trg_enforce_consistent_schema_idtrigger — it is structurally unnecessary becausedocument_custom_metadatahas noschema_idcolumn in v4. - Reminder: v4 DOES add two triggers —
trg_prevent_schema_reassignment(v3's Trigger 1, renamed for clarity) andtrg_prevent_legacy_extraction_on_custom_document(new in v4, see Section 2.2). Those are correct and must be present. - Do NOT add a
current_document_custom_metadataview. - Do NOT add
DeleteDocumentCustomMetadata/NullifyDocumentCustomSchemaId/DeleteClientMetadataSchemasto the existing delete code paths. - Do NOT add a
PUT /super-admin/custom-schemas/{schemaId}route. - Do NOT accept
createdByin new request bodies. - Do NOT add
customSchemaNameorcustomMetadatatoGET /document/{id}. - Do NOT declare
format: emailoncreatedBy/resetByin the new OpenAPI response schemas — those are plain-string Cognito subject IDs (plan Section 8.3 "Actor fields"). - If any of those appear in your diff, something has regressed to v3.
- Do NOT add a