From 17fc813823f313b4f8f9439f130672633430de1d Mon Sep 17 00:00:00 2001 From: Jay Brown Date: Thu, 16 Apr 2026 23:11:26 +0000 Subject: [PATCH] Merged in feature/mutable-metadata1 (pull request #221) M1, M2 and M3 complete * M1, M2 and M3 complete * review changes * docs * docs --- api/queryAPI/api.gen.go | 1229 ++++++--- api/queryAPI/controllers.go | 2 + api/queryAPI/custommetadata.go | 264 ++ api/queryAPI/custommetadata_errors_test.go | 150 ++ .../custommetadata_integration_test.go | 489 ++++ api/queryAPI/customschemas.go | 325 +++ api/queryAPI/customschemas_bulk.go | 4 + .../customschemas_integration_test.go | 709 +++++ api/queryAPI/customschemas_reset.go | 74 + api/queryAPI/customschemas_reset_test.go | 397 +++ api/queryAPI/customschemas_test.go | 595 +++++ api/queryAPI/documents.go | 10 + api/queryAPI/documents_test.go | 16 +- api/queryAPI/fieldextractions.go | 20 + api/queryAPI/testutils_test.go | 3 + cmd/auth_related/permit.setup/list_roles.sh | 12 + .../permit.setup/permit_policies.yaml | 91 + cmd/auth_related/permit.setup/run.tool.dev.sh | 18 + .../permit.setup/run.tool.prod.sh | 15 + cmd/auth_related/permit.setup/run.tool.uat.sh | 17 + cmd/queryAPI/main.go | 3 + docs/ai.generated/01-system-overview.md | 8 + docs/ai.generated/03-api-documentation.md | 414 ++- docs/ai.generated/04-data-architecture.md | 174 +- docs/ai.generated/09-authentication-guide.md | 2 + docs/ai.generated/11-custom-metadata-guide.md | 331 +++ docs/ai.generated/README.md | 20 +- docs/ai.generated/architecture-diagram.png | Bin 0 -> 569241 bytes docs/ai.generated/architecture-diagram.svg | 639 +++++ docs/ai.generated/queryapi.summary.md | 105 + docs/ai.generated/software-architecture.md | 8 + go.mod | 1 + go.sum | 4 + internal/customschema/audit.go | 82 + internal/customschema/audit_test.go | 141 + internal/customschema/constants.go | 10 + internal/customschema/models.go | 124 + internal/customschema/models_bulk.go | 4 + internal/customschema/models_reset.go | 65 + internal/customschema/service.go | 409 +++ internal/customschema/service_metadata.go | 537 ++++ .../customschema/service_metadata_test.go | 444 ++++ internal/customschema/service_reset.go | 170 ++ internal/customschema/service_reset_test.go | 544 ++++ internal/customschema/service_test.go | 925 +++++++ internal/customschema/validator.go | 128 + internal/customschema/validator_test.go | 189 ++ ...27_create_client_metadata_schemas.down.sql | 11 + ...0127_create_client_metadata_schemas.up.sql | 26 + ...add_custom_schema_id_to_documents.down.sql | 9 + ...8_add_custom_schema_id_to_documents.up.sql | 18 + ...9_create_document_custom_metadata.down.sql | 6 + ...129_create_document_custom_metadata.up.sql | 39 + ...130_add_schema_invariant_triggers.down.sql | 11 + ...00130_add_schema_invariant_triggers.up.sql | 116 + internal/database/queries/custommetadata.sql | 186 ++ internal/database/queries/customschemas.sql | 109 + internal/database/queries/document.sql | 24 +- .../client_metadata_schemas_migration_test.go | 899 +++++++ .../database/repository/custommetadata.sql.go | 523 ++++ .../database/repository/customschemas.sql.go | 394 +++ .../repository/customschemas_queries_test.go | 601 +++++ internal/database/repository/document.sql.go | 67 +- internal/database/repository/folders.sql.go | 5 +- internal/database/repository/labels.sql.go | 10 +- .../repository/milestone2_migrations_test.go | 1073 ++++++++ .../milestone2_sqlc_queries_test.go | 166 ++ internal/database/repository/models.go | 79 +- internal/document/get.go | 20 +- internal/document/get_test.go | 108 + internal/document/service.go | 29 +- internal/fieldextraction/service.go | 75 +- internal/fieldextraction/service_test.go | 68 +- internal/test/database.go | 25 + pkg/queryAPI/api.gen.go | 2318 +++++++++++++++++ plans/metadata.progress.review.codex.md | 19 + plans/mutable.metadata.plan.claude.md | 1100 ++++++++ ...e.metadata.plan.codex.comparison.claude.md | 242 ++ plans/mutable.metadata.plan.codex.md | 539 ++++ plans/mutable.metadata.plan.combo.md | 1364 ++++++++++ plans/mutable.metadata.plan.combo.v2.md | 1566 +++++++++++ plans/mutable.metadata.plan.combo.v3.md | 1729 ++++++++++++ ...mutable.metadata.plan.combo.v3.tracking.md | 652 +++++ plans/mutable.metadata.plan.combo.v4.md | 2008 ++++++++++++++ ...ta.plan.combo.v4.review.claude-response.md | 140 + ...metadata.plan.combo.v4.review.coded.old.md | 55 + ...ble.metadata.plan.combo.v4.review.codex.md | 62 + ....metadata.plan.combo.v4.review.findings.md | 270 ++ ...mutable.metadata.plan.combo.v4.tracking.md | 575 ++++ plans/mutable.metadata.requirments.md | 30 + .../mutable.metadata.review.combo.v3.codex.md | 86 + ...utable.metadata.simplify. plan.codex.v3.md | 544 ++++ .../mutable.metadata.testing.review.codex.md | 79 + .../manual_tests/test_custom_metadata.1.sh | 1080 ++++++++ .../manual_tests/test_zip_all_types_upload.sh | 137 + serviceAPIs/queryAPI.yaml | 1035 ++++++++ .../santhosh-tekuri/jsonschema/v6/.gitmodules | 4 + .../jsonschema/v6/.golangci.yml | 7 + .../jsonschema/v6/.pre-commit-hooks.yaml | 7 + .../santhosh-tekuri/jsonschema/v6/LICENSE | 175 ++ .../santhosh-tekuri/jsonschema/v6/README.md | 88 + .../santhosh-tekuri/jsonschema/v6/compiler.go | 332 +++ .../santhosh-tekuri/jsonschema/v6/content.go | 51 + .../santhosh-tekuri/jsonschema/v6/draft.go | 360 +++ .../santhosh-tekuri/jsonschema/v6/format.go | 708 +++++ .../santhosh-tekuri/jsonschema/v6/go.work | 8 + .../santhosh-tekuri/jsonschema/v6/go.work.sum | 4 + .../jsonschema/v6/kind/kind.go | 651 +++++ .../santhosh-tekuri/jsonschema/v6/loader.go | 266 ++ .../jsonschema/v6/metaschemas/draft-04/schema | 151 ++ .../jsonschema/v6/metaschemas/draft-06/schema | 150 ++ .../jsonschema/v6/metaschemas/draft-07/schema | 172 ++ .../metaschemas/draft/2019-09/meta/applicator | 55 + .../v6/metaschemas/draft/2019-09/meta/content | 15 + .../v6/metaschemas/draft/2019-09/meta/core | 56 + .../v6/metaschemas/draft/2019-09/meta/format | 13 + .../metaschemas/draft/2019-09/meta/meta-data | 35 + .../metaschemas/draft/2019-09/meta/validation | 97 + .../v6/metaschemas/draft/2019-09/schema | 41 + .../metaschemas/draft/2020-12/meta/applicator | 47 + .../v6/metaschemas/draft/2020-12/meta/content | 15 + .../v6/metaschemas/draft/2020-12/meta/core | 50 + .../draft/2020-12/meta/format-annotation | 13 + .../draft/2020-12/meta/format-assertion | 13 + .../metaschemas/draft/2020-12/meta/meta-data | 35 + .../draft/2020-12/meta/unevaluated | 14 + .../metaschemas/draft/2020-12/meta/validation | 97 + .../v6/metaschemas/draft/2020-12/schema | 57 + .../jsonschema/v6/objcompiler.go | 549 ++++ .../santhosh-tekuri/jsonschema/v6/output.go | 216 ++ .../santhosh-tekuri/jsonschema/v6/position.go | 142 + .../santhosh-tekuri/jsonschema/v6/root.go | 202 ++ .../santhosh-tekuri/jsonschema/v6/roots.go | 286 ++ .../santhosh-tekuri/jsonschema/v6/schema.go | 254 ++ .../santhosh-tekuri/jsonschema/v6/util.go | 464 ++++ .../jsonschema/v6/validator.go | 975 +++++++ .../santhosh-tekuri/jsonschema/v6/vocab.go | 111 + .../x/text/feature/plural/common.go | 70 + .../x/text/feature/plural/message.go | 244 ++ .../x/text/feature/plural/plural.go | 262 ++ .../x/text/feature/plural/tables.go | 552 ++++ .../x/text/internal/catmsg/catmsg.go | 417 +++ .../x/text/internal/catmsg/codec.go | 407 +++ .../x/text/internal/catmsg/varint.go | 62 + .../x/text/internal/format/format.go | 41 + .../x/text/internal/format/parser.go | 358 +++ .../x/text/internal/number/common.go | 55 + .../x/text/internal/number/decimal.go | 500 ++++ .../x/text/internal/number/format.go | 533 ++++ .../x/text/internal/number/number.go | 152 ++ .../x/text/internal/number/pattern.go | 485 ++++ .../internal/number/roundingmode_string.go | 30 + .../x/text/internal/number/tables.go | 1219 +++++++++ .../x/text/internal/stringset/set.go | 86 + vendor/golang.org/x/text/message/catalog.go | 36 + .../x/text/message/catalog/catalog.go | 365 +++ .../golang.org/x/text/message/catalog/dict.go | 129 + .../golang.org/x/text/message/catalog/go19.go | 15 + .../x/text/message/catalog/gopre19.go | 23 + vendor/golang.org/x/text/message/doc.go | 99 + vendor/golang.org/x/text/message/format.go | 510 ++++ vendor/golang.org/x/text/message/message.go | 192 ++ vendor/golang.org/x/text/message/print.go | 984 +++++++ vendor/modules.txt | 11 + 164 files changed, 44700 insertions(+), 371 deletions(-) create mode 100644 api/queryAPI/custommetadata.go create mode 100644 api/queryAPI/custommetadata_errors_test.go create mode 100644 api/queryAPI/custommetadata_integration_test.go create mode 100644 api/queryAPI/customschemas.go create mode 100644 api/queryAPI/customschemas_bulk.go create mode 100644 api/queryAPI/customschemas_integration_test.go create mode 100644 api/queryAPI/customschemas_reset.go create mode 100644 api/queryAPI/customschemas_reset_test.go create mode 100644 api/queryAPI/customschemas_test.go create mode 100755 cmd/auth_related/permit.setup/list_roles.sh create mode 100755 cmd/auth_related/permit.setup/run.tool.dev.sh create mode 100755 cmd/auth_related/permit.setup/run.tool.prod.sh create mode 100755 cmd/auth_related/permit.setup/run.tool.uat.sh create mode 100644 docs/ai.generated/11-custom-metadata-guide.md create mode 100644 docs/ai.generated/architecture-diagram.png create mode 100644 docs/ai.generated/architecture-diagram.svg create mode 100644 internal/customschema/audit.go create mode 100644 internal/customschema/audit_test.go create mode 100644 internal/customschema/constants.go create mode 100644 internal/customschema/models.go create mode 100644 internal/customschema/models_bulk.go create mode 100644 internal/customschema/models_reset.go create mode 100644 internal/customschema/service.go create mode 100644 internal/customschema/service_metadata.go create mode 100644 internal/customschema/service_metadata_test.go create mode 100644 internal/customschema/service_reset.go create mode 100644 internal/customschema/service_reset_test.go create mode 100644 internal/customschema/service_test.go create mode 100644 internal/customschema/validator.go create mode 100644 internal/customschema/validator_test.go create mode 100644 internal/database/migrations/00000000000127_create_client_metadata_schemas.down.sql create mode 100644 internal/database/migrations/00000000000127_create_client_metadata_schemas.up.sql create mode 100644 internal/database/migrations/00000000000128_add_custom_schema_id_to_documents.down.sql create mode 100644 internal/database/migrations/00000000000128_add_custom_schema_id_to_documents.up.sql create mode 100644 internal/database/migrations/00000000000129_create_document_custom_metadata.down.sql create mode 100644 internal/database/migrations/00000000000129_create_document_custom_metadata.up.sql create mode 100644 internal/database/migrations/00000000000130_add_schema_invariant_triggers.down.sql create mode 100644 internal/database/migrations/00000000000130_add_schema_invariant_triggers.up.sql create mode 100644 internal/database/queries/custommetadata.sql create mode 100644 internal/database/queries/customschemas.sql create mode 100644 internal/database/repository/client_metadata_schemas_migration_test.go create mode 100644 internal/database/repository/custommetadata.sql.go create mode 100644 internal/database/repository/customschemas.sql.go create mode 100644 internal/database/repository/customschemas_queries_test.go create mode 100644 internal/database/repository/milestone2_migrations_test.go create mode 100644 internal/database/repository/milestone2_sqlc_queries_test.go create mode 100644 plans/metadata.progress.review.codex.md create mode 100644 plans/mutable.metadata.plan.claude.md create mode 100644 plans/mutable.metadata.plan.codex.comparison.claude.md create mode 100644 plans/mutable.metadata.plan.codex.md create mode 100644 plans/mutable.metadata.plan.combo.md create mode 100644 plans/mutable.metadata.plan.combo.v2.md create mode 100644 plans/mutable.metadata.plan.combo.v3.md create mode 100644 plans/mutable.metadata.plan.combo.v3.tracking.md create mode 100644 plans/mutable.metadata.plan.combo.v4.md create mode 100644 plans/mutable.metadata.plan.combo.v4.review.claude-response.md create mode 100644 plans/mutable.metadata.plan.combo.v4.review.coded.old.md create mode 100644 plans/mutable.metadata.plan.combo.v4.review.codex.md create mode 100644 plans/mutable.metadata.plan.combo.v4.review.findings.md create mode 100644 plans/mutable.metadata.plan.combo.v4.tracking.md create mode 100644 plans/mutable.metadata.requirments.md create mode 100644 plans/mutable.metadata.review.combo.v3.codex.md create mode 100644 plans/mutable.metadata.simplify. plan.codex.v3.md create mode 100644 plans/mutable.metadata.testing.review.codex.md create mode 100755 scripts/manual_tests/test_custom_metadata.1.sh create mode 100644 vendor/github.com/santhosh-tekuri/jsonschema/v6/.gitmodules create mode 100644 vendor/github.com/santhosh-tekuri/jsonschema/v6/.golangci.yml create mode 100644 vendor/github.com/santhosh-tekuri/jsonschema/v6/.pre-commit-hooks.yaml create mode 100644 vendor/github.com/santhosh-tekuri/jsonschema/v6/LICENSE create mode 100644 vendor/github.com/santhosh-tekuri/jsonschema/v6/README.md create mode 100644 vendor/github.com/santhosh-tekuri/jsonschema/v6/compiler.go create mode 100644 vendor/github.com/santhosh-tekuri/jsonschema/v6/content.go create mode 100644 vendor/github.com/santhosh-tekuri/jsonschema/v6/draft.go create mode 100644 vendor/github.com/santhosh-tekuri/jsonschema/v6/format.go create mode 100644 vendor/github.com/santhosh-tekuri/jsonschema/v6/go.work create mode 100644 vendor/github.com/santhosh-tekuri/jsonschema/v6/go.work.sum create mode 100644 vendor/github.com/santhosh-tekuri/jsonschema/v6/kind/kind.go create mode 100644 vendor/github.com/santhosh-tekuri/jsonschema/v6/loader.go create mode 100644 vendor/github.com/santhosh-tekuri/jsonschema/v6/metaschemas/draft-04/schema create mode 100644 vendor/github.com/santhosh-tekuri/jsonschema/v6/metaschemas/draft-06/schema create mode 100644 vendor/github.com/santhosh-tekuri/jsonschema/v6/metaschemas/draft-07/schema create mode 100644 vendor/github.com/santhosh-tekuri/jsonschema/v6/metaschemas/draft/2019-09/meta/applicator create mode 100644 vendor/github.com/santhosh-tekuri/jsonschema/v6/metaschemas/draft/2019-09/meta/content create mode 100644 vendor/github.com/santhosh-tekuri/jsonschema/v6/metaschemas/draft/2019-09/meta/core create mode 100644 vendor/github.com/santhosh-tekuri/jsonschema/v6/metaschemas/draft/2019-09/meta/format create mode 100644 vendor/github.com/santhosh-tekuri/jsonschema/v6/metaschemas/draft/2019-09/meta/meta-data create mode 100644 vendor/github.com/santhosh-tekuri/jsonschema/v6/metaschemas/draft/2019-09/meta/validation create mode 100644 vendor/github.com/santhosh-tekuri/jsonschema/v6/metaschemas/draft/2019-09/schema create mode 100644 vendor/github.com/santhosh-tekuri/jsonschema/v6/metaschemas/draft/2020-12/meta/applicator create mode 100644 vendor/github.com/santhosh-tekuri/jsonschema/v6/metaschemas/draft/2020-12/meta/content create mode 100644 vendor/github.com/santhosh-tekuri/jsonschema/v6/metaschemas/draft/2020-12/meta/core create mode 100644 vendor/github.com/santhosh-tekuri/jsonschema/v6/metaschemas/draft/2020-12/meta/format-annotation create mode 100644 vendor/github.com/santhosh-tekuri/jsonschema/v6/metaschemas/draft/2020-12/meta/format-assertion create mode 100644 vendor/github.com/santhosh-tekuri/jsonschema/v6/metaschemas/draft/2020-12/meta/meta-data create mode 100644 vendor/github.com/santhosh-tekuri/jsonschema/v6/metaschemas/draft/2020-12/meta/unevaluated create mode 100644 vendor/github.com/santhosh-tekuri/jsonschema/v6/metaschemas/draft/2020-12/meta/validation create mode 100644 vendor/github.com/santhosh-tekuri/jsonschema/v6/metaschemas/draft/2020-12/schema create mode 100644 vendor/github.com/santhosh-tekuri/jsonschema/v6/objcompiler.go create mode 100644 vendor/github.com/santhosh-tekuri/jsonschema/v6/output.go create mode 100644 vendor/github.com/santhosh-tekuri/jsonschema/v6/position.go create mode 100644 vendor/github.com/santhosh-tekuri/jsonschema/v6/root.go create mode 100644 vendor/github.com/santhosh-tekuri/jsonschema/v6/roots.go create mode 100644 vendor/github.com/santhosh-tekuri/jsonschema/v6/schema.go create mode 100644 vendor/github.com/santhosh-tekuri/jsonschema/v6/util.go create mode 100644 vendor/github.com/santhosh-tekuri/jsonschema/v6/validator.go create mode 100644 vendor/github.com/santhosh-tekuri/jsonschema/v6/vocab.go create mode 100644 vendor/golang.org/x/text/feature/plural/common.go create mode 100644 vendor/golang.org/x/text/feature/plural/message.go create mode 100644 vendor/golang.org/x/text/feature/plural/plural.go create mode 100644 vendor/golang.org/x/text/feature/plural/tables.go create mode 100644 vendor/golang.org/x/text/internal/catmsg/catmsg.go create mode 100644 vendor/golang.org/x/text/internal/catmsg/codec.go create mode 100644 vendor/golang.org/x/text/internal/catmsg/varint.go create mode 100644 vendor/golang.org/x/text/internal/format/format.go create mode 100644 vendor/golang.org/x/text/internal/format/parser.go create mode 100644 vendor/golang.org/x/text/internal/number/common.go create mode 100644 vendor/golang.org/x/text/internal/number/decimal.go create mode 100644 vendor/golang.org/x/text/internal/number/format.go create mode 100644 vendor/golang.org/x/text/internal/number/number.go create mode 100644 vendor/golang.org/x/text/internal/number/pattern.go create mode 100644 vendor/golang.org/x/text/internal/number/roundingmode_string.go create mode 100644 vendor/golang.org/x/text/internal/number/tables.go create mode 100644 vendor/golang.org/x/text/internal/stringset/set.go create mode 100644 vendor/golang.org/x/text/message/catalog.go create mode 100644 vendor/golang.org/x/text/message/catalog/catalog.go create mode 100644 vendor/golang.org/x/text/message/catalog/dict.go create mode 100644 vendor/golang.org/x/text/message/catalog/go19.go create mode 100644 vendor/golang.org/x/text/message/catalog/gopre19.go create mode 100644 vendor/golang.org/x/text/message/doc.go create mode 100644 vendor/golang.org/x/text/message/format.go create mode 100644 vendor/golang.org/x/text/message/message.go create mode 100644 vendor/golang.org/x/text/message/print.go diff --git a/api/queryAPI/api.gen.go b/api/queryAPI/api.gen.go index f726bf18..4c0c6dfb 100644 --- a/api/queryAPI/api.gen.go +++ b/api/queryAPI/api.gen.go @@ -83,6 +83,13 @@ const ( RightClosedInterval FieldFilterCondition = "right_closed_interval" ) +// Defines values for SchemaStatus. +const ( + SchemaStatusActive SchemaStatus = "active" + SchemaStatusRetired SchemaStatus = "retired" + SchemaStatusSuperseded SchemaStatus = "superseded" +) + // Defines values for ListAdminUsersParamsStatus. const ( All ListAdminUsersParamsStatus = "all" @@ -103,6 +110,14 @@ const ( Desc ListAdminUsersParamsSortOrder = "desc" ) +// Defines values for ListCustomSchemasParamsStatus. +const ( + ListCustomSchemasParamsStatusActive ListCustomSchemasParamsStatus = "active" + ListCustomSchemasParamsStatusAny ListCustomSchemasParamsStatus = "any" + ListCustomSchemasParamsStatusRetired ListCustomSchemasParamsStatus = "retired" + ListCustomSchemasParamsStatusSuperseded ListCustomSchemasParamsStatus = "superseded" +) + // AdminUserActionResponse Response for user enable/disable actions with success status and timestamp type AdminUserActionResponse struct { // Action Action performed @@ -581,6 +596,142 @@ type CollectorSet struct { MinimumCleanerVersion *CodeVersion `json:"minimum_cleaner_version,omitempty"` } +// CustomMetadataHistoryResponse Paged list of version summaries, newest first. +type CustomMetadataHistoryResponse struct { + Versions []struct { + CreatedAt time.Time `json:"createdAt"` + CreatedBy string `json:"createdBy"` + Version int32 `json:"version"` + } `json:"versions"` +} + +// CustomMetadataRequest POST /custom-metadata body. No createdBy field — the actor is derived from the JWT sub claim. +type CustomMetadataRequest struct { + DocumentId openapi_types.UUID `json:"documentId"` + + // Metadata Arbitrary JSON object that must conform to the document's bound schema_def. The byte-level cap (1 048 576) lives in the service layer constant MaxMetadataPayloadBytes. + Metadata map[string]interface{} `json:"metadata"` +} + +// CustomMetadataResponse Full metadata row decorated with the document's bound schema id, name, and version. +type CustomMetadataResponse struct { + CreatedAt time.Time `json:"createdAt"` + + // CreatedBy Cognito subject ID (JWT sub claim) of the writer. + CreatedBy string `json:"createdBy"` + DocumentId openapi_types.UUID `json:"documentId"` + Id openapi_types.UUID `json:"id"` + Metadata map[string]interface{} `json:"metadata"` + SchemaId nullable.Nullable[openapi_types.UUID] `json:"schemaId,omitempty"` + SchemaName nullable.Nullable[string] `json:"schemaName,omitempty"` + SchemaVersion nullable.Nullable[int32] `json:"schemaVersion,omitempty"` + Version int32 `json:"version"` +} + +// CustomSchemaListResponse List of custom metadata schemas for a client. Each row omits the full JSON Schema document; use GET /super-admin/custom-schemas/ {schemaId} to retrieve it. +type CustomSchemaListResponse struct { + // Schemas The schema rows matching the query filters. + Schemas []struct { + // CanDelete True when documentCount is zero. + CanDelete bool `json:"canDelete"` + + // ClientId The client that owns this schema. + ClientId string `json:"clientId"` + + // CreatedAt Timestamp when this schema row was created. + CreatedAt time.Time `json:"createdAt"` + + // CreatedBy Cognito subject ID (JWT sub claim) of the caller. Opaque UUID, not an email. + CreatedBy string `json:"createdBy"` + + // Description Optional human-readable description of the schema. + Description *string `json:"description,omitempty"` + + // DocumentCount Number of documents referencing this schema version. + DocumentCount int32 `json:"documentCount"` + + // Id The schema row ID. + Id openapi_types.UUID `json:"id"` + + // Name The schema name (shared across versions in a lineage). + Name string `json:"name"` + + // Status Lifecycle state of a client_metadata_schemas row. `active` is + // the version currently assignable to new documents. `superseded` + // is automatically set when a newer version of the same (clientId, + // name) lineage is created via POST /versions. `retired` is set + // when a super_admin explicitly deletes an unused schema. + Status SchemaStatus `json:"status"` + + // Version Monotonic version number within the lineage. + Version int32 `json:"version"` + } `json:"schemas"` +} + +// CustomSchemaRequest Request body for creating a new custom metadata schema. Does not include a createdBy field; the handler derives the actor from the authenticated JWT subject. +type CustomSchemaRequest struct { + // ClientId The client that owns this schema. + ClientId string `json:"clientId"` + + // Description Optional human-readable description of the schema. + Description *string `json:"description,omitempty"` + + // Name Unique schema name within the client lineage. + Name string `json:"name"` + + // Schema The full JSON Schema document (Draft 2020-12) used to validate custom document metadata. Must declare root type=object and must explicitly set additionalProperties. The maxProperties cap is a defense-in-depth limit against malformed schema payloads; the byte-level cap (65 536) lives in the customschema validator. + Schema map[string]interface{} `json:"schema"` +} + +// CustomSchemaResponse Full custom metadata schema row including the JSON Schema document and lifecycle metadata. +type CustomSchemaResponse struct { + // CanDelete True when documentCount is zero and the schema can be retired. + CanDelete bool `json:"canDelete"` + + // ClientId The client that owns this schema. + ClientId string `json:"clientId"` + + // CreatedAt Timestamp when this schema row was created. + CreatedAt time.Time `json:"createdAt"` + + // CreatedBy Cognito subject ID (JWT sub claim) of the caller. Opaque UUID, not an email. + CreatedBy string `json:"createdBy"` + + // Description Optional human-readable description of the schema. + Description *string `json:"description,omitempty"` + + // DocumentCount Number of documents currently referencing this schema version. + DocumentCount int32 `json:"documentCount"` + + // Id The schema row ID. + Id openapi_types.UUID `json:"id"` + + // Name The schema name (shared across versions in a lineage). + Name string `json:"name"` + + // Schema The full JSON Schema document (Draft 2020-12). + Schema map[string]interface{} `json:"schema"` + + // Status Lifecycle state of a client_metadata_schemas row. `active` is + // the version currently assignable to new documents. `superseded` + // is automatically set when a newer version of the same (clientId, + // name) lineage is created via POST /versions. `retired` is set + // when a super_admin explicitly deletes an unused schema. + Status SchemaStatus `json:"status"` + + // Version Monotonic version number within the (clientId, name) lineage. + Version int32 `json:"version"` +} + +// CustomSchemaVersionRequest Request body for creating a new version of an existing custom metadata schema. The parent schema row supplies clientId and name; the actor is derived from the authenticated JWT subject. +type CustomSchemaVersionRequest struct { + // Description Optional human-readable description of this new version. + Description *string `json:"description,omitempty"` + + // Schema The full JSON Schema document (Draft 2020-12) for the new version. Same validation rules as CustomSchemaRequest.schema. + Schema map[string]interface{} `json:"schema"` +} + // DeleteResponse Response for hard-delete operations when verbose=true type DeleteResponse struct { // DeletedDocuments S3 paths of orphaned source documents (only populated when verbose=true) @@ -610,6 +761,9 @@ type DocumentEnriched struct { // ClientId The client external id ClientId ClientID `json:"client_id"` + // CustomSchemaId Mutable-metadata feature: the client_metadata_schemas row id this document is currently bound to, or null when the document has not been opted in to the custom schema system. A document with a non-null customSchemaId rejects legacy field extraction writes. + CustomSchemaId *openapi_types.UUID `json:"customSchemaId,omitempty"` + // FileSizeBytes File size in bytes (null for legacy documents) FileSizeBytes *int64 `json:"fileSizeBytes,omitempty"` @@ -619,6 +773,9 @@ type DocumentEnriched struct { // FolderId Parent folder ID, null if document is not in a folder FolderId *openapi_types.UUID `json:"folderId,omitempty"` + // HasCustomMetadata True iff at least one document_custom_metadata row exists for this document. A document can have customSchemaId set but hasCustomMetadata false (schema bound, no metadata written yet). + HasCustomMetadata *bool `json:"hasCustomMetadata,omitempty"` + // HasTextRecord Whether a text extraction (field extraction) record exists for this document HasTextRecord bool `json:"hasTextRecord"` @@ -671,6 +828,44 @@ type DocumentInFolder struct { TextRecord *FieldExtractionResponse `json:"textRecord,omitempty"` } +// DocumentMetadataResetResponse Result of a reset-metadata operation. The previous* fields describe the document's schema binding before the reset ran so the caller can log or display what was wiped. Fields are null when the document had no schema bound at reset time (the idempotent no-op case, returned alongside metadataVersionsDeleted set to 0). +type DocumentMetadataResetResponse struct { + // DocumentId The document whose metadata was reset. + DocumentId openapi_types.UUID `json:"documentId"` + + // MetadataVersionsDeleted Number of document_custom_metadata rows that existed (and were deleted) at reset time. Zero is valid: the reset endpoint is idempotent on already-clean documents. + MetadataVersionsDeleted int32 `json:"metadataVersionsDeleted"` + + // PreviousSchemaId The document's custom_schema_id before the reset ran. Null when no schema was bound. + PreviousSchemaId nullable.Nullable[openapi_types.UUID] `json:"previousSchemaId"` + + // PreviousSchemaName Name of the previously bound schema, or null when no schema was bound. + PreviousSchemaName nullable.Nullable[string] `json:"previousSchemaName"` + + // PreviousSchemaVersion Version of the previously bound schema row, or null when no schema was bound. + PreviousSchemaVersion nullable.Nullable[int32] `json:"previousSchemaVersion"` + + // ResetAt UTC timestamp when the reset completed. + ResetAt time.Time `json:"resetAt"` + + // ResetBy Cognito subject ID (JWT sub claim) of the caller. Opaque UUID, not an email. + ResetBy string `json:"resetBy"` +} + +// DocumentSchemaAssignRequest Request body for PATCH /super-admin/documents/{id}/schema. The actor is derived from the JWT sub claim; the body has no createdBy field. A null customSchemaId clears the binding. +type DocumentSchemaAssignRequest struct { + // CustomSchemaId The client_metadata_schemas row id to bind, or null to clear. + CustomSchemaId nullable.Nullable[openapi_types.UUID] `json:"customSchemaId,omitempty"` +} + +// DocumentSchemaAssignResponse Result of a schema binding mutation. Returns the bound schema metadata (or nulls when cleared). +type DocumentSchemaAssignResponse struct { + CustomSchemaId nullable.Nullable[openapi_types.UUID] `json:"customSchemaId,omitempty"` + DocumentId openapi_types.UUID `json:"documentId"` + SchemaName nullable.Nullable[string] `json:"schemaName,omitempty"` + SchemaVersion nullable.Nullable[int32] `json:"schemaVersion,omitempty"` +} + // DocumentSummary The document summary properties. type DocumentSummary struct { // Hash The document hash @@ -1187,6 +1382,13 @@ type LabelRecord struct { // ListDocuments The documents in the client. type ListDocuments = []DocumentSummary +// SchemaStatus Lifecycle state of a client_metadata_schemas row. `active` is +// the version currently assignable to new documents. `superseded` +// is automatically set when a newer version of the same (clientId, +// name) lineage is created via POST /versions. `retired` is set +// when a super_admin explicitly deletes an unused schema. +type SchemaStatus string + // SingleFields Single-value fields for a document extraction (1:1 relationship) type SingleFields struct { // AareteDerivedAmendmentNum Amendment number derived by Aarete @@ -1430,6 +1632,33 @@ type ListClientFoldersParams struct { Metrics *bool `form:"metrics,omitempty" json:"metrics,omitempty"` } +// GetCurrentCustomMetadataParams defines parameters for GetCurrentCustomMetadata. +type GetCurrentCustomMetadataParams struct { + // DocumentId The document whose current metadata is being requested. + DocumentId openapi_types.UUID `form:"documentId" json:"documentId"` +} + +// GetCustomMetadataHistoryParams defines parameters for GetCustomMetadataHistory. +type GetCustomMetadataHistoryParams struct { + // DocumentId The document whose metadata history is being requested. + DocumentId openapi_types.UUID `form:"documentId" json:"documentId"` + + // Limit Maximum number of version summaries to return per page (1-200, default 50). + Limit *int32 `form:"limit,omitempty" json:"limit,omitempty"` + + // Offset Number of version summaries to skip before the page starts (default 0). + Offset *int32 `form:"offset,omitempty" json:"offset,omitempty"` +} + +// GetCustomMetadataByVersionParams defines parameters for GetCustomMetadataByVersion. +type GetCustomMetadataByVersionParams struct { + // DocumentId The document whose historical metadata version is being requested. + DocumentId openapi_types.UUID `form:"documentId" json:"documentId"` + + // Version The metadata version number (>= 1) to return. + Version int32 `form:"version" json:"version"` +} + // DeleteDocumentParams defines parameters for DeleteDocument. type DeleteDocumentParams struct { // Verbose When true, returns S3 paths of orphaned source documents in the response body @@ -1493,6 +1722,30 @@ type LoginCallbackParams struct { State string `form:"state" json:"state"` } +// ListCustomSchemasParams defines parameters for ListCustomSchemas. +type ListCustomSchemasParams struct { + // ClientId The client whose schemas to list. + ClientId string `form:"clientId" json:"clientId"` + + // Name Filter by schema name (exact match). + Name *string `form:"name,omitempty" json:"name,omitempty"` + + // Status Filter by lifecycle status. The sentinel `any` returns rows in all statuses. Defaults to `active` when omitted. + Status *ListCustomSchemasParamsStatus `form:"status,omitempty" json:"status,omitempty"` + + // IncludeAllVersions When false, return only the latest version per (clientId, name) lineage. When true, return every version that matches the other filters. + IncludeAllVersions *bool `form:"includeAllVersions,omitempty" json:"includeAllVersions,omitempty"` + + // Limit Maximum number of results to return. + Limit *int32 `form:"limit,omitempty" json:"limit,omitempty"` + + // Offset Number of results to skip for pagination. + Offset *int32 `form:"offset,omitempty" json:"offset,omitempty"` +} + +// ListCustomSchemasParamsStatus defines parameters for ListCustomSchemas. +type ListCustomSchemasParamsStatus string + // ListUISettingsParams defines parameters for ListUISettings. type ListUISettingsParams struct { // Namespace Filter by namespace (omit to list all). @@ -1529,6 +1782,9 @@ type UploadDocumentBatchMultipartRequestBody UploadDocumentBatchMultipartBody // TriggerExportJSONRequestBody defines body for TriggerExport for application/json ContentType. type TriggerExportJSONRequestBody = ExportTrigger +// SetCustomMetadataJSONRequestBody defines body for SetCustomMetadata for application/json ContentType. +type SetCustomMetadataJSONRequestBody = CustomMetadataRequest + // ApplyLabelJSONRequestBody defines body for ApplyLabel for application/json ContentType. type ApplyLabelJSONRequestBody = LabelApplication @@ -1541,6 +1797,15 @@ type CreateFolderJSONRequestBody = FolderCreate // RenameFolderJSONRequestBody defines body for RenameFolder for application/json ContentType. type RenameFolderJSONRequestBody = FolderRename +// CreateCustomSchemaJSONRequestBody defines body for CreateCustomSchema for application/json ContentType. +type CreateCustomSchemaJSONRequestBody = CustomSchemaRequest + +// CreateCustomSchemaVersionJSONRequestBody defines body for CreateCustomSchemaVersion for application/json ContentType. +type CreateCustomSchemaVersionJSONRequestBody = CustomSchemaVersionRequest + +// AssignDocumentSchemaJSONRequestBody defines body for AssignDocumentSchema for application/json ContentType. +type AssignDocumentSchemaJSONRequestBody = DocumentSchemaAssignRequest + // CreateUISettingJSONRequestBody defines body for CreateUISetting for application/json ContentType. type CreateUISettingJSONRequestBody = UISettingCreate @@ -1639,6 +1904,18 @@ type ServerInterface interface { // List all clients // (GET /clients) ListClients(ctx echo.Context) error + // Get the current custom metadata row for a document + // (GET /custom-metadata) + GetCurrentCustomMetadata(ctx echo.Context, params GetCurrentCustomMetadataParams) error + // Write a new custom metadata version for a document + // (POST /custom-metadata) + SetCustomMetadata(ctx echo.Context) error + // List version summaries for a document's metadata + // (GET /custom-metadata/history) + GetCustomMetadataHistory(ctx echo.Context, params GetCustomMetadataHistoryParams) error + // Get a specific historical metadata version + // (GET /custom-metadata/version) + GetCustomMetadataByVersion(ctx echo.Context, params GetCustomMetadataByVersionParams) error // Hard-delete a document // (DELETE /document/{id}) DeleteDocument(ctx echo.Context, id DocumentID, params DeleteDocumentParams) error @@ -1711,6 +1988,27 @@ type ServerInterface interface { // Logout from the application // (GET /logout) Logout(ctx echo.Context) error + // List custom metadata schemas for a client + // (GET /super-admin/custom-schemas) + ListCustomSchemas(ctx echo.Context, params ListCustomSchemasParams) error + // Create a custom metadata schema + // (POST /super-admin/custom-schemas) + CreateCustomSchema(ctx echo.Context) error + // Retire a custom metadata schema + // (DELETE /super-admin/custom-schemas/{schemaId}) + DeleteCustomSchema(ctx echo.Context, schemaId openapi_types.UUID) error + // Get a custom metadata schema by ID + // (GET /super-admin/custom-schemas/{schemaId}) + GetCustomSchema(ctx echo.Context, schemaId openapi_types.UUID) error + // Create a new version of a custom metadata schema + // (POST /super-admin/custom-schemas/{schemaId}/versions) + CreateCustomSchemaVersion(ctx echo.Context, schemaId openapi_types.UUID) error + // Reset a document's custom metadata and schema binding + // (POST /super-admin/documents/{id}/reset-metadata) + ResetDocumentMetadata(ctx echo.Context, id openapi_types.UUID) error + // Assign (or clear) a custom schema for a document + // (PATCH /super-admin/documents/{id}/schema) + AssignDocumentSchema(ctx echo.Context, id openapi_types.UUID) error // List UI settings // (GET /ui-settings) ListUISettings(ctx echo.Context, params ListUISettingsParams) error @@ -2395,6 +2693,98 @@ func (w *ServerInterfaceWrapper) ListClients(ctx echo.Context) error { return err } +// GetCurrentCustomMetadata converts echo context to params. +func (w *ServerInterfaceWrapper) GetCurrentCustomMetadata(ctx echo.Context) error { + var err error + + ctx.Set(JwtAuthScopes, []string{}) + + // Parameter object where we will unmarshal all parameters from the context + var params GetCurrentCustomMetadataParams + // ------------- Required query parameter "documentId" ------------- + + err = runtime.BindQueryParameter("form", true, true, "documentId", ctx.QueryParams(), ¶ms.DocumentId) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Invalid format for parameter documentId: %s", err)) + } + + // Invoke the callback with all the unmarshaled arguments + err = w.Handler.GetCurrentCustomMetadata(ctx, params) + return err +} + +// SetCustomMetadata converts echo context to params. +func (w *ServerInterfaceWrapper) SetCustomMetadata(ctx echo.Context) error { + var err error + + ctx.Set(JwtAuthScopes, []string{}) + + // Invoke the callback with all the unmarshaled arguments + err = w.Handler.SetCustomMetadata(ctx) + return err +} + +// GetCustomMetadataHistory converts echo context to params. +func (w *ServerInterfaceWrapper) GetCustomMetadataHistory(ctx echo.Context) error { + var err error + + ctx.Set(JwtAuthScopes, []string{}) + + // Parameter object where we will unmarshal all parameters from the context + var params GetCustomMetadataHistoryParams + // ------------- Required query parameter "documentId" ------------- + + err = runtime.BindQueryParameter("form", true, true, "documentId", ctx.QueryParams(), ¶ms.DocumentId) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Invalid format for parameter documentId: %s", err)) + } + + // ------------- Optional query parameter "limit" ------------- + + err = runtime.BindQueryParameter("form", true, false, "limit", ctx.QueryParams(), ¶ms.Limit) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Invalid format for parameter limit: %s", err)) + } + + // ------------- Optional query parameter "offset" ------------- + + err = runtime.BindQueryParameter("form", true, false, "offset", ctx.QueryParams(), ¶ms.Offset) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Invalid format for parameter offset: %s", err)) + } + + // Invoke the callback with all the unmarshaled arguments + err = w.Handler.GetCustomMetadataHistory(ctx, params) + return err +} + +// GetCustomMetadataByVersion converts echo context to params. +func (w *ServerInterfaceWrapper) GetCustomMetadataByVersion(ctx echo.Context) error { + var err error + + ctx.Set(JwtAuthScopes, []string{}) + + // Parameter object where we will unmarshal all parameters from the context + var params GetCustomMetadataByVersionParams + // ------------- Required query parameter "documentId" ------------- + + err = runtime.BindQueryParameter("form", true, true, "documentId", ctx.QueryParams(), ¶ms.DocumentId) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Invalid format for parameter documentId: %s", err)) + } + + // ------------- Required query parameter "version" ------------- + + err = runtime.BindQueryParameter("form", true, true, "version", ctx.QueryParams(), ¶ms.Version) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Invalid format for parameter version: %s", err)) + } + + // Invoke the callback with all the unmarshaled arguments + err = w.Handler.GetCustomMetadataByVersion(ctx, params) + return err +} + // DeleteDocument converts echo context to params. func (w *ServerInterfaceWrapper) DeleteDocument(ctx echo.Context) error { var err error @@ -2834,6 +3224,162 @@ func (w *ServerInterfaceWrapper) Logout(ctx echo.Context) error { return err } +// ListCustomSchemas converts echo context to params. +func (w *ServerInterfaceWrapper) ListCustomSchemas(ctx echo.Context) error { + var err error + + ctx.Set(JwtAuthScopes, []string{}) + + // Parameter object where we will unmarshal all parameters from the context + var params ListCustomSchemasParams + // ------------- Required query parameter "clientId" ------------- + + err = runtime.BindQueryParameter("form", true, true, "clientId", ctx.QueryParams(), ¶ms.ClientId) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Invalid format for parameter clientId: %s", err)) + } + + // ------------- Optional query parameter "name" ------------- + + err = runtime.BindQueryParameter("form", true, false, "name", ctx.QueryParams(), ¶ms.Name) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Invalid format for parameter name: %s", err)) + } + + // ------------- Optional query parameter "status" ------------- + + err = runtime.BindQueryParameter("form", true, false, "status", ctx.QueryParams(), ¶ms.Status) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Invalid format for parameter status: %s", err)) + } + + // ------------- Optional query parameter "includeAllVersions" ------------- + + err = runtime.BindQueryParameter("form", true, false, "includeAllVersions", ctx.QueryParams(), ¶ms.IncludeAllVersions) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Invalid format for parameter includeAllVersions: %s", err)) + } + + // ------------- Optional query parameter "limit" ------------- + + err = runtime.BindQueryParameter("form", true, false, "limit", ctx.QueryParams(), ¶ms.Limit) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Invalid format for parameter limit: %s", err)) + } + + // ------------- Optional query parameter "offset" ------------- + + err = runtime.BindQueryParameter("form", true, false, "offset", ctx.QueryParams(), ¶ms.Offset) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Invalid format for parameter offset: %s", err)) + } + + // Invoke the callback with all the unmarshaled arguments + err = w.Handler.ListCustomSchemas(ctx, params) + return err +} + +// CreateCustomSchema converts echo context to params. +func (w *ServerInterfaceWrapper) CreateCustomSchema(ctx echo.Context) error { + var err error + + ctx.Set(JwtAuthScopes, []string{}) + + // Invoke the callback with all the unmarshaled arguments + err = w.Handler.CreateCustomSchema(ctx) + return err +} + +// DeleteCustomSchema converts echo context to params. +func (w *ServerInterfaceWrapper) DeleteCustomSchema(ctx echo.Context) error { + var err error + // ------------- Path parameter "schemaId" ------------- + var schemaId openapi_types.UUID + + err = runtime.BindStyledParameterWithOptions("simple", "schemaId", ctx.Param("schemaId"), &schemaId, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true}) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Invalid format for parameter schemaId: %s", err)) + } + + ctx.Set(JwtAuthScopes, []string{}) + + // Invoke the callback with all the unmarshaled arguments + err = w.Handler.DeleteCustomSchema(ctx, schemaId) + return err +} + +// GetCustomSchema converts echo context to params. +func (w *ServerInterfaceWrapper) GetCustomSchema(ctx echo.Context) error { + var err error + // ------------- Path parameter "schemaId" ------------- + var schemaId openapi_types.UUID + + err = runtime.BindStyledParameterWithOptions("simple", "schemaId", ctx.Param("schemaId"), &schemaId, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true}) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Invalid format for parameter schemaId: %s", err)) + } + + ctx.Set(JwtAuthScopes, []string{}) + + // Invoke the callback with all the unmarshaled arguments + err = w.Handler.GetCustomSchema(ctx, schemaId) + return err +} + +// CreateCustomSchemaVersion converts echo context to params. +func (w *ServerInterfaceWrapper) CreateCustomSchemaVersion(ctx echo.Context) error { + var err error + // ------------- Path parameter "schemaId" ------------- + var schemaId openapi_types.UUID + + err = runtime.BindStyledParameterWithOptions("simple", "schemaId", ctx.Param("schemaId"), &schemaId, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true}) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Invalid format for parameter schemaId: %s", err)) + } + + ctx.Set(JwtAuthScopes, []string{}) + + // Invoke the callback with all the unmarshaled arguments + err = w.Handler.CreateCustomSchemaVersion(ctx, schemaId) + return err +} + +// ResetDocumentMetadata converts echo context to params. +func (w *ServerInterfaceWrapper) ResetDocumentMetadata(ctx echo.Context) error { + var err error + // ------------- Path parameter "id" ------------- + var id openapi_types.UUID + + err = runtime.BindStyledParameterWithOptions("simple", "id", ctx.Param("id"), &id, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true}) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Invalid format for parameter id: %s", err)) + } + + ctx.Set(JwtAuthScopes, []string{}) + + // Invoke the callback with all the unmarshaled arguments + err = w.Handler.ResetDocumentMetadata(ctx, id) + return err +} + +// AssignDocumentSchema converts echo context to params. +func (w *ServerInterfaceWrapper) AssignDocumentSchema(ctx echo.Context) error { + var err error + // ------------- Path parameter "id" ------------- + var id openapi_types.UUID + + err = runtime.BindStyledParameterWithOptions("simple", "id", ctx.Param("id"), &id, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true}) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Invalid format for parameter id: %s", err)) + } + + ctx.Set(JwtAuthScopes, []string{}) + + // Invoke the callback with all the unmarshaled arguments + err = w.Handler.AssignDocumentSchema(ctx, id) + return err +} + // ListUISettings converts echo context to params. func (w *ServerInterfaceWrapper) ListUISettings(ctx echo.Context) error { var err error @@ -2977,6 +3523,10 @@ func RegisterHandlersWithBaseURL(router EchoRouter, si ServerInterface, baseURL router.GET(baseURL+"/client/:id/folders", wrapper.ListClientFolders) router.GET(baseURL+"/client/:id/status", wrapper.GetStatusByClientId) router.GET(baseURL+"/clients", wrapper.ListClients) + router.GET(baseURL+"/custom-metadata", wrapper.GetCurrentCustomMetadata) + router.POST(baseURL+"/custom-metadata", wrapper.SetCustomMetadata) + router.GET(baseURL+"/custom-metadata/history", wrapper.GetCustomMetadataHistory) + router.GET(baseURL+"/custom-metadata/version", wrapper.GetCustomMetadataByVersion) router.DELETE(baseURL+"/document/:id", wrapper.DeleteDocument) router.GET(baseURL+"/document/:id", wrapper.GetDocument) router.GET(baseURL+"/document/:id/download", wrapper.DownloadDocument) @@ -3001,6 +3551,13 @@ func RegisterHandlersWithBaseURL(router EchoRouter, si ServerInterface, baseURL router.GET(baseURL+"/login", wrapper.Login) router.GET(baseURL+"/login-callback", wrapper.LoginCallback) router.GET(baseURL+"/logout", wrapper.Logout) + router.GET(baseURL+"/super-admin/custom-schemas", wrapper.ListCustomSchemas) + router.POST(baseURL+"/super-admin/custom-schemas", wrapper.CreateCustomSchema) + router.DELETE(baseURL+"/super-admin/custom-schemas/:schemaId", wrapper.DeleteCustomSchema) + router.GET(baseURL+"/super-admin/custom-schemas/:schemaId", wrapper.GetCustomSchema) + router.POST(baseURL+"/super-admin/custom-schemas/:schemaId/versions", wrapper.CreateCustomSchemaVersion) + router.POST(baseURL+"/super-admin/documents/:id/reset-metadata", wrapper.ResetDocumentMetadata) + router.PATCH(baseURL+"/super-admin/documents/:id/schema", wrapper.AssignDocumentSchema) router.GET(baseURL+"/ui-settings", wrapper.ListUISettings) router.POST(baseURL+"/ui-settings", wrapper.CreateUISetting) router.DELETE(baseURL+"/ui-settings/:id", wrapper.DeleteUISetting) @@ -3012,307 +3569,377 @@ func RegisterHandlersWithBaseURL(router EchoRouter, si ServerInterface, baseURL // Base64 encoded, gzipped, json marshaled Swagger object var swaggerSpec = []string{ - "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", + "N425zdQxSvY+PN3K95//dwAAAP//4pzDb8dIAgA=", } // GetSwagger returns the content of the embedded swagger specification file diff --git a/api/queryAPI/controllers.go b/api/queryAPI/controllers.go index 7ef5b9a8..3e19d099 100644 --- a/api/queryAPI/controllers.go +++ b/api/queryAPI/controllers.go @@ -6,6 +6,7 @@ import ( clientupdate "queryorchestration/internal/client/update" "queryorchestration/internal/collector" collectorset "queryorchestration/internal/collector/set" + "queryorchestration/internal/customschema" "queryorchestration/internal/document" documentbatch "queryorchestration/internal/document/batch" documentdownload "queryorchestration/internal/document/download" @@ -36,6 +37,7 @@ type Services struct { DocumentBatch *documentbatch.Service UISettings *uisettings.Service FieldExtraction *fieldextraction.Service + CustomSchema *customschema.Service Folder *folder.Service Label *label.Service Eula *eula.Service diff --git a/api/queryAPI/custommetadata.go b/api/queryAPI/custommetadata.go new file mode 100644 index 00000000..6c598196 --- /dev/null +++ b/api/queryAPI/custommetadata.go @@ -0,0 +1,264 @@ +// Package queryapi: handler implementations for the CustomMetadataService +// routes (POST /custom-metadata, GET /custom-metadata, +// GET /custom-metadata/version, GET /custom-metadata/history) plus the +// super-admin PATCH /super-admin/documents/{id}/schema route. +// +// Every mutating handler derives the actor from the JWT sub claim via +// cognitoauth.GetUserSubject and never reads a createdBy field from the +// request body — the generated request types do not even declare one. +package queryapi + +import ( + "encoding/json" + "errors" + "log/slog" + "net/http" + "strings" + "time" + + "queryorchestration/internal/cognitoauth" + "queryorchestration/internal/customschema" + + "github.com/google/uuid" + "github.com/labstack/echo/v4" + "github.com/oapi-codegen/nullable" + openapi_types "github.com/oapi-codegen/runtime/types" +) + +// AssignDocumentSchema is PATCH /super-admin/documents/{id}/schema. The +// customSchemaId field may be null (clear the binding) or a UUID (bind). +func (s *Controllers) AssignDocumentSchema(ctx echo.Context, id openapi_types.UUID) error { + actor, ok := cognitoauth.GetUserSubject(ctx) + if !ok { + return echo.NewHTTPError(http.StatusUnauthorized, "missing user subject") + } + + var req DocumentSchemaAssignRequest + if err := ctx.Bind(&req); err != nil { + slog.Error("failed to parse assign schema request", "error", err) + return echo.NewHTTPError(http.StatusBadRequest, "invalid request body") + } + + // CustomSchemaId is a Nullable[UUID]: the caller can omit it + // (unspecified), explicitly set it to null (clear the binding), or + // provide a UUID (bind). Unspecified is treated as a 400 because the + // admin must make an explicit choice. + var schemaID *uuid.UUID + switch { + case req.CustomSchemaId.IsSpecified() && !req.CustomSchemaId.IsNull(): + sid, err := req.CustomSchemaId.Get() + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, "invalid customSchemaId") + } + u := uuid.UUID(sid) + schemaID = &u + case req.CustomSchemaId.IsSpecified() && req.CustomSchemaId.IsNull(): + // Explicit null → clear binding. schemaID stays nil. + default: + return echo.NewHTTPError(http.StatusBadRequest, "customSchemaId is required (use null to clear)") + } + + result, err := s.svc.CustomSchema.AssignSchema(ctx.Request().Context(), uuid.UUID(id), schemaID, actor) + if err != nil { + return mapCustomMetadataError(err) + } + + resp := DocumentSchemaAssignResponse{ + DocumentId: openapi_types.UUID(result.DocumentID), + } + if result.CustomSchemaID != nil { + resp.CustomSchemaId = nullable.NewNullableWithValue(openapi_types.UUID(*result.CustomSchemaID)) + } else { + resp.CustomSchemaId = nullable.NewNullNullable[openapi_types.UUID]() + } + if result.SchemaName != nil { + resp.SchemaName = nullable.NewNullableWithValue(*result.SchemaName) + } else { + resp.SchemaName = nullable.NewNullNullable[string]() + } + if result.SchemaVersion != nil { + v := int32(*result.SchemaVersion) //nolint:gosec // per-lineage monotonic counter + resp.SchemaVersion = nullable.NewNullableWithValue(v) + } else { + resp.SchemaVersion = nullable.NewNullNullable[int32]() + } + return ctx.JSON(http.StatusOK, resp) +} + +// SetCustomMetadata is POST /custom-metadata. +func (s *Controllers) SetCustomMetadata(ctx echo.Context) error { + actor, ok := cognitoauth.GetUserSubject(ctx) + if !ok { + return echo.NewHTTPError(http.StatusUnauthorized, "missing user subject") + } + + var req CustomMetadataRequest + if err := ctx.Bind(&req); err != nil { + slog.Error("failed to parse custom metadata request", "error", err) + return echo.NewHTTPError(http.StatusBadRequest, "invalid request body") + } + + metadataBytes, err := json.Marshal(req.Metadata) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, "invalid metadata JSON") + } + + input := customschema.SetMetadataInput{ + DocumentID: uuid.UUID(req.DocumentId), + Metadata: metadataBytes, + } + got, err := s.svc.CustomSchema.SetDocumentMetadata(ctx.Request().Context(), input, actor) + if err != nil { + return mapCustomMetadataError(err) + } + resp, err := metadataToResponse(got) + if err != nil { + return echo.NewHTTPError(http.StatusInternalServerError, "failed to encode metadata response") + } + return ctx.JSON(http.StatusCreated, resp) +} + +// GetCurrentCustomMetadata is GET /custom-metadata?documentId=.... +func (s *Controllers) GetCurrentCustomMetadata(ctx echo.Context, params GetCurrentCustomMetadataParams) error { + if _, ok := cognitoauth.GetUserSubject(ctx); !ok { + return echo.NewHTTPError(http.StatusUnauthorized, "missing user subject") + } + got, err := s.svc.CustomSchema.GetCurrentMetadata(ctx.Request().Context(), uuid.UUID(params.DocumentId)) + if err != nil { + return mapCustomMetadataError(err) + } + resp, err := metadataToResponse(got) + if err != nil { + return echo.NewHTTPError(http.StatusInternalServerError, "failed to encode metadata response") + } + return ctx.JSON(http.StatusOK, resp) +} + +// GetCustomMetadataByVersion is GET /custom-metadata/version?documentId=...&version=N. +func (s *Controllers) GetCustomMetadataByVersion(ctx echo.Context, params GetCustomMetadataByVersionParams) error { + if _, ok := cognitoauth.GetUserSubject(ctx); !ok { + return echo.NewHTTPError(http.StatusUnauthorized, "missing user subject") + } + got, err := s.svc.CustomSchema.GetMetadataByVersion(ctx.Request().Context(), uuid.UUID(params.DocumentId), int(params.Version)) + if err != nil { + return mapCustomMetadataError(err) + } + resp, err := metadataToResponse(got) + if err != nil { + return echo.NewHTTPError(http.StatusInternalServerError, "failed to encode metadata response") + } + return ctx.JSON(http.StatusOK, resp) +} + +// customMetadataHistoryRow is a type alias onto the anonymous element +// type inside the generated CustomMetadataHistoryResponse.Versions slice. +// Structural identity with the generated type lets us append directly. +type customMetadataHistoryRow = struct { + CreatedAt time.Time `json:"createdAt"` + CreatedBy string `json:"createdBy"` + Version int32 `json:"version"` +} + +// GetCustomMetadataHistory is GET /custom-metadata/history?documentId=...&limit=N&offset=M. +func (s *Controllers) GetCustomMetadataHistory(ctx echo.Context, params GetCustomMetadataHistoryParams) error { + if _, ok := cognitoauth.GetUserSubject(ctx); !ok { + return echo.NewHTTPError(http.StatusUnauthorized, "missing user subject") + } + limit := 50 + if params.Limit != nil { + limit = int(*params.Limit) + } + offset := 0 + if params.Offset != nil { + offset = int(*params.Offset) + } + got, err := s.svc.CustomSchema.GetMetadataHistory(ctx.Request().Context(), uuid.UUID(params.DocumentId), limit, offset) + if err != nil { + return mapCustomMetadataError(err) + } + out := CustomMetadataHistoryResponse{ + Versions: make([]customMetadataHistoryRow, 0, len(got)), + } + for _, v := range got { + out.Versions = append(out.Versions, customMetadataHistoryRow{ + Version: int32(v.Version), //nolint:gosec // metadata version is a per-doc monotonic counter, far below int32 ceiling + CreatedAt: v.CreatedAt, + CreatedBy: v.CreatedBy, + }) + } + return ctx.JSON(http.StatusOK, out) +} + +// mapCustomMetadataError translates a customschema.Service error coming out +// of the metadata/assign-schema paths into an echo HTTP error with the +// correct status code. +func mapCustomMetadataError(err error) error { + switch { + case errors.Is(err, customschema.ErrDocumentNotFound): + return echo.NewHTTPError(http.StatusNotFound, "document not found") + case errors.Is(err, customschema.ErrMetadataVersionNotFound): + return echo.NewHTTPError(http.StatusNotFound, "metadata version not found") + case errors.Is(err, customschema.ErrDocumentNotBoundToSchema): + return echo.NewHTTPError(http.StatusBadRequest, "document is not bound to a custom schema") + case errors.Is(err, customschema.ErrMetadataPayloadTooLarge): + return echo.NewHTTPError(http.StatusBadRequest, "metadata payload exceeds maximum size") + case errors.Is(err, customschema.ErrSchemaNotFound): + return echo.NewHTTPError(http.StatusNotFound, "schema not found") + case errors.Is(err, customschema.ErrSchemaNotActive): + return echo.NewHTTPError(http.StatusConflict, "schema is not active") + case errors.Is(err, customschema.ErrSchemaClientMismatch): + return echo.NewHTTPError(http.StatusConflict, "schema and document belong to different clients") + case errors.Is(err, customschema.ErrDocumentHasCustomMetadata): + return echo.NewHTTPError(http.StatusConflict, "document already has custom metadata") + case errors.Is(err, customschema.ErrDocumentHasLegacyExtractions): + return echo.NewHTTPError(http.StatusConflict, "document has legacy field extractions") + } + // Validator failures wrap "validate metadata". The history service + // method surfaces limit/offset errors as plain strings. + msg := err.Error() + if strings.Contains(msg, "validate metadata") || + strings.Contains(msg, "limit must be") || + strings.Contains(msg, "offset must be") || + strings.Contains(msg, "document id is required") || + strings.Contains(msg, "version must be") { + return echo.NewHTTPError(http.StatusBadRequest, msg) + } + slog.Error("custom metadata handler internal error", "error", err) + return echo.NewHTTPError(http.StatusInternalServerError, "internal server error") +} + +// metadataToResponse copies a customschema.CustomMetadata into the +// generated CustomMetadataResponse. +func metadataToResponse(m *customschema.CustomMetadata) (CustomMetadataResponse, error) { + var metaMap map[string]interface{} + if len(m.Metadata) > 0 { + if err := json.Unmarshal(m.Metadata, &metaMap); err != nil { + return CustomMetadataResponse{}, err + } + } + resp := CustomMetadataResponse{ + Id: openapi_types.UUID(m.ID), + DocumentId: openapi_types.UUID(m.DocumentID), + Metadata: metaMap, + Version: int32(m.Version), //nolint:gosec // per-doc monotonic counter + CreatedAt: m.CreatedAt, + CreatedBy: m.CreatedBy, + } + if m.SchemaID != nil { + resp.SchemaId = nullable.NewNullableWithValue(openapi_types.UUID(*m.SchemaID)) + } else { + resp.SchemaId = nullable.NewNullNullable[openapi_types.UUID]() + } + if m.SchemaName != nil { + resp.SchemaName = nullable.NewNullableWithValue(*m.SchemaName) + } else { + resp.SchemaName = nullable.NewNullNullable[string]() + } + if m.SchemaVersion != nil { + v := int32(*m.SchemaVersion) //nolint:gosec // per-lineage monotonic counter + resp.SchemaVersion = nullable.NewNullableWithValue(v) + } else { + resp.SchemaVersion = nullable.NewNullNullable[int32]() + } + return resp, nil +} diff --git a/api/queryAPI/custommetadata_errors_test.go b/api/queryAPI/custommetadata_errors_test.go new file mode 100644 index 00000000..8dd02d57 --- /dev/null +++ b/api/queryAPI/custommetadata_errors_test.go @@ -0,0 +1,150 @@ +// Package queryapi — white-box unit tests for mapCustomMetadataError. +// These tests live in package queryapi (not package queryapi_test) to access +// the unexported function directly without requiring a real DB or echo context. +package queryapi + +import ( + "errors" + "fmt" + "net/http" + "testing" + + "queryorchestration/internal/customschema" + + "github.com/labstack/echo/v4" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func httpCode(t *testing.T, err error) int { + t.Helper() + var httpErr *echo.HTTPError + require.True(t, errors.As(err, &httpErr), "expected echo.HTTPError, got %T: %v", err, err) + return httpErr.Code +} + +func TestMapCustomMetadataError(t *testing.T) { + cases := []struct { + name string + input error + wantCode int + }{ + { + name: "document_not_found", + input: customschema.ErrDocumentNotFound, + wantCode: http.StatusNotFound, + }, + { + name: "metadata_version_not_found", + input: customschema.ErrMetadataVersionNotFound, + wantCode: http.StatusNotFound, + }, + { + name: "not_bound_to_schema", + input: customschema.ErrDocumentNotBoundToSchema, + wantCode: http.StatusBadRequest, + }, + { + name: "payload_too_large", + input: customschema.ErrMetadataPayloadTooLarge, + wantCode: http.StatusBadRequest, + }, + { + name: "schema_not_found", + input: customschema.ErrSchemaNotFound, + wantCode: http.StatusNotFound, + }, + { + name: "schema_not_active", + input: customschema.ErrSchemaNotActive, + wantCode: http.StatusConflict, + }, + { + name: "schema_client_mismatch", + input: customschema.ErrSchemaClientMismatch, + wantCode: http.StatusConflict, + }, + { + name: "has_custom_metadata", + input: customschema.ErrDocumentHasCustomMetadata, + wantCode: http.StatusConflict, + }, + { + name: "has_legacy_extractions", + input: customschema.ErrDocumentHasLegacyExtractions, + wantCode: http.StatusConflict, + }, + { + name: "validate_metadata_string", + input: fmt.Errorf("customschema: validate metadata: payload error"), + wantCode: http.StatusBadRequest, + }, + { + name: "limit_must_be_string", + input: fmt.Errorf("customschema: limit must be between 1 and 200"), + wantCode: http.StatusBadRequest, + }, + { + name: "offset_must_be_string", + input: fmt.Errorf("customschema: offset must be >= 0"), + wantCode: http.StatusBadRequest, + }, + { + name: "document_id_required_string", + input: fmt.Errorf("customschema: document id is required"), + wantCode: http.StatusBadRequest, + }, + { + name: "version_must_be_string", + input: fmt.Errorf("customschema: version must be >= 1"), + wantCode: http.StatusBadRequest, + }, + { + name: "unknown_error_returns_500", + input: fmt.Errorf("unexpected internal error"), + wantCode: http.StatusInternalServerError, + }, + } + + for _, tc := range cases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + got := mapCustomMetadataError(tc.input) + assert.Equal(t, tc.wantCode, httpCode(t, got), "wrong status for %v", tc.input) + }) + } +} + +// TestMapResetError covers all three branches of mapResetError declared in +// customschemas_reset.go. The function is unexported so the test lives in the +// same package (package queryapi, not package queryapi_test). +func TestMapResetError(t *testing.T) { + cases := []struct { + name string + input error + wantCode int + }{ + { + name: "document_not_found", + input: customschema.ErrDocumentNotFound, + wantCode: http.StatusNotFound, + }, + { + name: "mutual_exclusivity_violation", + input: customschema.ErrMutualExclusivityViolation, + wantCode: http.StatusConflict, + }, + { + name: "unknown_error_returns_500", + input: fmt.Errorf("unexpected reset failure"), + wantCode: http.StatusInternalServerError, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := mapResetError(tc.input) + assert.Equal(t, tc.wantCode, httpCode(t, got), "wrong status for %v", tc.input) + }) + } +} diff --git a/api/queryAPI/custommetadata_integration_test.go b/api/queryAPI/custommetadata_integration_test.go new file mode 100644 index 00000000..94cdc63d --- /dev/null +++ b/api/queryAPI/custommetadata_integration_test.go @@ -0,0 +1,489 @@ +// Package queryapi_test — Milestone 2 §2.8 end-to-end integration tests. +// +// These tests compose the Milestone 2 HTTP handlers, the customschema +// service, the fieldextraction service, and the real Postgres testcontainer +// produced by internal/test.CreateDB to prove the mutual-exclusivity +// invariant, the full happy-path round-trip, and the edge cases the plan +// calls out in §2.8. +// +// No mocks. Every request goes through a real handler receiver. The +// helpers below reuse the Milestone 1 testutils (newCustomSchemaContext, +// seedCustomSchema, resetClientForSchemaTests) so the two files share the +// same fixture shape. +package queryapi_test + +import ( + "bytes" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "testing" + + queryapi "queryorchestration/api/queryAPI" + "queryorchestration/internal/cognitoauth" + "queryorchestration/internal/database/repository" + "queryorchestration/internal/fieldextraction" + "queryorchestration/internal/test" + + "github.com/google/uuid" + "github.com/labstack/echo/v4" + "github.com/oapi-codegen/nullable" + openapi_types "github.com/oapi-codegen/runtime/types" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// newCustomMetadataContext is the Milestone 2 sibling of +// newCustomSchemaContext — it exists only so the tests in this file can +// read clearly at a glance; the implementation is identical. +func newCustomMetadataContext(t testing.TB, method string, body interface{}) (echo.Context, *httptest.ResponseRecorder) { + t.Helper() + e := echo.New() + var reqBody []byte + if body != nil { + var err error + reqBody, err = json.Marshal(body) + require.NoError(t, err) + } + req := httptest.NewRequest(method, "/", bytes.NewReader(reqBody)) + if reqBody != nil { + req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON) + } + rec := httptest.NewRecorder() + ctx := e.NewContext(req, rec) + ctx.Set("user_claims", map[string]interface{}{"sub": testActorSubject}) + ctx.Set("user_info", cognitoauth.UserInfo{Username: "admin"}) + return ctx, rec +} + +// setupM2Fixture boots a fresh DB, wipes any rows belonging to clientID, +// creates the client and returns the controllers. Mirrors the Milestone 1 +// pattern but in one helper so the §2.8 tests stay short. +func setupM2Fixture(t *testing.T, clientID string) (*queryapi.Controllers, *ControllerConfig) { + t.Helper() + cfg := &ControllerConfig{} + test.CreateDB(t, cfg) + initializeTestConfig(t, cfg) + svc := createControllerServices(cfg) + cons := queryapi.NewControllers(svc, cfg) + + resetClientForSchemaTests(t, cfg, clientID) + test.CreateTestClient(t, cfg, clientID, "M2 integration "+clientID) + return cons, cfg +} + +// seedDoc creates a document for clientID with a unique hash. +func seedDoc(t *testing.T, cfg *ControllerConfig, clientID, hash string) uuid.UUID { + t.Helper() + id, err := cfg.GetDBQueries().CreateDocument(t.Context(), &repository.CreateDocumentParams{ + Clientid: clientID, + Hash: hash, + }) + require.NoError(t, err) + return id +} + +// assignSchemaForTest wraps AssignDocumentSchema against a freshly built +// echo context so the §2.8 tests can bind a document in one line. +func assignSchemaForTest(t *testing.T, cons *queryapi.Controllers, docID, schemaID uuid.UUID) { + t.Helper() + body := queryapi.DocumentSchemaAssignRequest{ + CustomSchemaId: nullable.NewNullableWithValue(openapi_types.UUID(schemaID)), + } + ctx, rec := newCustomMetadataContext(t, http.MethodPatch, body) + require.NoError(t, cons.AssignDocumentSchema(ctx, openapi_types.UUID(docID))) + require.Equal(t, http.StatusOK, rec.Code, "assign must return 200") +} + +// ---------- end-to-end happy round-trip ---------- + +// TestM2_HappyRoundTrip covers the §2.8 full round-trip: +// create schema → assign to document → POST valid metadata → GET returns +// it with populated schemaId / schemaName / schemaVersion / createdBy. +func TestM2_HappyRoundTrip(t *testing.T) { + if testing.Short() { + t.SkipNow() + } + const clientID = "M2_HAPPY" + cons, cfg := setupM2Fixture(t, clientID) + + schema := seedCustomSchema(t, cons, clientID, "m2-happy") + docID := seedDoc(t, cfg, clientID, "m2-happy-doc") + assignSchemaForTest(t, cons, docID, uuid.UUID(schema.Id)) + + // POST valid metadata. + postBody := queryapi.CustomMetadataRequest{ + DocumentId: openapi_types.UUID(docID), + Metadata: map[string]interface{}{"name": "contract-42"}, + } + ctxPost, recPost := newCustomMetadataContext(t, http.MethodPost, postBody) + require.NoError(t, cons.SetCustomMetadata(ctxPost)) + require.Equal(t, http.StatusCreated, recPost.Code) + + var posted queryapi.CustomMetadataResponse + require.NoError(t, json.Unmarshal(recPost.Body.Bytes(), &posted)) + assert.Equal(t, int32(1), posted.Version) + assert.Equal(t, testActorSubject, posted.CreatedBy) + + // GET /custom-metadata?documentId=... + ctxGet, recGet := newCustomMetadataContext(t, http.MethodGet, nil) + err := cons.GetCurrentCustomMetadata(ctxGet, queryapi.GetCurrentCustomMetadataParams{ + DocumentId: openapi_types.UUID(docID), + }) + require.NoError(t, err) + require.Equal(t, http.StatusOK, recGet.Code) + + var got queryapi.CustomMetadataResponse + require.NoError(t, json.Unmarshal(recGet.Body.Bytes(), &got)) + assert.Equal(t, posted.Version, got.Version) + assert.Equal(t, posted.Id, got.Id) + require.True(t, got.SchemaId.IsSpecified() && !got.SchemaId.IsNull()) + sid, _ := got.SchemaId.Get() + assert.Equal(t, schema.Id, sid) + schemaName, _ := got.SchemaName.Get() + assert.Equal(t, "m2-happy", schemaName) + schemaVersion, _ := got.SchemaVersion.Get() + assert.Equal(t, int32(1), schemaVersion) +} + +// TestM2_PostWithoutSchemaReturns400 proves POST /custom-metadata refuses +// writes to documents that have no schema binding. +func TestM2_PostWithoutSchemaReturns400(t *testing.T) { + if testing.Short() { + t.SkipNow() + } + const clientID = "M2_NOSCH" + cons, cfg := setupM2Fixture(t, clientID) + + docID := seedDoc(t, cfg, clientID, "m2-nosch-doc") + + postBody := queryapi.CustomMetadataRequest{ + DocumentId: openapi_types.UUID(docID), + Metadata: map[string]interface{}{"name": "nope"}, + } + ctxPost, recPost := newCustomMetadataContext(t, http.MethodPost, postBody) + err := cons.SetCustomMetadata(ctxPost) + // The handler propagates echo.HTTPError for mapped sentinels; the + // real HTTP server flushes this as a response. For the handler unit + // test we inspect the error directly. + requireHTTPStatus(t, err, recPost, http.StatusBadRequest) +} + +// TestM2_LegacyWriteOnCustomDoc covers the §2.8 mutual-exclusivity end: +// schema bound → legacy field extraction write → 409. +func TestM2_LegacyWriteOnCustomDoc(t *testing.T) { + if testing.Short() { + t.SkipNow() + } + const clientID = "M2_LEGACY" + cons, cfg := setupM2Fixture(t, clientID) + + schema := seedCustomSchema(t, cons, clientID, "m2-legacy") + docID := seedDoc(t, cfg, clientID, "m2-legacy-doc") + assignSchemaForTest(t, cons, docID, uuid.UUID(schema.Id)) + + // Attempt legacy write via the fieldextraction service directly + // (the REST handler would do the same mapping — we test service + + // handler mapping separately in the fieldextraction package). + svc := createControllerServices(cfg) + _, err := svc.FieldExtraction.CreateFieldExtraction(t.Context(), &fieldextraction.CreateFieldExtractionInput{ + DocumentID: docID, + SingleFields: &repository.AddFieldExtractionParams{ + Documentid: docID, + Createdby: "tester", + }, + ArrayFields: nil, + }) + require.Error(t, err) + assert.ErrorIs(t, err, fieldextraction.ErrMutualExclusivityViolation) +} + +// TestM2_GetByVersion covers §2.8 GET /custom-metadata/version happy +// path: post 3 versions, retrieve v2 exactly. +func TestM2_GetByVersion(t *testing.T) { + if testing.Short() { + t.SkipNow() + } + const clientID = "M2_VERSION" + cons, cfg := setupM2Fixture(t, clientID) + + schema := seedCustomSchema(t, cons, clientID, "m2-version") + docID := seedDoc(t, cfg, clientID, "m2-version-doc") + assignSchemaForTest(t, cons, docID, uuid.UUID(schema.Id)) + + for _, payload := range []string{`{"name":"v1"}`, `{"name":"v2"}`, `{"name":"v3"}`} { + var m map[string]interface{} + require.NoError(t, json.Unmarshal([]byte(payload), &m)) + body := queryapi.CustomMetadataRequest{ + DocumentId: openapi_types.UUID(docID), + Metadata: m, + } + ctx, rec := newCustomMetadataContext(t, http.MethodPost, body) + require.NoError(t, cons.SetCustomMetadata(ctx)) + require.Equal(t, http.StatusCreated, rec.Code) + } + + ctxVer, recVer := newCustomMetadataContext(t, http.MethodGet, nil) + err := cons.GetCustomMetadataByVersion(ctxVer, queryapi.GetCustomMetadataByVersionParams{ + DocumentId: openapi_types.UUID(docID), + Version: 2, + }) + require.NoError(t, err) + require.Equal(t, http.StatusOK, recVer.Code) + + var got queryapi.CustomMetadataResponse + require.NoError(t, json.Unmarshal(recVer.Body.Bytes(), &got)) + assert.Equal(t, int32(2), got.Version) + // metadata must be the v2 payload, not v1 or v3 + v, ok := got.Metadata["name"] + require.True(t, ok) + assert.Equal(t, "v2", v) +} + +// TestM2_History covers the §2.8 history GET happy path with pagination. +func TestM2_History(t *testing.T) { + if testing.Short() { + t.SkipNow() + } + const clientID = "M2_HIST" + cons, cfg := setupM2Fixture(t, clientID) + + schema := seedCustomSchema(t, cons, clientID, "m2-hist") + docID := seedDoc(t, cfg, clientID, "m2-hist-doc") + assignSchemaForTest(t, cons, docID, uuid.UUID(schema.Id)) + + for i := 0; i < 5; i++ { + body := queryapi.CustomMetadataRequest{ + DocumentId: openapi_types.UUID(docID), + Metadata: map[string]interface{}{"name": "x"}, + } + ctx, rec := newCustomMetadataContext(t, http.MethodPost, body) + require.NoError(t, cons.SetCustomMetadata(ctx)) + require.Equal(t, http.StatusCreated, rec.Code) + } + + limit := int32(2) + offset := int32(0) + ctx1, rec1 := newCustomMetadataContext(t, http.MethodGet, nil) + require.NoError(t, cons.GetCustomMetadataHistory(ctx1, queryapi.GetCustomMetadataHistoryParams{ + DocumentId: openapi_types.UUID(docID), + Limit: &limit, + Offset: &offset, + })) + require.Equal(t, http.StatusOK, rec1.Code) + var page1 queryapi.CustomMetadataHistoryResponse + require.NoError(t, json.Unmarshal(rec1.Body.Bytes(), &page1)) + require.Len(t, page1.Versions, 2) + assert.Equal(t, int32(5), page1.Versions[0].Version) + assert.Equal(t, int32(4), page1.Versions[1].Version) + + offset2 := int32(2) + ctx2, rec2 := newCustomMetadataContext(t, http.MethodGet, nil) + require.NoError(t, cons.GetCustomMetadataHistory(ctx2, queryapi.GetCustomMetadataHistoryParams{ + DocumentId: openapi_types.UUID(docID), + Limit: &limit, + Offset: &offset2, + })) + var page2 queryapi.CustomMetadataHistoryResponse + require.NoError(t, json.Unmarshal(rec2.Body.Bytes(), &page2)) + require.Len(t, page2.Versions, 2) + assert.Equal(t, int32(3), page2.Versions[0].Version) + assert.Equal(t, int32(2), page2.Versions[1].Version) +} + +// TestM2_AssignToDocWithLegacyExtractionsReturns409 covers the reverse +// direction of the mutual-exclusivity invariant: a doc with legacy +// extractions cannot be bound to a schema. +func TestM2_AssignToDocWithLegacyExtractionsReturns409(t *testing.T) { + if testing.Short() { + t.SkipNow() + } + const clientID = "M2_LEGACYASSIGN" + cons, cfg := setupM2Fixture(t, clientID) + + // Create a document and seed a legacy extraction via the service. + svc := createControllerServices(cfg) + docID := seedDoc(t, cfg, clientID, "m2-legacy-assign-doc") + _, err := svc.FieldExtraction.CreateFieldExtraction(t.Context(), &fieldextraction.CreateFieldExtractionInput{ + DocumentID: docID, + SingleFields: &repository.AddFieldExtractionParams{ + Documentid: docID, + Createdby: "tester", + }, + ArrayFields: nil, + }) + require.NoError(t, err) + + schema := seedCustomSchema(t, cons, clientID, "m2-legacy-assign") + + // Attempt to bind — must 409. + body := queryapi.DocumentSchemaAssignRequest{ + CustomSchemaId: nullable.NewNullableWithValue(schema.Id), + } + ctx, rec := newCustomMetadataContext(t, http.MethodPatch, body) + err = cons.AssignDocumentSchema(ctx, openapi_types.UUID(docID)) + requireHTTPStatus(t, err, rec, http.StatusConflict) +} + +// TestM2_FKCascadeDeletesMetadata proves the FK cascade from documents +// removes document_custom_metadata rows on document delete, covering the +// §2.8 "FK-cascade delete" row. +func TestM2_FKCascadeDeletesMetadata(t *testing.T) { + if testing.Short() { + t.SkipNow() + } + const clientID = "M2_FKCASCADE" + cons, cfg := setupM2Fixture(t, clientID) + + schema := seedCustomSchema(t, cons, clientID, "m2-fkcascade") + docID := seedDoc(t, cfg, clientID, "m2-fkcascade-doc") + assignSchemaForTest(t, cons, docID, uuid.UUID(schema.Id)) + + // Write two metadata versions via the handler. + for i := 0; i < 2; i++ { + body := queryapi.CustomMetadataRequest{ + DocumentId: openapi_types.UUID(docID), + Metadata: map[string]interface{}{"name": "x"}, + } + ctx, rec := newCustomMetadataContext(t, http.MethodPost, body) + require.NoError(t, cons.SetCustomMetadata(ctx)) + require.Equal(t, http.StatusCreated, rec.Code) + } + + var before int + require.NoError(t, cfg.GetDBPool().QueryRow(t.Context(), + `SELECT COUNT(*) FROM document_custom_metadata WHERE document_id = $1`, + docID).Scan(&before)) + require.Equal(t, 2, before) + + // Delete the document row directly (the feature relies on the FK + // cascade, not on an application-level delete path). + _, err := cfg.GetDBPool().Exec(t.Context(), + `DELETE FROM documents WHERE id = $1`, docID) + require.NoError(t, err) + + var after int + require.NoError(t, cfg.GetDBPool().QueryRow(t.Context(), + `SELECT COUNT(*) FROM document_custom_metadata WHERE document_id = $1`, + docID).Scan(&after)) + assert.Equal(t, 0, after, "FK cascade must remove every metadata row") +} + +// TestM2_GetByVersionNotFound exercises the ErrMetadataVersionNotFound (404) +// branch in mapCustomMetadataError by requesting a version that does not exist. +func TestM2_GetByVersionNotFound(t *testing.T) { + if testing.Short() { + t.SkipNow() + } + const clientID = "M2_VERNOTFOUND" + cons, cfg := setupM2Fixture(t, clientID) + + schema := seedCustomSchema(t, cons, clientID, "m2-ver-notfound") + docID := seedDoc(t, cfg, clientID, "m2-ver-notfound-doc") + assignSchemaForTest(t, cons, docID, uuid.UUID(schema.Id)) + + // POST one version so the document exists and has metadata. + body := queryapi.CustomMetadataRequest{ + DocumentId: openapi_types.UUID(docID), + Metadata: map[string]interface{}{"name": "x"}, + } + ctx, rec := newCustomMetadataContext(t, http.MethodPost, body) + require.NoError(t, cons.SetCustomMetadata(ctx)) + require.Equal(t, http.StatusCreated, rec.Code) + + // Request a version that does not exist → 404. + ctxVer, recVer := newCustomMetadataContext(t, http.MethodGet, nil) + err := cons.GetCustomMetadataByVersion(ctxVer, queryapi.GetCustomMetadataByVersionParams{ + DocumentId: openapi_types.UUID(docID), + Version: 99, + }) + requireHTTPStatus(t, err, recVer, http.StatusNotFound) +} + +// TestM2_CreateFieldExtractionOnCustomDocReturns409 exercises the +// ErrMutualExclusivityViolation branch in the CreateFieldExtraction handler. +// A document bound to a custom schema must reject legacy field extraction writes +// with 409. +func TestM2_CreateFieldExtractionOnCustomDocReturns409(t *testing.T) { + if testing.Short() { + t.SkipNow() + } + const clientID = "M2_LEGFX409" + cons, cfg := setupM2Fixture(t, clientID) + + schema := seedCustomSchema(t, cons, clientID, "m2-legfx-409") + docID := seedDoc(t, cfg, clientID, "m2-legfx-409-doc") + assignSchemaForTest(t, cons, docID, uuid.UUID(schema.Id)) + + // Attempt a legacy field extraction write — must 409. + reqBody := queryapi.FieldExtractionRequest{ + DocumentId: queryapi.DocumentID(docID), + CreatedBy: "test@example.com", + SingleFields: queryapi.SingleFields{ + FileName: func() *string { s := "test.pdf"; return &s }(), + }, + } + b, err := json.Marshal(reqBody) + require.NoError(t, err) + + e := echo.New() + req := httptest.NewRequest(http.MethodPost, "/field-extractions", bytes.NewReader(b)) + req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON) + rec := httptest.NewRecorder() + ctx := e.NewContext(req, rec) + ctx.Set("user_claims", map[string]interface{}{"sub": testActorSubject}) + + fxErr := cons.CreateFieldExtraction(ctx) + requireHTTPStatus(t, fxErr, rec, http.StatusConflict) +} + +// TestM2_CreateFieldExtractionDocNotFound exercises the ErrDocumentNotFound (404) +// branch in the CreateFieldExtraction handler when the document does not exist. +func TestM2_CreateFieldExtractionDocNotFound(t *testing.T) { + if testing.Short() { + t.SkipNow() + } + const clientID = "M2_FXDOCNOTFOUND" + cons, _ := setupM2Fixture(t, clientID) + + // Use a UUID that will not match any document. + nonExistentDocID := uuid.New() + reqBody := queryapi.FieldExtractionRequest{ + DocumentId: queryapi.DocumentID(nonExistentDocID), + CreatedBy: "test@example.com", + SingleFields: queryapi.SingleFields{ + FileName: func() *string { s := "test.pdf"; return &s }(), + }, + } + b, err := json.Marshal(reqBody) + require.NoError(t, err) + + e := echo.New() + req := httptest.NewRequest(http.MethodPost, "/field-extractions", bytes.NewReader(b)) + req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON) + rec := httptest.NewRecorder() + ctx := e.NewContext(req, rec) + ctx.Set("user_claims", map[string]interface{}{"sub": testActorSubject}) + + fxErr := cons.CreateFieldExtraction(ctx) + requireHTTPStatus(t, fxErr, rec, http.StatusNotFound) +} + +// requireHTTPStatus asserts the handler produced the expected HTTP code, +// handling both the "echo.HTTPError returned" and "recorder has code +// already" shapes the handlers can emit. Milestone 2 handlers always +// return an echo.HTTPError for mapped sentinels rather than writing to +// the recorder, so the error branch is the common path. +func requireHTTPStatus(t *testing.T, err error, rec *httptest.ResponseRecorder, want int) { + t.Helper() + if err != nil { + var httpErr *echo.HTTPError + if errors.As(err, &httpErr) { + assert.Equalf(t, want, httpErr.Code, "expected status %d, got %d: %v", want, httpErr.Code, httpErr.Message) + return + } + t.Fatalf("expected echo.HTTPError with status %d, got %T: %v", want, err, err) + } + assert.Equal(t, want, rec.Code) +} diff --git a/api/queryAPI/customschemas.go b/api/queryAPI/customschemas.go new file mode 100644 index 00000000..2487a3fc --- /dev/null +++ b/api/queryAPI/customschemas.go @@ -0,0 +1,325 @@ +// Package queryapi: real handler implementations for the +// SuperAdminSchemaService routes declared in the OpenAPI spec. +// +// Each handler: +// 1. Extracts the authenticated Cognito subject via cognitoauth.GetUserSubject. +// The handler never trusts a body-supplied "createdBy" — the request schema +// does not even expose the field, and the service takes the actor as a +// separate argument. +// 2. Binds the request body (for POST) into the generated request type. +// 3. Delegates to customschema.Service. +// 4. Maps customschema sentinel errors and validator errors onto HTTP codes. +// 5. Serializes the returned model into the generated response type. +package queryapi + +import ( + "encoding/json" + "errors" + "log/slog" + "net/http" + "strings" + "time" + + "queryorchestration/internal/cognitoauth" + "queryorchestration/internal/customschema" + + "github.com/google/uuid" + "github.com/labstack/echo/v4" + openapi_types "github.com/oapi-codegen/runtime/types" +) + +// validatorErrorMarker is the prefix customschema.Service wraps around every +// validator failure it surfaces from CreateSchema / CreateSchemaVersion. The +// handler layer uses it (via strings.Contains) to distinguish a 400-class +// validation problem from a 500-class infrastructure error after sentinel +// checks have already ruled out ErrSchemaNotFound, ErrSchemaInUse, etc. +const validatorErrorMarker = "validate schema definition" + +// customSchemaListRow is a type alias onto the anonymous element type that +// oapi-codegen produced inside CustomSchemaListResponse.Schemas. Using an +// alias (not a new type) preserves Go's structural identity so we can append +// directly into the generated slice. +type customSchemaListRow = struct { + // CanDelete True when documentCount is zero. + CanDelete bool `json:"canDelete"` + + // ClientId The client that owns this schema. + ClientId string `json:"clientId"` + + // CreatedAt Timestamp when this schema row was created. + CreatedAt time.Time `json:"createdAt"` + + // CreatedBy Cognito subject ID (JWT sub claim) of the caller. Opaque UUID, not an email. + CreatedBy string `json:"createdBy"` + + // Description Optional human-readable description of the schema. + Description *string `json:"description,omitempty"` + + // DocumentCount Number of documents referencing this schema version. + DocumentCount int32 `json:"documentCount"` + + // Id The schema row ID. + Id openapi_types.UUID `json:"id"` + + // Name The schema name (shared across versions in a lineage). + Name string `json:"name"` + + // Status Lifecycle state of a client_metadata_schemas row. `active` is + // the version currently assignable to new documents. `superseded` + // is automatically set when a newer version of the same (clientId, + // name) lineage is created via POST /versions. `retired` is set + // when a super_admin explicitly deletes an unused schema. + Status SchemaStatus `json:"status"` + + // Version Monotonic version number within the lineage. + Version int32 `json:"version"` +} + +// CreateCustomSchema is POST /super-admin/custom-schemas. +// +// Body: CustomSchemaRequest. Actor: JWT subject. Returns 201 with the full +// CustomSchemaResponse on success. +func (s *Controllers) CreateCustomSchema(ctx echo.Context) error { + actor, ok := cognitoauth.GetUserSubject(ctx) + if !ok { + return echo.NewHTTPError(http.StatusUnauthorized, "missing user subject") + } + + var req CustomSchemaRequest + if err := ctx.Bind(&req); err != nil { + slog.Error("failed to parse custom schema request", "error", err) + return echo.NewHTTPError(http.StatusBadRequest, "invalid request body") + } + + schemaBytes, err := json.Marshal(req.Schema) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, "invalid schema JSON") + } + + description := "" + if req.Description != nil { + description = *req.Description + } + + input := customschema.CreateSchemaInput{ + ClientID: req.ClientId, + Name: req.Name, + Description: description, + SchemaDef: schemaBytes, + } + + created, err := s.svc.CustomSchema.CreateSchema(ctx.Request().Context(), input, actor) + if err != nil { + return mapCustomSchemaError(err) + } + + resp, err := schemaToResponse(created, 0) + if err != nil { + return echo.NewHTTPError(http.StatusInternalServerError, "failed to encode schema response") + } + return ctx.JSON(http.StatusCreated, resp) +} + +// ListCustomSchemas is GET /super-admin/custom-schemas. The required +// clientId query parameter is enforced by the generated wrapper; the +// handler still guards for an empty string to fail fast with a clear 400. +func (s *Controllers) ListCustomSchemas(ctx echo.Context, params ListCustomSchemasParams) error { + if _, ok := cognitoauth.GetUserSubject(ctx); !ok { + return echo.NewHTTPError(http.StatusUnauthorized, "missing user subject") + } + + if strings.TrimSpace(params.ClientId) == "" { + return echo.NewHTTPError(http.StatusBadRequest, "clientId is required") + } + + filters := customschema.ListFilters{ + Limit: 50, + Offset: 0, + } + if params.Name != nil { + filters.Name = *params.Name + } + if params.IncludeAllVersions != nil { + filters.IncludeAllVersions = *params.IncludeAllVersions + } + if params.Limit != nil { + if *params.Limit < 1 || *params.Limit > 200 { + return echo.NewHTTPError(http.StatusBadRequest, "limit must be between 1 and 200") + } + filters.Limit = *params.Limit + } + if params.Offset != nil { + if *params.Offset < 0 { + return echo.NewHTTPError(http.StatusBadRequest, "offset must be >= 0") + } + filters.Offset = *params.Offset + } + if params.Status != nil { + if *params.Status == ListCustomSchemasParamsStatusAny { + filters.StatusAny = true + } else { + filters.Status = string(*params.Status) + } + } + + summaries, err := s.svc.CustomSchema.ListSchemas(ctx.Request().Context(), params.ClientId, filters) + if err != nil { + return mapCustomSchemaError(err) + } + + resp := CustomSchemaListResponse{ + Schemas: make([]customSchemaListRow, 0, len(summaries)), + } + for _, sum := range summaries { + resp.Schemas = append(resp.Schemas, summaryToListRow(sum)) + } + return ctx.JSON(http.StatusOK, resp) +} + +// GetCustomSchema is GET /super-admin/custom-schemas/{schemaId}. +func (s *Controllers) GetCustomSchema(ctx echo.Context, schemaId openapi_types.UUID) error { + if _, ok := cognitoauth.GetUserSubject(ctx); !ok { + return echo.NewHTTPError(http.StatusUnauthorized, "missing user subject") + } + + summary, err := s.svc.CustomSchema.GetSchema(ctx.Request().Context(), uuid.UUID(schemaId)) + if err != nil { + return mapCustomSchemaError(err) + } + + resp, err := schemaToResponse(&summary.Schema, summary.DocumentCount) + if err != nil { + return echo.NewHTTPError(http.StatusInternalServerError, "failed to encode schema response") + } + return ctx.JSON(http.StatusOK, resp) +} + +// CreateCustomSchemaVersion is POST /super-admin/custom-schemas/{schemaId}/versions. +func (s *Controllers) CreateCustomSchemaVersion(ctx echo.Context, schemaId openapi_types.UUID) error { + actor, ok := cognitoauth.GetUserSubject(ctx) + if !ok { + return echo.NewHTTPError(http.StatusUnauthorized, "missing user subject") + } + + var req CustomSchemaVersionRequest + if err := ctx.Bind(&req); err != nil { + slog.Error("failed to parse custom schema version request", "error", err) + return echo.NewHTTPError(http.StatusBadRequest, "invalid request body") + } + + schemaBytes, err := json.Marshal(req.Schema) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, "invalid schema JSON") + } + + description := "" + if req.Description != nil { + description = *req.Description + } + + input := customschema.CreateSchemaVersionInput{ + Description: description, + SchemaDef: schemaBytes, + } + + created, err := s.svc.CustomSchema.CreateSchemaVersion(ctx.Request().Context(), uuid.UUID(schemaId), input, actor) + if err != nil { + return mapCustomSchemaError(err) + } + + resp, err := schemaToResponse(created, 0) + if err != nil { + return echo.NewHTTPError(http.StatusInternalServerError, "failed to encode schema response") + } + return ctx.JSON(http.StatusCreated, resp) +} + +// DeleteCustomSchema is DELETE /super-admin/custom-schemas/{schemaId}. +func (s *Controllers) DeleteCustomSchema(ctx echo.Context, schemaId openapi_types.UUID) error { + actor, ok := cognitoauth.GetUserSubject(ctx) + if !ok { + return echo.NewHTTPError(http.StatusUnauthorized, "missing user subject") + } + + if err := s.svc.CustomSchema.DeleteSchema(ctx.Request().Context(), uuid.UUID(schemaId), actor); err != nil { + return mapCustomSchemaError(err) + } + return ctx.NoContent(http.StatusNoContent) +} + +// mapCustomSchemaError translates a customschema.Service error into an +// echo.HTTPError with the correct status code and message. Sentinel checks +// run first; anything that looks like a validator failure maps to 400; +// everything else is a 500. +func mapCustomSchemaError(err error) error { + switch { + case errors.Is(err, customschema.ErrSchemaNotFound): + return echo.NewHTTPError(http.StatusNotFound, "schema not found") + case errors.Is(err, customschema.ErrSchemaInUse): + return echo.NewHTTPError(http.StatusConflict, "schema is in use by one or more documents and cannot be deleted") + case errors.Is(err, customschema.ErrSchemaNameConflict): + return echo.NewHTTPError(http.StatusConflict, "schema with this duplicate name already exists for client") + case errors.Is(err, customschema.ErrSchemaParentRetired): + return echo.NewHTTPError(http.StatusConflict, "parent schema is retired; cannot create a new version from a retired parent") + case errors.Is(err, customschema.ErrSchemaParentNotActive): + return echo.NewHTTPError(http.StatusConflict, "parent schema is not the active version in its lineage") + } + if strings.Contains(err.Error(), validatorErrorMarker) { + return echo.NewHTTPError(http.StatusBadRequest, err.Error()) + } + slog.Error("custom schema handler internal error", "error", err) + return echo.NewHTTPError(http.StatusInternalServerError, "internal server error") +} + +// schemaToResponse copies a customschema.Schema into the generated +// CustomSchemaResponse. documentCount is the live document binding count; +// CanDelete is derived from it (true iff count == 0). The model's SchemaDef +// is json.RawMessage and the generated type expects map[string]interface{}, +// so we round-trip through encoding/json to reshape without losing structure. +func schemaToResponse(schema *customschema.Schema, documentCount int64) (CustomSchemaResponse, error) { + var schemaMap map[string]interface{} + if err := json.Unmarshal(schema.SchemaDef, &schemaMap); err != nil { + return CustomSchemaResponse{}, err + } + var descriptionPtr *string + if schema.Description != "" { + d := schema.Description + descriptionPtr = &d + } + return CustomSchemaResponse{ + Id: openapi_types.UUID(schema.ID), + ClientId: schema.ClientID, + Name: schema.Name, + Description: descriptionPtr, + Schema: schemaMap, + Version: int32(schema.Version), //nolint:gosec // schema.Version is the per-lineage monotonic counter; an int32 ceiling of ~2 billion is trivially safe for schema versions + Status: SchemaStatus(schema.Status), + CreatedAt: schema.CreatedAt, + CreatedBy: schema.CreatedBy, + DocumentCount: int32(documentCount), //nolint:gosec // document count per schema is bounded by total documents; int32 max (~2B) is safe + CanDelete: documentCount == 0, + }, nil +} + +// summaryToListRow translates a customschema.SchemaSummary into the anonymous +// row struct declared inside CustomSchemaListResponse. The alias defined +// above preserves structural identity with the generated type. +func summaryToListRow(summary customschema.SchemaSummary) customSchemaListRow { + var descriptionPtr *string + if summary.Schema.Description != "" { + d := summary.Schema.Description + descriptionPtr = &d + } + return customSchemaListRow{ + CanDelete: summary.CanDelete, + ClientId: summary.Schema.ClientID, + CreatedAt: summary.Schema.CreatedAt, + CreatedBy: summary.Schema.CreatedBy, + Description: descriptionPtr, + DocumentCount: int32(summary.DocumentCount), //nolint:gosec // documentCount is COUNT(*) of documents bound to this schema; an int32 ceiling of ~2 billion is far above any realistic per-client document count + Id: openapi_types.UUID(summary.Schema.ID), + Name: summary.Schema.Name, + Status: SchemaStatus(summary.Schema.Status), + Version: int32(summary.Schema.Version), //nolint:gosec // see schemaToResponse; per-lineage monotonic version counter + } +} diff --git a/api/queryAPI/customschemas_bulk.go b/api/queryAPI/customschemas_bulk.go new file mode 100644 index 00000000..39bedec6 --- /dev/null +++ b/api/queryAPI/customschemas_bulk.go @@ -0,0 +1,4 @@ +// Package queryapi: Milestone 4 (bulk assign) pre-partitioning stub. +// This file is intentionally empty so Milestone 4 owns a file disjoint +// from customschemas.go. See tracking §2.9. +package queryapi diff --git a/api/queryAPI/customschemas_integration_test.go b/api/queryAPI/customschemas_integration_test.go new file mode 100644 index 00000000..dbeb2183 --- /dev/null +++ b/api/queryAPI/customschemas_integration_test.go @@ -0,0 +1,709 @@ +// Package queryapi_test — Milestone 1 §1.10 end-to-end integration tests. +// +// These tests compose the Milestone 1 Schema CRUD HTTP handlers, the +// customschema service, and the real Postgres testcontainer produced by +// internal/test.CreateDB to prove: +// +// 1. super_admin can POST a schema and GET it back (version=1, active) +// 2. super_admin can POST a new version and the listing endpoint surfaces +// both the active v2 and the superseded v1 when includeAllVersions+status=any +// while the default listing returns only the active v2 +// 3. DELETE on a schema bound to a document returns 409 +// 4. DELETE on an unreferenced schema succeeds and sets status=retired +// 5. Direct SQL attempting to bind a document owned by client A to a schema +// owned by client B fails with the composite FK constraint +// 6. Authorization matrix: (role, route, method) cells — super_admin allowed +// on every route, user_admin and client_user forbidden on every route, +// auditor GET-only. +// +// The authorization matrix is exercised by calling performPermitIOAuthorization +// directly with a roleAwarePermitChecker that emulates what the real Permit.io +// PDP would return for the policy rules declared in +// cmd/auth_related/permit.setup/permit_policies.yaml. +package queryapi_test + +import ( + "encoding/json" + "net/http" + "strings" + "sync" + "testing" + + queryapi "queryorchestration/api/queryAPI" + "queryorchestration/internal/cognitoauth" + "queryorchestration/internal/database/repository" + "queryorchestration/internal/test" + + "github.com/google/uuid" + "github.com/labstack/echo/v4" + openapi_types "github.com/oapi-codegen/runtime/types" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// ---------- flow 1: create v1 then GET ---------- + +// TestMilestone1_CreateV1AndGet covers §1.10 end-to-end #1: +// super_admin POSTs schema v1 → GET returns it with version 1, active. +func TestMilestone1_CreateV1AndGet(t *testing.T) { + if testing.Short() { + t.SkipNow() + } + cfg := &ControllerConfig{} + test.CreateDB(t, cfg) + initializeTestConfig(t, cfg) + + svc := createControllerServices(cfg) + cons := queryapi.NewControllers(svc, cfg) + + const clientID = "M1_INTG_C1" + resetClientForSchemaTests(t, cfg, clientID) + test.CreateTestClient(t, cfg, clientID, "Milestone1 Integration Client 1") + + // Step 1: POST schema v1. + descr := "integration v1" + createBody := queryapi.CustomSchemaRequest{ + ClientId: clientID, + Name: "integration-v1", + Description: &descr, + Schema: mustValidSchema(t), + } + ctxCreate, recCreate := newCustomSchemaContext(t, http.MethodPost, createBody) + require.NoError(t, cons.CreateCustomSchema(ctxCreate)) + require.Equal(t, http.StatusCreated, recCreate.Code) + + var created queryapi.CustomSchemaResponse + require.NoError(t, json.Unmarshal(recCreate.Body.Bytes(), &created)) + require.Equal(t, int32(1), created.Version) + require.Equal(t, queryapi.SchemaStatusActive, created.Status) + + // Step 2: GET /super-admin/custom-schemas/{id}. + ctxGet, recGet := newCustomSchemaContext(t, http.MethodGet, nil) + require.NoError(t, cons.GetCustomSchema(ctxGet, created.Id)) + require.Equal(t, http.StatusOK, recGet.Code) + + var fetched queryapi.CustomSchemaResponse + require.NoError(t, json.Unmarshal(recGet.Body.Bytes(), &fetched)) + assert.Equal(t, created.Id, fetched.Id) + assert.Equal(t, "integration-v1", fetched.Name) + assert.Equal(t, int32(1), fetched.Version) + assert.Equal(t, queryapi.SchemaStatusActive, fetched.Status) + assert.Equal(t, clientID, fetched.ClientId) + assert.Equal(t, testActorSubject, fetched.CreatedBy) + assert.NotNil(t, fetched.Schema) +} + +// ---------- flow 2: create v2, list semantics ---------- + +// TestMilestone1_CreateV2ListSemantics covers §1.10 end-to-end #2: +// super_admin POSTs schema v2 via POST .../versions → GET with +// ?includeAllVersions=true&status=any returns both rows (v1 superseded, +// v2 active); default GET returns only v2 (active). +func TestMilestone1_CreateV2ListSemantics(t *testing.T) { + if testing.Short() { + t.SkipNow() + } + cfg := &ControllerConfig{} + test.CreateDB(t, cfg) + initializeTestConfig(t, cfg) + + svc := createControllerServices(cfg) + cons := queryapi.NewControllers(svc, cfg) + + const clientID = "M1_INTG_C2" + resetClientForSchemaTests(t, cfg, clientID) + test.CreateTestClient(t, cfg, clientID, "Milestone1 Integration Client 2") + + // Seed v1 through the handler. + v1 := seedCustomSchema(t, cons, clientID, "lineage-alpha") + require.Equal(t, int32(1), v1.Version) + require.Equal(t, queryapi.SchemaStatusActive, v1.Status) + + // Create v2 via POST .../versions. + bumpBody := queryapi.CustomSchemaVersionRequest{Schema: mustValidSchema(t)} + ctxBump, recBump := newCustomSchemaContext(t, http.MethodPost, bumpBody) + require.NoError(t, cons.CreateCustomSchemaVersion(ctxBump, v1.Id)) + require.Equal(t, http.StatusCreated, recBump.Code) + + var v2 queryapi.CustomSchemaResponse + require.NoError(t, json.Unmarshal(recBump.Body.Bytes(), &v2)) + require.Equal(t, int32(2), v2.Version) + require.Equal(t, queryapi.SchemaStatusActive, v2.Status) + require.NotEqual(t, v1.Id, v2.Id) + + // 2a: default list — only active latest per lineage → only v2. + t.Run("default_list_returns_active_latest_only", func(t *testing.T) { + ctx, rec := newCustomSchemaContext(t, http.MethodGet, nil) + name := "lineage-alpha" + params := queryapi.ListCustomSchemasParams{ + ClientId: clientID, + Name: &name, + } + require.NoError(t, cons.ListCustomSchemas(ctx, params)) + require.Equal(t, http.StatusOK, rec.Code) + + var resp queryapi.CustomSchemaListResponse + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp)) + require.Len(t, resp.Schemas, 1, + "default list must return exactly one row — the active v2") + row := resp.Schemas[0] + assert.Equal(t, int32(2), row.Version) + assert.Equal(t, queryapi.SchemaStatusActive, row.Status) + assert.Equal(t, v2.Id, row.Id) + }) + + // 2b: includeAllVersions=true + status=any returns both rows. + t.Run("include_all_versions_status_any_returns_both", func(t *testing.T) { + ctx, rec := newCustomSchemaContext(t, http.MethodGet, nil) + status := queryapi.ListCustomSchemasParamsStatusAny + includeAll := true + name := "lineage-alpha" + params := queryapi.ListCustomSchemasParams{ + ClientId: clientID, + Name: &name, + Status: &status, + IncludeAllVersions: &includeAll, + } + require.NoError(t, cons.ListCustomSchemas(ctx, params)) + require.Equal(t, http.StatusOK, rec.Code) + + var resp queryapi.CustomSchemaListResponse + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp)) + require.Len(t, resp.Schemas, 2, + "status=any + includeAllVersions must return v1 and v2") + + byVersion := map[int32]queryapi.SchemaStatus{} + for _, row := range resp.Schemas { + byVersion[row.Version] = row.Status + } + assert.Equal(t, queryapi.SchemaStatusSuperseded, byVersion[1], + "v1 must be reported as superseded after v2 was created") + assert.Equal(t, queryapi.SchemaStatusActive, byVersion[2], + "v2 must be reported as active") + }) +} + +// ---------- flow 3 + 4: delete paths ---------- + +// TestMilestone1_DeleteBoundReturns409 covers §1.10 end-to-end #3: +// DELETE on a schema with documents bound → 409, and the schema stays active. +func TestMilestone1_DeleteBoundReturns409(t *testing.T) { + if testing.Short() { + t.SkipNow() + } + cfg := &ControllerConfig{} + test.CreateDB(t, cfg) + initializeTestConfig(t, cfg) + + svc := createControllerServices(cfg) + cons := queryapi.NewControllers(svc, cfg) + + const clientID = "M1_INTG_C3" + resetClientForSchemaTests(t, cfg, clientID) + test.CreateTestClient(t, cfg, clientID, "Milestone1 Integration Client 3") + + seeded := seedCustomSchema(t, cons, clientID, "bound-delete-case") + + // Bind a document to the schema. + docID, err := cfg.GetDBQueries().CreateDocument(t.Context(), &repository.CreateDocumentParams{ + Clientid: clientID, + Hash: "bound-delete-hash", + }) + require.NoError(t, err) + _, err = cfg.GetDBPool().Exec(t.Context(), + `UPDATE documents SET custom_schema_id = $1 WHERE id = $2`, + uuid.UUID(seeded.Id), docID) + require.NoError(t, err) + + // DELETE must return 409. + ctxDel, _ := newCustomSchemaContext(t, http.MethodDelete, nil) + err = cons.DeleteCustomSchema(ctxDel, seeded.Id) + require.Error(t, err) + httpErr, ok := err.(*echo.HTTPError) + require.True(t, ok) + assert.Equal(t, http.StatusConflict, httpErr.Code) + + // DB unchanged: row still active. + row, err := cfg.GetDBQueries().GetClientMetadataSchema(t.Context(), uuid.UUID(seeded.Id)) + require.NoError(t, err) + assert.Equal(t, repository.SchemaStatusTypeActive, row.Status, + "rejected delete must not mutate status") + + // Unbind so the testcontainer volume resets cleanly on rerun. + _, err = cfg.GetDBPool().Exec(t.Context(), + `UPDATE documents SET custom_schema_id = NULL WHERE id = $1`, docID) + require.NoError(t, err) +} + +// TestMilestone1_DeleteUnreferencedRetires covers §1.10 end-to-end #4: +// DELETE on an unreferenced schema succeeds and status becomes retired. +func TestMilestone1_DeleteUnreferencedRetires(t *testing.T) { + if testing.Short() { + t.SkipNow() + } + cfg := &ControllerConfig{} + test.CreateDB(t, cfg) + initializeTestConfig(t, cfg) + + svc := createControllerServices(cfg) + cons := queryapi.NewControllers(svc, cfg) + + const clientID = "M1_INTG_C4" + resetClientForSchemaTests(t, cfg, clientID) + test.CreateTestClient(t, cfg, clientID, "Milestone1 Integration Client 4") + + seeded := seedCustomSchema(t, cons, clientID, "unreferenced-delete") + + ctxDel, recDel := newCustomSchemaContext(t, http.MethodDelete, nil) + require.NoError(t, cons.DeleteCustomSchema(ctxDel, seeded.Id)) + assert.Equal(t, http.StatusNoContent, recDel.Code) + + row, err := cfg.GetDBQueries().GetClientMetadataSchema(t.Context(), uuid.UUID(seeded.Id)) + require.NoError(t, err) + assert.Equal(t, repository.SchemaStatusTypeRetired, row.Status) +} + +// ---------- flow 5: cross-client FK rejection ---------- + +// TestMilestone1_CrossClientFKRejection covers §1.10 end-to-end #5: +// Directly UPDATE a document owned by client A to point at a schema owned +// by client B. The composite FK fk_documents_custom_schema_same_client must +// refuse the write. +func TestMilestone1_CrossClientFKRejection(t *testing.T) { + if testing.Short() { + t.SkipNow() + } + cfg := &ControllerConfig{} + test.CreateDB(t, cfg) + initializeTestConfig(t, cfg) + + svc := createControllerServices(cfg) + cons := queryapi.NewControllers(svc, cfg) + + const clientA = "M1_INTG_CA" + const clientB = "M1_INTG_CB" + resetClientForSchemaTests(t, cfg, clientA) + resetClientForSchemaTests(t, cfg, clientB) + test.CreateTestClient(t, cfg, clientA, "Cross-client FK test A") + test.CreateTestClient(t, cfg, clientB, "Cross-client FK test B") + + // Seed a schema on client B. + schemaB := seedCustomSchema(t, cons, clientB, "cross-client-schemaB") + + // Create a document owned by client A. + docA, err := cfg.GetDBQueries().CreateDocument(t.Context(), &repository.CreateDocumentParams{ + Clientid: clientA, + Hash: "cross-client-doc-a", + }) + require.NoError(t, err) + + // Attempt to bind docA to schemaB — must fail with FK violation. + _, err = cfg.GetDBPool().Exec(t.Context(), + `UPDATE documents SET custom_schema_id = $1 WHERE id = $2`, + uuid.UUID(schemaB.Id), docA) + require.Error(t, err, "composite FK must reject cross-client schema binding") + assert.Contains(t, err.Error(), "fk_documents_custom_schema_same_client", + "error must reference the composite FK constraint by name") + + // DB unchanged: docA still has custom_schema_id = NULL. + var bound *uuid.UUID + row := cfg.GetDBPool().QueryRow(t.Context(), + `SELECT custom_schema_id FROM documents WHERE id = $1`, docA) + require.NoError(t, row.Scan(&bound)) + assert.Nil(t, bound, "rejected FK update must leave custom_schema_id NULL") +} + +// ---------- flow 6: authorization matrix ---------- + +// roleAwarePermitChecker emulates the Permit.io PDP response for the +// Milestone 1 policy rules declared in +// cmd/auth_related/permit.setup/permit_policies.yaml. The matrix is the +// source of truth for what "correctly gated" means in §1.10 end-to-end #6. +// +// We intentionally reconstruct the policy in-process: Permit.io itself is +// a remote service and the v4 plan forbids running the setup tool inside +// this session (see journal entry 2026-04-14 §1.8 partial), so the only +// way to prove the middleware would route the right decision through is +// to exercise performPermitIOAuthorization with a checker whose truth +// table mirrors the YAML. +type roleAwarePermitChecker struct { + // role is the Permit.io role name associated with the user subject. + role string + // called is set to true when CheckPermission is invoked — lets tests + // assert that short-circuit paths (auth-only, swagger) really skip + // the PDP call. + called bool +} + +// CheckPermission mirrors the role → (resource, action) grants in +// permit_policies.yaml for the Milestone 1 resources. +func (r *roleAwarePermitChecker) CheckPermission(userID, action, resourceType string) (bool, error) { + r.called = true + grants := map[string]map[string]map[string]bool{ + "super_admin": { + "super-admin": {"get": true, "post": true, "patch": true, "delete": true}, + }, + "user_admin": { + // Intentionally no super-admin grants. + }, + "client_user": { + // No super-admin grants. + }, + "auditor": { + "super-admin": {"get": true}, + }, + } + if resources, ok := grants[r.role]; ok { + if actions, ok := resources[resourceType]; ok { + return actions[action], nil + } + } + return false, nil +} + +// runPermitGate reproduces the authorization choke-point logic from +// internal/cognitoauth/middleware.go:84-146 (performPermitIOAuthorization) +// using only symbols that `cognitoauth` already exports: +// - GetUserSubject(c) +// - GetResourceFromRoute(path) +// - GetActionFromMethod(method) +// - PermitChecker.CheckPermission(userID, action, resourceType) +// +// Reproducing the gate in-test is safer than exposing the production +// function as a test shim: the rule for this package is tests never +// change production code, and exposing a PerformPermitIOAuthorizationForTest +// function would violate that. If the real function's logic drifts, the +// tests here will diverge — which is exactly the signal a reviewer wants. +// +// The function writes exactly one JSON response on denial (mirroring +// the "single response" contract enforced by TestPermitIOAuthorization_SingleResponseIntegration) +// and invokes the supplied downstream handler on success. +func runPermitGate( + c echo.Context, + requestPath string, + checker cognitoauth.PermitChecker, + handler echo.HandlerFunc, +) error { + // 1. Paths that skip authorization entirely. + if strings.HasPrefix(requestPath, "/swagger") || strings.HasPrefix(requestPath, "/metrics") { + return handler(c) + } + // 2. Auth-only paths — auth required but no authorization check. + switch requestPath { + case "/identity", "/eula/status", "/eula/agree": + return handler(c) + } + // 3. Require a user subject. + userSubject, ok := cognitoauth.GetUserSubject(c) + if !ok { + return c.JSON(http.StatusUnauthorized, map[string]string{"error": "Authentication required"}) + } + // 4. Call the PDP. + resource := cognitoauth.GetResourceFromRoute(requestPath) + action := cognitoauth.GetActionFromMethod(c.Request().Method) + permitted, err := checker.CheckPermission(userSubject, action, resource) + if err != nil { + return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Authorization service unavailable"}) + } + if !permitted { + return c.JSON(http.StatusForbidden, map[string]interface{}{ + "error": "Insufficient permissions", + "user": userSubject, + "resource": resource, + "action": action, + }) + } + // 5. Authorized — call the downstream handler. + return handler(c) +} + +// authMatrixRoute is one row in the §1.10 authorization matrix: a request +// method, a request path, and a pointer to the handler that would run if +// authorization succeeded. The handler is only reached in the positive +// (super_admin) case — for denials, the recorder must show 403 and the +// handler must not be called. +type authMatrixRoute struct { + name string + method string + path string + handler func(c echo.Context, cons *queryapi.Controllers, seededID openapi_types.UUID) error + // isRead is true when this row is a GET. Only read rows are allowed + // for the auditor role; write rows must all be 403 for auditor. + isRead bool +} + +// TestMilestone1_AuthorizationMatrix covers §1.10 end-to-end #6: +// one subtest per (role, route, method) cell, each asserting status and +// that the DB is unchanged on denial. +// +// For every role other than super_admin (and auditor on GET paths), the +// Permit.io middleware must refuse the request BEFORE the handler runs. +// To prove the refusal really blocks the handler, each subtest counts +// rows in client_metadata_schemas before and after the request and +// asserts the count is unchanged when the request is denied. +func TestMilestone1_AuthorizationMatrix(t *testing.T) { + if testing.Short() { + t.SkipNow() + } + cfg := &ControllerConfig{} + test.CreateDB(t, cfg) + initializeTestConfig(t, cfg) + + svc := createControllerServices(cfg) + cons := queryapi.NewControllers(svc, cfg) + + const clientID = "M1_AUTHZ_C1" + resetClientForSchemaTests(t, cfg, clientID) + test.CreateTestClient(t, cfg, clientID, "Authz matrix client") + + // Seed one schema so GET /{id} and DELETE /{id} have a real target. + // We will NOT mutate this row except in the super_admin DELETE subtest, + // and that subtest re-seeds its own row so the rest of the matrix keeps + // working. + baseSeed := seedCustomSchema(t, cons, clientID, "authz-base-seed") + + // Build the route list. The handlers reach into cons so every row + // exercises the real generated wrapper logic path. + createBodyFactory := func() interface{} { + n := uuid.New().String()[:8] + return queryapi.CustomSchemaRequest{ + ClientId: clientID, + Name: "authz-create-" + n, + Schema: mustValidSchema(t), + } + } + versionBody := queryapi.CustomSchemaVersionRequest{Schema: mustValidSchema(t)} + + routes := []authMatrixRoute{ + { + name: "POST_/super-admin/custom-schemas", + method: http.MethodPost, + path: "/super-admin/custom-schemas", + handler: func(c echo.Context, cons *queryapi.Controllers, _ openapi_types.UUID) error { + return cons.CreateCustomSchema(c) + }, + }, + { + name: "GET_/super-admin/custom-schemas", + method: http.MethodGet, + path: "/super-admin/custom-schemas", + handler: func(c echo.Context, cons *queryapi.Controllers, _ openapi_types.UUID) error { + return cons.ListCustomSchemas(c, queryapi.ListCustomSchemasParams{ClientId: clientID}) + }, + isRead: true, + }, + { + name: "GET_/super-admin/custom-schemas/{id}", + method: http.MethodGet, + path: "/super-admin/custom-schemas/" + uuid.UUID(baseSeed.Id).String(), + handler: func(c echo.Context, cons *queryapi.Controllers, seededID openapi_types.UUID) error { + return cons.GetCustomSchema(c, seededID) + }, + isRead: true, + }, + { + name: "POST_/super-admin/custom-schemas/{id}/versions", + method: http.MethodPost, + path: "/super-admin/custom-schemas/" + uuid.UUID(baseSeed.Id).String() + "/versions", + handler: func(c echo.Context, cons *queryapi.Controllers, seededID openapi_types.UUID) error { + return cons.CreateCustomSchemaVersion(c, seededID) + }, + }, + { + name: "DELETE_/super-admin/custom-schemas/{id}", + method: http.MethodDelete, + path: "/super-admin/custom-schemas/" + uuid.UUID(baseSeed.Id).String() + "/delete", + handler: func(c echo.Context, cons *queryapi.Controllers, seededID openapi_types.UUID) error { + return cons.DeleteCustomSchema(c, seededID) + }, + }, + } + + rolesUnderTest := []struct { + role string + allowRead bool + allowWrite bool + description string + }{ + {role: "super_admin", allowRead: true, allowWrite: true, description: "full super-admin grants"}, + {role: "user_admin", allowRead: false, allowWrite: false, description: "no super-admin grants"}, + {role: "client_user", allowRead: false, allowWrite: false, description: "no super-admin grants"}, + {role: "auditor", allowRead: true, allowWrite: false, description: "GET-only super-admin grant"}, + } + + countSchemas := func(t *testing.T) int { + t.Helper() + var n int + require.NoError(t, + cfg.GetDBPool().QueryRow(t.Context(), + `SELECT COUNT(*) FROM client_metadata_schemas WHERE client_id = $1`, + clientID).Scan(&n)) + return n + } + + for _, rt := range rolesUnderTest { + rt := rt + t.Run(rt.role, func(t *testing.T) { + for _, route := range routes { + route := route + t.Run(route.name, func(t *testing.T) { + // Pick a fresh seed for the DELETE-super_admin cell so + // we don't retire the shared baseSeed row. + seedID := baseSeed.Id + if rt.role == "super_admin" && route.method == http.MethodDelete { + fresh := seedCustomSchema(t, cons, clientID, + "authz-del-"+uuid.New().String()[:6]) + seedID = fresh.Id + } + + allowed := rt.allowWrite + if route.isRead { + allowed = rt.allowRead + } + + // Build the echo context with the correct body for POST + // routes so if the handler IS called, it can succeed. + var body interface{} + if route.method == http.MethodPost && route.path == "/super-admin/custom-schemas" { + body = createBodyFactory() + } else if route.method == http.MethodPost { + body = versionBody + } + c, rec := newCustomSchemaContext(t, route.method, body) + // Override the request URL so the middleware sees the + // real path prefix — the default test harness uses "/". + c.Request().URL.Path = route.path + + beforeCount := countSchemas(t) + + handlerCalled := false + wrappedHandler := func(ec echo.Context) error { + handlerCalled = true + return route.handler(ec, cons, seedID) + } + checker := &roleAwarePermitChecker{role: rt.role} + + err := runPermitGate(c, route.path, checker, wrappedHandler) + // runPermitGate may return a handler error on the + // allowed path (e.g., a handler-level 409 surfaces as + // *echo.HTTPError). Only assert NoError for the + // positive path; denials never produce a Go error + // because runPermitGate writes the response directly. + if allowed { + require.NoError(t, err, "allowed path must succeed: %v", err) + } + + if allowed { + assert.True(t, handlerCalled, + "%s on %s must reach the handler when role %s is allowed", + route.method, route.path, rt.role) + // Successful responses live in recCreate.Code — not + // necessarily 200, but must be a 2xx. + assert.GreaterOrEqual(t, rec.Code, 200) + assert.Less(t, rec.Code, 300, + "allowed %s on %s must produce a 2xx response; got %d", + route.method, route.path, rec.Code) + } else { + assert.False(t, handlerCalled, + "%s on %s must NOT reach the handler when role %s is denied", + route.method, route.path, rt.role) + assert.Equal(t, http.StatusForbidden, rec.Code, + "denied %s on %s must return 403; role %s", + route.method, route.path, rt.role) + + // DB unchanged: write routes must not have created + // rows; read routes can't change the count anyway. + afterCount := countSchemas(t) + assert.Equal(t, beforeCount, afterCount, + "denied request must leave client_metadata_schemas row count unchanged") + } + }) + } + }) + } +} + +// TestM3_SchemaVersionConcurrency proves that two concurrent +// POST /super-admin/custom-schemas/{schemaId}/versions calls against the +// same parent schema produce deterministic results: one succeeds with v2 +// (status 201), the other returns 409 (ErrSchemaParentNotActive because +// creating v2 sets v1 to superseded, so creating v3 from v1 fails). +// +// After both goroutines complete, the DB must have exactly one "active" +// row for this schema name. +func TestM3_SchemaVersionConcurrency(t *testing.T) { + if testing.Short() { + t.SkipNow() + } + cfg := &ControllerConfig{} + test.CreateDB(t, cfg) + initializeTestConfig(t, cfg) + + svc := createControllerServices(cfg) + cons := queryapi.NewControllers(svc, cfg) + + const clientID = "M3_CONC_VER" + resetClientForSchemaTests(t, cfg, clientID) + test.CreateTestClient(t, cfg, clientID, "M3 concurrency version client") + + // Seed a v1 schema -- both goroutines will try to create v2 from this parent. + v1 := seedCustomSchema(t, cons, clientID, "m3-conc-lineage") + require.Equal(t, int32(1), v1.Version) + require.Equal(t, queryapi.SchemaStatusActive, v1.Status) + + type result struct { + statusCode int + err error + } + + var wg sync.WaitGroup + results := make(chan result, 2) + + for i := 0; i < 2; i++ { + wg.Add(1) + go func() { + defer wg.Done() + body := queryapi.CustomSchemaVersionRequest{Schema: mustValidSchema(t)} + ctx, rec := newCustomSchemaContext(t, http.MethodPost, body) + handlerErr := cons.CreateCustomSchemaVersion(ctx, v1.Id) + + if handlerErr != nil { + // The handler returns echo.HTTPError for mapped sentinels. + httpErr, ok := handlerErr.(*echo.HTTPError) + if ok { + results <- result{statusCode: httpErr.Code, err: nil} + } else { + results <- result{statusCode: 0, err: handlerErr} + } + return + } + results <- result{statusCode: rec.Code, err: nil} + }() + } + + wg.Wait() + close(results) + + var codes []int + for r := range results { + require.NoError(t, r.err, "goroutine must not produce an unexpected error") + codes = append(codes, r.statusCode) + } + + // One must be 201 (created v2), the other must be 409 (parent not active). + assert.Len(t, codes, 2) + assert.Contains(t, codes, http.StatusCreated, + "one goroutine must succeed with 201") + assert.Contains(t, codes, http.StatusConflict, + "one goroutine must fail with 409 (parent no longer active)") + + // DB invariant: exactly one active row for this lineage. + var activeCount int + err := cfg.GetDBPool().QueryRow(t.Context(), + `SELECT COUNT(*) FROM client_metadata_schemas + WHERE client_id = $1 AND name = $2 AND status = 'active'`, + clientID, "m3-conc-lineage").Scan(&activeCount) + require.NoError(t, err) + assert.Equal(t, 1, activeCount, + "after concurrent version creation, exactly one row must be active") +} diff --git a/api/queryAPI/customschemas_reset.go b/api/queryAPI/customschemas_reset.go new file mode 100644 index 00000000..18eaa414 --- /dev/null +++ b/api/queryAPI/customschemas_reset.go @@ -0,0 +1,74 @@ +// Package queryapi: Milestone 3 handler for +// POST /super-admin/documents/{id}/reset-metadata. +// +// This file is the M3-owned partition (see tracking §2.9). It must not +// introduce new types into models.go or customschemas.go. +package queryapi + +import ( + "errors" + "log/slog" + "net/http" + + "queryorchestration/internal/cognitoauth" + "queryorchestration/internal/customschema" + + "github.com/google/uuid" + "github.com/labstack/echo/v4" + "github.com/oapi-codegen/nullable" + openapi_types "github.com/oapi-codegen/runtime/types" +) + +// ResetDocumentMetadata is POST /super-admin/documents/{id}/reset-metadata. +// It wipes the document's custom metadata history and clears the schema +// binding in a single atomic transaction. The operation is idempotent; +// already-clean documents return 200 with metadataVersionsDeleted: 0. +// Restricted to super_admin (enforced by Permit.io middleware upstream). +func (s *Controllers) ResetDocumentMetadata(ctx echo.Context, id openapi_types.UUID) error { + actor, ok := cognitoauth.GetUserSubject(ctx) + if !ok { + return echo.NewHTTPError(http.StatusUnauthorized, "missing user subject") + } + + result, err := s.svc.CustomSchema.ResetDocumentMetadata(ctx.Request().Context(), uuid.UUID(id), actor) + if err != nil { + return mapResetError(err) + } + + resp := DocumentMetadataResetResponse{ + DocumentId: openapi_types.UUID(result.DocumentID), + MetadataVersionsDeleted: int32(result.MetadataVersionsDeleted), //nolint:gosec // bounded by row count + ResetAt: result.ResetAt, + ResetBy: result.ResetBy, + } + if result.PreviousSchemaID != nil { + resp.PreviousSchemaId = nullable.NewNullableWithValue(openapi_types.UUID(*result.PreviousSchemaID)) + } else { + resp.PreviousSchemaId = nullable.NewNullNullable[openapi_types.UUID]() + } + if result.PreviousSchemaName != nil { + resp.PreviousSchemaName = nullable.NewNullableWithValue(*result.PreviousSchemaName) + } else { + resp.PreviousSchemaName = nullable.NewNullNullable[string]() + } + if result.PreviousSchemaVersion != nil { + resp.PreviousSchemaVersion = nullable.NewNullableWithValue(int32(*result.PreviousSchemaVersion)) //nolint:gosec // schema version is a small counter + } else { + resp.PreviousSchemaVersion = nullable.NewNullNullable[int32]() + } + return ctx.JSON(http.StatusOK, resp) +} + +// mapResetError converts reset-specific service sentinels to HTTP errors. +// Errors not recognized here are treated as unexpected server failures. +func mapResetError(err error) error { + switch { + case errors.Is(err, customschema.ErrDocumentNotFound): + return echo.NewHTTPError(http.StatusNotFound, "document not found") + case errors.Is(err, customschema.ErrMutualExclusivityViolation): + return echo.NewHTTPError(http.StatusConflict, err.Error()) + default: + slog.Error("reset document metadata: unexpected error", "error", err) + return echo.NewHTTPError(http.StatusInternalServerError, "internal server error") + } +} diff --git a/api/queryAPI/customschemas_reset_test.go b/api/queryAPI/customschemas_reset_test.go new file mode 100644 index 00000000..10aae3ac --- /dev/null +++ b/api/queryAPI/customschemas_reset_test.go @@ -0,0 +1,397 @@ +// Package queryapi_test — Milestone 3 §3.3 and §3.4 integration tests +// for POST /super-admin/documents/{id}/reset-metadata. +// +// These tests exercise the ResetDocumentMetadata handler end-to-end against +// the real Postgres testcontainer, covering all observable status codes: +// 401 (missing subject), 200 (happy path with prior schema), 200 (idempotent +// clean doc), 404 (no such document), 409 (legacy field extractions present). +package queryapi_test + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + queryapi "queryorchestration/api/queryAPI" + "queryorchestration/internal/database/repository" + "queryorchestration/internal/fieldextraction" + "queryorchestration/internal/test" + + "github.com/google/uuid" + "github.com/labstack/echo/v4" + openapi_types "github.com/oapi-codegen/runtime/types" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// setupM3Fixture boots a fresh DB, wipes rows belonging to clientID, creates +// the client, and returns controllers + cfg. Mirrors setupM2Fixture from the +// Milestone 2 integration tests. +func setupM3Fixture(t *testing.T, clientID string) (*queryapi.Controllers, *ControllerConfig) { + t.Helper() + cfg := &ControllerConfig{} + test.CreateDB(t, cfg) + initializeTestConfig(t, cfg) + svc := createControllerServices(cfg) + cons := queryapi.NewControllers(svc, cfg) + + resetClientForSchemaTests(t, cfg, clientID) + test.CreateTestClient(t, cfg, clientID, "M3 integration "+clientID) + return cons, cfg +} + +// callReset dispatches POST /super-admin/documents/{id}/reset-metadata via the +// handler using the standard test actor subject. Returns the handler error and +// the response recorder so callers can inspect both status code and body. +func callReset(t *testing.T, cons *queryapi.Controllers, docID uuid.UUID) (error, *httptest.ResponseRecorder) { + t.Helper() + ctx, rec := newCustomSchemaContext(t, http.MethodPost, nil) + err := cons.ResetDocumentMetadata(ctx, openapi_types.UUID(docID)) + return err, rec +} + +// TestResetDocumentMetadata_MissingUserSubject verifies the 401 branch: when +// the echo context carries no user_claims the handler short-circuits before +// calling the service. +func TestResetDocumentMetadata_MissingUserSubject(t *testing.T) { + if testing.Short() { + t.SkipNow() + } + cfg := &ControllerConfig{} + test.CreateDB(t, cfg) + initializeTestConfig(t, cfg) + svc := createControllerServices(cfg) + cons := queryapi.NewControllers(svc, cfg) + + e := echo.New() + req := httptest.NewRequest(http.MethodPost, "/", nil) + rec := httptest.NewRecorder() + ctx := e.NewContext(req, rec) + // intentionally no ctx.Set("user_claims", ...) — simulates unauthenticated call + + err := cons.ResetDocumentMetadata(ctx, openapi_types.UUID(uuid.New())) + requireHTTPStatus(t, err, rec, http.StatusUnauthorized) +} + +// TestM3_ResetDocumentMetadata_DocumentNotFound exercises the 404 branch: when +// the document UUID does not exist the service returns ErrDocumentNotFound which +// mapResetError converts to 404. +func TestM3_ResetDocumentMetadata_DocumentNotFound(t *testing.T) { + if testing.Short() { + t.SkipNow() + } + const clientID = "M3_RST_NOTFOUND" + cons, _ := setupM3Fixture(t, clientID) + + err, rec := callReset(t, cons, uuid.New()) + requireHTTPStatus(t, err, rec, http.StatusNotFound) +} + +// TestM3_ResetDocumentMetadata_LegacyExtractionsConflict exercises the 409 +// branch: a document carrying legacy field extractions returns +// ErrMutualExclusivityViolation which mapResetError converts to 409. +func TestM3_ResetDocumentMetadata_LegacyExtractionsConflict(t *testing.T) { + if testing.Short() { + t.SkipNow() + } + const clientID = "M3_RST_LEGACY" + cons, cfg := setupM3Fixture(t, clientID) + + docID := seedDoc(t, cfg, clientID, "m3-reset-legacy-doc") + + svc := createControllerServices(cfg) + _, err := svc.FieldExtraction.CreateFieldExtraction(t.Context(), &fieldextraction.CreateFieldExtractionInput{ + DocumentID: docID, + SingleFields: &repository.AddFieldExtractionParams{ + Documentid: docID, + Createdby: "tester", + }, + ArrayFields: nil, + }) + require.NoError(t, err) + + resetErr, rec := callReset(t, cons, docID) + requireHTTPStatus(t, resetErr, rec, http.StatusConflict) +} + +// TestM3_ResetDocumentMetadata_HappyPath exercises the 200 success path: a +// document with a bound schema and two metadata versions is reset. The +// response must carry the previous schema fields, metadataVersionsDeleted == 2, +// and the DB must show zero metadata rows and custom_schema_id == NULL. +func TestM3_ResetDocumentMetadata_HappyPath(t *testing.T) { + if testing.Short() { + t.SkipNow() + } + const clientID = "M3_RST_HAPPY" + cons, cfg := setupM3Fixture(t, clientID) + + schema := seedCustomSchema(t, cons, clientID, "m3-reset-happy-schema") + docID := seedDoc(t, cfg, clientID, "m3-reset-happy-doc") + assignSchemaForTest(t, cons, docID, uuid.UUID(schema.Id)) + + // Write two metadata versions so the deleted count is verifiable. + for range 2 { + metaBody := queryapi.CustomMetadataRequest{ + DocumentId: openapi_types.UUID(docID), + Metadata: map[string]interface{}{"name": "v"}, + } + ctx, rec := newCustomMetadataContext(t, http.MethodPost, metaBody) + require.NoError(t, cons.SetCustomMetadata(ctx)) + require.Equal(t, http.StatusCreated, rec.Code) + } + + // Call reset. + resetErr, rec := callReset(t, cons, docID) + requireHTTPStatus(t, resetErr, rec, http.StatusOK) + + var resp queryapi.DocumentMetadataResetResponse + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp)) + assert.Equal(t, openapi_types.UUID(docID), resp.DocumentId) + assert.Equal(t, int32(2), resp.MetadataVersionsDeleted) + assert.Equal(t, testActorSubject, resp.ResetBy) + assert.False(t, resp.ResetAt.IsZero(), "resetAt must be populated") + + // Previous schema fields must reflect the just-cleared binding. + prevID, err := resp.PreviousSchemaId.Get() + require.NoError(t, err, "previousSchemaId must be non-null after a bound reset") + assert.Equal(t, schema.Id, prevID) + + prevName, err := resp.PreviousSchemaName.Get() + require.NoError(t, err, "previousSchemaName must be non-null after a bound reset") + assert.Equal(t, "m3-reset-happy-schema", prevName) + + _, err = resp.PreviousSchemaVersion.Get() + assert.NoError(t, err, "previousSchemaVersion must be non-null after a bound reset") + + // DB state: all metadata rows deleted, custom_schema_id cleared. + var metaCount int + err = cfg.GetDBPool().QueryRow(t.Context(), + `SELECT COUNT(*) FROM document_custom_metadata WHERE document_id = $1`, docID). + Scan(&metaCount) + require.NoError(t, err) + assert.Equal(t, 0, metaCount, "reset must delete all metadata rows") + + var customSchemaID *uuid.UUID + err = cfg.GetDBPool().QueryRow(t.Context(), + `SELECT custom_schema_id FROM documents WHERE id = $1`, docID). + Scan(&customSchemaID) + require.NoError(t, err) + assert.Nil(t, customSchemaID, "reset must set custom_schema_id to NULL") +} + +// TestM3_ResetDocumentMetadata_IdempotentOnCleanDoc exercises the idempotent +// path: resetting an already-clean document (no schema bound, no metadata) +// returns 200 with metadataVersionsDeleted == 0 and null previous schema fields. +func TestM3_ResetDocumentMetadata_IdempotentOnCleanDoc(t *testing.T) { + if testing.Short() { + t.SkipNow() + } + const clientID = "M3_RST_IDEM" + cons, cfg := setupM3Fixture(t, clientID) + + docID := seedDoc(t, cfg, clientID, "m3-reset-idem-doc") + + resetErr, rec := callReset(t, cons, docID) + requireHTTPStatus(t, resetErr, rec, http.StatusOK) + + var resp queryapi.DocumentMetadataResetResponse + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp)) + assert.Equal(t, int32(0), resp.MetadataVersionsDeleted) + + _, err := resp.PreviousSchemaId.Get() + assert.Error(t, err, "clean doc must report null previousSchemaId") + + _, err = resp.PreviousSchemaName.Get() + assert.Error(t, err, "clean doc must report null previousSchemaName") +} + +// TestM3_FullUpgradeFlow exercises the complete schema upgrade story: +// +// 1. Create schema v1 +// 2. Bind document to v1 +// 3. POST metadata against v1 +// 4. Create schema v2 from v1 (POST .../versions with an added optional field) +// 5. Reset-metadata (must return 200, metadataVersionsDeleted=1, previousSchemaId=v1.id) +// 6. Assign v2 to the document +// 7. POST metadata with v2 shape (include the new optional field) +// 8. Assert final DB state: exactly 1 metadata row, doc's custom_schema_id = v2.id +func TestM3_FullUpgradeFlow(t *testing.T) { + if testing.Short() { + t.SkipNow() + } + const clientID = "M3_UPGRADE" + cons, cfg := setupM3Fixture(t, clientID) + + // Step 1: Create schema v1 via the handler. + v1 := seedCustomSchema(t, cons, clientID, "m3-upgrade-lineage") + + // Step 2: Create a document and bind it to v1. + docID := seedDoc(t, cfg, clientID, "m3-upgrade-doc") + assignSchemaForTest(t, cons, docID, uuid.UUID(v1.Id)) + + // Step 3: POST metadata against the v1 binding. + metaV1Body := queryapi.CustomMetadataRequest{ + DocumentId: openapi_types.UUID(docID), + Metadata: map[string]interface{}{"name": "original-contract"}, + } + ctxMeta1, recMeta1 := newCustomMetadataContext(t, http.MethodPost, metaV1Body) + require.NoError(t, cons.SetCustomMetadata(ctxMeta1)) + require.Equal(t, http.StatusCreated, recMeta1.Code) + + // Step 4: Create v2 from v1 — the schema definition adds an optional "notes" field. + v2SchemaDef := map[string]interface{}{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": map[string]interface{}{ + "name": map[string]interface{}{"type": "string"}, + "notes": map[string]interface{}{"type": "string"}, + }, + "additionalProperties": false, + } + v2Body := queryapi.CustomSchemaVersionRequest{Schema: v2SchemaDef} + ctxV2, recV2 := newCustomSchemaContext(t, http.MethodPost, v2Body) + require.NoError(t, cons.CreateCustomSchemaVersion(ctxV2, v1.Id)) + require.Equal(t, http.StatusCreated, recV2.Code) + + var v2 queryapi.CustomSchemaResponse + require.NoError(t, json.Unmarshal(recV2.Body.Bytes(), &v2)) + require.Equal(t, int32(2), v2.Version) + require.Equal(t, queryapi.SchemaStatusActive, v2.Status) + + // Step 5: Reset-metadata — must wipe the v1 metadata and clear the schema binding. + resetErr, recReset := callReset(t, cons, docID) + requireHTTPStatus(t, resetErr, recReset, http.StatusOK) + + var resetResp queryapi.DocumentMetadataResetResponse + require.NoError(t, json.Unmarshal(recReset.Body.Bytes(), &resetResp)) + assert.Equal(t, int32(1), resetResp.MetadataVersionsDeleted, + "reset must report exactly 1 metadata version deleted") + + prevID, err := resetResp.PreviousSchemaId.Get() + require.NoError(t, err, "previousSchemaId must be non-null") + assert.Equal(t, v1.Id, prevID, "previousSchemaId must be the v1 schema") + + // Step 6: Assign v2 to the document. + assignSchemaForTest(t, cons, docID, uuid.UUID(v2.Id)) + + // Step 7: POST metadata with v2 shape (includes the new optional "notes" field). + metaV2Body := queryapi.CustomMetadataRequest{ + DocumentId: openapi_types.UUID(docID), + Metadata: map[string]interface{}{"name": "upgraded-contract", "notes": "added in v2"}, + } + ctxMeta2, recMeta2 := newCustomMetadataContext(t, http.MethodPost, metaV2Body) + require.NoError(t, cons.SetCustomMetadata(ctxMeta2)) + require.Equal(t, http.StatusCreated, recMeta2.Code) + + // Step 8: Assert final DB state. + // 8a: Exactly 1 metadata row for this document (the v2 write; v1 was deleted by reset). + var metaCount int + err = cfg.GetDBPool().QueryRow(t.Context(), + `SELECT COUNT(*) FROM document_custom_metadata WHERE document_id = $1`, docID). + Scan(&metaCount) + require.NoError(t, err) + assert.Equal(t, 1, metaCount, "after upgrade there must be exactly 1 metadata row (the v2 write)") + + // 8b: Document's custom_schema_id must be v2's ID. + var boundSchemaID *uuid.UUID + err = cfg.GetDBPool().QueryRow(t.Context(), + `SELECT custom_schema_id FROM documents WHERE id = $1`, docID). + Scan(&boundSchemaID) + require.NoError(t, err) + require.NotNil(t, boundSchemaID, "document must have a schema binding after reassignment") + assert.Equal(t, uuid.UUID(v2.Id), *boundSchemaID, "document must be bound to v2") +} + +// TestM3_AuthMatrix_Reset exercises the authorization matrix for +// POST /super-admin/documents/{id}/reset-metadata: +// +// super_admin -> 200 +// user_admin -> 403 +// client_user -> 403 +// auditor -> 403 +// +// For denied roles: assert the DB has zero metadata rows changed (nothing +// was written or deleted). +// For the allowed role (super_admin): need a document with a schema binding +// and at least 1 metadata row, call reset, assert 200. +func TestM3_AuthMatrix_Reset(t *testing.T) { + if testing.Short() { + t.SkipNow() + } + const clientID = "M3_AUTHZ_RST" + cons, cfg := setupM3Fixture(t, clientID) + + // Seed a document with a schema + metadata so the super_admin positive + // case has something to reset and the denial cases can verify the DB + // is unchanged. + schema := seedCustomSchema(t, cons, clientID, "m3-authz-reset-schema") + docID := seedDoc(t, cfg, clientID, "m3-authz-reset-doc") + assignSchemaForTest(t, cons, docID, uuid.UUID(schema.Id)) + + metaBody := queryapi.CustomMetadataRequest{ + DocumentId: openapi_types.UUID(docID), + Metadata: map[string]interface{}{"name": "authz-test"}, + } + ctxMeta, recMeta := newCustomMetadataContext(t, http.MethodPost, metaBody) + require.NoError(t, cons.SetCustomMetadata(ctxMeta)) + require.Equal(t, http.StatusCreated, recMeta.Code) + + resetPath := "/super-admin/documents/" + docID.String() + "/reset-metadata" + + rolesUnderTest := []struct { + role string + expectAllow bool + }{ + {role: "super_admin", expectAllow: true}, + {role: "user_admin", expectAllow: false}, + {role: "client_user", expectAllow: false}, + {role: "auditor", expectAllow: false}, + } + + countMeta := func(t *testing.T) int { + t.Helper() + var n int + require.NoError(t, + cfg.GetDBPool().QueryRow(t.Context(), + `SELECT COUNT(*) FROM document_custom_metadata WHERE document_id = $1`, + docID).Scan(&n)) + return n + } + + for _, rt := range rolesUnderTest { + rt := rt + t.Run(rt.role, func(t *testing.T) { + beforeCount := countMeta(t) + + // Build the echo context for the reset handler. + ctx, rec := newCustomSchemaContext(t, http.MethodPost, nil) + ctx.Request().URL.Path = resetPath + + handlerCalled := false + wrappedHandler := func(ec echo.Context) error { + handlerCalled = true + return cons.ResetDocumentMetadata(ec, openapi_types.UUID(docID)) + } + checker := &roleAwarePermitChecker{role: rt.role} + + err := runPermitGate(ctx, resetPath, checker, wrappedHandler) + + if rt.expectAllow { + require.NoError(t, err, "super_admin must be allowed") + assert.True(t, handlerCalled, "handler must be called for super_admin") + assert.Equal(t, http.StatusOK, rec.Code, "reset must return 200 for super_admin") + } else { + assert.False(t, handlerCalled, + "handler must NOT be called for role %s", rt.role) + assert.Equal(t, http.StatusForbidden, rec.Code, + "reset must return 403 for role %s", rt.role) + + // DB unchanged: no metadata rows were deleted. + afterCount := countMeta(t) + assert.Equal(t, beforeCount, afterCount, + "denied request must leave metadata row count unchanged for role %s", rt.role) + } + }) + } +} diff --git a/api/queryAPI/customschemas_test.go b/api/queryAPI/customschemas_test.go new file mode 100644 index 00000000..cd2d0980 --- /dev/null +++ b/api/queryAPI/customschemas_test.go @@ -0,0 +1,595 @@ +package queryapi_test + +import ( + "bytes" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "testing" + + queryapi "queryorchestration/api/queryAPI" + "queryorchestration/internal/cognitoauth" + "queryorchestration/internal/customschema" + "queryorchestration/internal/database/repository" + "queryorchestration/internal/test" + + "github.com/google/uuid" + "github.com/labstack/echo/v4" + openapi_types "github.com/oapi-codegen/runtime/types" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// testActorSubject is the fake JWT subject the custom schema handler tests +// inject into the echo context via ctx.Set("user_claims", ...). The value +// is an opaque UUID to match the plan's "createdBy is the Cognito subject, +// not an email" rule. +const testActorSubject = "b7a4c1d2-8e3f-4a5b-9c6d-0e1f2a3b4c5d" + +// trivialValidSchemaJSON is a minimal JSON Schema document that passes every +// rule in customschema.SchemaValidator: draft 2020-12, root type=object, +// additionalProperties explicitly declared. +const trivialValidSchemaJSON = `{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": {"name": {"type": "string"}}, + "additionalProperties": false +}` + +// newCustomSchemaContext creates an echo context with the given method, +// optional JSON body, and the standard test subject injected as +// user_claims["sub"]. It is the equivalent of the eula test harness's +// ctx.Set("user_claims", ...) pattern — there is no package-level +// SetUserSubject helper, so we mirror the same approach used by +// eulaHandlers_test.go. +func newCustomSchemaContext(t testing.TB, method string, body interface{}) (echo.Context, *httptest.ResponseRecorder) { + t.Helper() + e := echo.New() + + var reqBody []byte + if body != nil { + var err error + reqBody, err = json.Marshal(body) + require.NoError(t, err) + } + + req := httptest.NewRequest(method, "/", bytes.NewReader(reqBody)) + if reqBody != nil { + req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON) + } + rec := httptest.NewRecorder() + ctx := e.NewContext(req, rec) + ctx.Set("user_claims", map[string]interface{}{"sub": testActorSubject}) + ctx.Set("user_info", cognitoauth.UserInfo{Username: "admin"}) + return ctx, rec +} + +// newCustomSchemaContextRaw is identical to newCustomSchemaContext but +// accepts a pre-marshaled raw body so tests can send arbitrary JSON with +// extra fields (e.g., the injected createdBy attack vector). +func newCustomSchemaContextRaw(t testing.TB, method string, rawBody []byte) (echo.Context, *httptest.ResponseRecorder) { + t.Helper() + e := echo.New() + req := httptest.NewRequest(method, "/", bytes.NewReader(rawBody)) + if rawBody != nil { + req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON) + } + rec := httptest.NewRecorder() + ctx := e.NewContext(req, rec) + ctx.Set("user_claims", map[string]interface{}{"sub": testActorSubject}) + ctx.Set("user_info", cognitoauth.UserInfo{Username: "admin"}) + return ctx, rec +} + +// resetClientForSchemaTests cascades a client and every FK-bound child +// row out of the database so reruns of the same test start from a clean +// slate even when the testcontainer Postgres volume is reused across +// invocations. The delete order follows the FK graph: +// - documents.custom_schema_id must be NULL'd before we drop schemas +// - client_metadata_schemas rows must go before documents (audit lineage +// is independent, so this ordering is driven by the FKs alone) +// - documents before folders (documents.folderId) +// - folders before clients (folders.clientid) +// - clients last +// +// We do not use client.Service.HardDelete here because it collects S3 +// paths and runs a full document-cascade pipeline that is unnecessary for +// schema-handler tests — we just need the rows gone. +func resetClientForSchemaTests(t testing.TB, cfg *ControllerConfig, clientID string) { + t.Helper() + ctx := t.Context() + pool := cfg.GetDBPool() + + stmts := []string{ + // Delete custom metadata rows first. Trigger 1 (migration 130) blocks + // UPDATE documents SET custom_schema_id = NULL when metadata rows exist, + // so we must remove them before clearing the FK column. + `DELETE FROM document_custom_metadata WHERE document_id IN (SELECT id FROM documents WHERE clientId = $1)`, + `UPDATE documents SET custom_schema_id = NULL WHERE clientId = $1`, + `DELETE FROM client_metadata_schemas WHERE client_id = $1`, + `DELETE FROM documentEntries WHERE documentId IN (SELECT id FROM documents WHERE clientId = $1)`, + // documentFieldExtractionVersions has a FK referencing documentFieldExtractions; + // delete versions first, then extractions, then documents. + `DELETE FROM documentFieldExtractionVersions WHERE documentId IN (SELECT id FROM documents WHERE clientId = $1)`, + `DELETE FROM documentFieldExtractions WHERE documentId IN (SELECT id FROM documents WHERE clientId = $1)`, + `DELETE FROM documents WHERE clientId = $1`, + `DELETE FROM folders WHERE clientId = $1`, + `DELETE FROM clientCanSync WHERE clientId = $1`, + `DELETE FROM clients WHERE clientId = $1`, + } + for _, stmt := range stmts { + _, err := pool.Exec(ctx, stmt, clientID) + require.NoErrorf(t, err, "reset stmt failed: %s", stmt) + } +} + +// mustValidSchema returns a map[string]interface{} that decodes the +// trivialValidSchemaJSON bytes, so test request bodies can reuse it without +// repeating the literal. +func mustValidSchema(t testing.TB) map[string]interface{} { + t.Helper() + var m map[string]interface{} + require.NoError(t, json.Unmarshal([]byte(trivialValidSchemaJSON), &m)) + return m +} + +// seedCustomSchema creates a fresh schema row through the real handler so +// downstream tests get a realistic active v1 to work with. It returns the +// parsed CustomSchemaResponse. +func seedCustomSchema(t *testing.T, cons *queryapi.Controllers, clientID, name string) queryapi.CustomSchemaResponse { + t.Helper() + descr := "seeded schema " + name + body := queryapi.CustomSchemaRequest{ + ClientId: clientID, + Name: name, + Description: &descr, + Schema: mustValidSchema(t), + } + ctx, rec := newCustomSchemaContext(t, http.MethodPost, body) + require.NoError(t, cons.CreateCustomSchema(ctx)) + require.Equal(t, http.StatusCreated, rec.Code) + + var resp queryapi.CustomSchemaResponse + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp)) + return resp +} + +// ---------- CreateCustomSchema ---------- + +func TestCreateCustomSchema_Handler(t *testing.T) { + if testing.Short() { + t.SkipNow() + } + cfg := &ControllerConfig{} + test.CreateDB(t, cfg) + initializeTestConfig(t, cfg) + + svc := createControllerServices(cfg) + cons := queryapi.NewControllers(svc, cfg) + + const clientID = "HAND_CS_C1" + resetClientForSchemaTests(t, cfg, clientID) + test.CreateTestClient(t, cfg, clientID, "Hand CS Client 1") + + t.Run("happy_path", func(t *testing.T) { + descr := "aircraft engineering fields" + body := queryapi.CustomSchemaRequest{ + ClientId: clientID, + Name: "aircraft-engineering", + Description: &descr, + Schema: mustValidSchema(t), + } + ctx, rec := newCustomSchemaContext(t, http.MethodPost, body) + + require.NoError(t, cons.CreateCustomSchema(ctx)) + assert.Equal(t, http.StatusCreated, rec.Code) + + var resp queryapi.CustomSchemaResponse + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp)) + assert.Equal(t, clientID, resp.ClientId) + assert.Equal(t, "aircraft-engineering", resp.Name) + assert.Equal(t, int32(1), resp.Version) + assert.Equal(t, queryapi.SchemaStatusActive, resp.Status) + assert.Equal(t, testActorSubject, resp.CreatedBy) + assert.NotEqual(t, uuid.Nil, uuid.UUID(resp.Id)) + + // DB round-trip: the row must exist. + row, err := cfg.GetDBQueries().GetClientMetadataSchema(t.Context(), uuid.UUID(resp.Id)) + require.NoError(t, err) + assert.Equal(t, clientID, row.ClientID) + assert.Equal(t, "aircraft-engineering", row.Name) + assert.Equal(t, testActorSubject, row.CreatedBy) + }) + + t.Run("body_created_by_is_ignored", func(t *testing.T) { + // Attacker sends a createdBy key in the raw body. The generated + // CustomSchemaRequest type has no CreatedBy field, so c.Bind will + // silently drop it. The handler must still produce the JWT subject. + rawBody := []byte(fmt.Sprintf(`{ + "clientId": %q, + "name": "attacker-created", + "description": "ignored", + "schema": %s, + "createdBy": "attacker-uuid-00000000-0000-0000-0000-000000000000" + }`, clientID, trivialValidSchemaJSON)) + ctx, rec := newCustomSchemaContextRaw(t, http.MethodPost, rawBody) + + require.NoError(t, cons.CreateCustomSchema(ctx)) + assert.Equal(t, http.StatusCreated, rec.Code) + + var resp queryapi.CustomSchemaResponse + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp)) + assert.Equal(t, testActorSubject, resp.CreatedBy, + "createdBy must be the JWT subject, never a body-supplied value") + }) + + t.Run("invalid_schema_rejected", func(t *testing.T) { + // Missing additionalProperties — validator rule 5. + invalid := map[string]interface{}{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + } + body := queryapi.CustomSchemaRequest{ + ClientId: clientID, + Name: "invalid-schema-case", + Schema: invalid, + } + ctx, rec := newCustomSchemaContext(t, http.MethodPost, body) + + err := cons.CreateCustomSchema(ctx) + require.Error(t, err) + httpErr, ok := err.(*echo.HTTPError) + require.True(t, ok) + assert.Equal(t, http.StatusBadRequest, httpErr.Code) + _ = rec // unused for error-path assertions + }) + + t.Run("duplicate_name_conflict", func(t *testing.T) { + // Insert one with a specific name, then try again. + descr := "first" + firstBody := queryapi.CustomSchemaRequest{ + ClientId: clientID, + Name: "duplicate-name-case", + Description: &descr, + Schema: mustValidSchema(t), + } + ctx1, rec1 := newCustomSchemaContext(t, http.MethodPost, firstBody) + require.NoError(t, cons.CreateCustomSchema(ctx1)) + require.Equal(t, http.StatusCreated, rec1.Code) + + ctx2, _ := newCustomSchemaContext(t, http.MethodPost, firstBody) + err := cons.CreateCustomSchema(ctx2) + require.Error(t, err) + httpErr, ok := err.(*echo.HTTPError) + require.True(t, ok) + assert.Equal(t, http.StatusConflict, httpErr.Code) + }) + + t.Run("oversize_schema_rejected", func(t *testing.T) { + // Build a valid schema and pad it past the 64 KB ceiling enforced + // by MaxSchemaDefinitionBytes. We stuff a large description into + // the properties map so the JSON is still well-formed. + bigPad := strings.Repeat("x", customschema.MaxSchemaDefinitionBytes) + oversize := map[string]interface{}{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "additionalProperties": false, + "description": bigPad, + } + body := queryapi.CustomSchemaRequest{ + ClientId: clientID, + Name: "oversize-case", + Schema: oversize, + } + ctx, _ := newCustomSchemaContext(t, http.MethodPost, body) + err := cons.CreateCustomSchema(ctx) + require.Error(t, err) + httpErr, ok := err.(*echo.HTTPError) + require.True(t, ok) + assert.Equal(t, http.StatusBadRequest, httpErr.Code) + }) +} + +// ---------- ListCustomSchemas ---------- + +func TestListCustomSchemas_Handler(t *testing.T) { + if testing.Short() { + t.SkipNow() + } + cfg := &ControllerConfig{} + test.CreateDB(t, cfg) + initializeTestConfig(t, cfg) + + svc := createControllerServices(cfg) + cons := queryapi.NewControllers(svc, cfg) + + const clientID = "HAND_CS_C2" + resetClientForSchemaTests(t, cfg, clientID) + test.CreateTestClient(t, cfg, clientID, "Hand CS Client 2") + + // Seed three schemas: two independent lineages plus a second version of + // one of them, so default-filter results should return 2 rows (active + // latest per lineage) and includeAllVersions+status=any should return 3. + schemaA := seedCustomSchema(t, cons, clientID, "alpha") + _ = seedCustomSchema(t, cons, clientID, "beta") + + // Bump alpha to v2 — supersedes v1. + bumpBody := queryapi.CustomSchemaVersionRequest{Schema: mustValidSchema(t)} + ctxBump, recBump := newCustomSchemaContext(t, http.MethodPost, bumpBody) + require.NoError(t, cons.CreateCustomSchemaVersion(ctxBump, schemaA.Id)) + require.Equal(t, http.StatusCreated, recBump.Code) + + t.Run("default_active_latest_only", func(t *testing.T) { + ctx, rec := newCustomSchemaContext(t, http.MethodGet, nil) + params := queryapi.ListCustomSchemasParams{ClientId: clientID} + + require.NoError(t, cons.ListCustomSchemas(ctx, params)) + require.Equal(t, http.StatusOK, rec.Code) + + var resp queryapi.CustomSchemaListResponse + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp)) + require.Len(t, resp.Schemas, 2, "default filter should return 2 active-latest rows") + names := []string{resp.Schemas[0].Name, resp.Schemas[1].Name} + assert.Contains(t, names, "alpha") + assert.Contains(t, names, "beta") + for _, row := range resp.Schemas { + assert.Equal(t, queryapi.SchemaStatusActive, row.Status) + } + }) + + t.Run("status_any_include_all_versions", func(t *testing.T) { + ctx, rec := newCustomSchemaContext(t, http.MethodGet, nil) + status := queryapi.ListCustomSchemasParamsStatusAny + includeAll := true + params := queryapi.ListCustomSchemasParams{ + ClientId: clientID, + Status: &status, + IncludeAllVersions: &includeAll, + } + require.NoError(t, cons.ListCustomSchemas(ctx, params)) + require.Equal(t, http.StatusOK, rec.Code) + + var resp queryapi.CustomSchemaListResponse + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp)) + require.Len(t, resp.Schemas, 3, + "status=any + includeAllVersions should return alpha v1, alpha v2, and beta v1") + }) + + t.Run("pagination_limit_one", func(t *testing.T) { + ctx, rec := newCustomSchemaContext(t, http.MethodGet, nil) + limit := int32(1) + offset := int32(0) + params := queryapi.ListCustomSchemasParams{ + ClientId: clientID, + Limit: &limit, + Offset: &offset, + } + require.NoError(t, cons.ListCustomSchemas(ctx, params)) + require.Equal(t, http.StatusOK, rec.Code) + + var resp queryapi.CustomSchemaListResponse + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp)) + require.Len(t, resp.Schemas, 1) + }) + + t.Run("limit_zero_rejected", func(t *testing.T) { + ctx, _ := newCustomSchemaContext(t, http.MethodGet, nil) + limit := int32(0) + params := queryapi.ListCustomSchemasParams{ClientId: clientID, Limit: &limit} + err := cons.ListCustomSchemas(ctx, params) + require.Error(t, err) + httpErr, ok := err.(*echo.HTTPError) + require.True(t, ok) + assert.Equal(t, http.StatusBadRequest, httpErr.Code) + }) + + t.Run("limit_over_max_rejected", func(t *testing.T) { + ctx, _ := newCustomSchemaContext(t, http.MethodGet, nil) + limit := int32(201) + params := queryapi.ListCustomSchemasParams{ClientId: clientID, Limit: &limit} + err := cons.ListCustomSchemas(ctx, params) + require.Error(t, err) + httpErr, ok := err.(*echo.HTTPError) + require.True(t, ok) + assert.Equal(t, http.StatusBadRequest, httpErr.Code) + }) + + t.Run("negative_offset_rejected", func(t *testing.T) { + ctx, _ := newCustomSchemaContext(t, http.MethodGet, nil) + offset := int32(-1) + params := queryapi.ListCustomSchemasParams{ClientId: clientID, Offset: &offset} + err := cons.ListCustomSchemas(ctx, params) + require.Error(t, err) + httpErr, ok := err.(*echo.HTTPError) + require.True(t, ok) + assert.Equal(t, http.StatusBadRequest, httpErr.Code) + }) + + t.Run("missing_client_id_rejected", func(t *testing.T) { + ctx, _ := newCustomSchemaContext(t, http.MethodGet, nil) + params := queryapi.ListCustomSchemasParams{ClientId: ""} + err := cons.ListCustomSchemas(ctx, params) + require.Error(t, err) + httpErr, ok := err.(*echo.HTTPError) + require.True(t, ok) + assert.Equal(t, http.StatusBadRequest, httpErr.Code) + }) +} + +// ---------- GetCustomSchema ---------- + +func TestGetCustomSchema_Handler(t *testing.T) { + if testing.Short() { + t.SkipNow() + } + cfg := &ControllerConfig{} + test.CreateDB(t, cfg) + initializeTestConfig(t, cfg) + + svc := createControllerServices(cfg) + cons := queryapi.NewControllers(svc, cfg) + + const clientID = "HAND_CS_C3" + resetClientForSchemaTests(t, cfg, clientID) + test.CreateTestClient(t, cfg, clientID, "Hand CS Client 3") + + seeded := seedCustomSchema(t, cons, clientID, "get-happy") + + t.Run("happy_path", func(t *testing.T) { + ctx, rec := newCustomSchemaContext(t, http.MethodGet, nil) + require.NoError(t, cons.GetCustomSchema(ctx, seeded.Id)) + require.Equal(t, http.StatusOK, rec.Code) + + var resp queryapi.CustomSchemaResponse + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp)) + assert.Equal(t, seeded.Id, resp.Id) + assert.Equal(t, "get-happy", resp.Name) + assert.NotNil(t, resp.Schema) + // No documents bound — count must be live 0, canDelete must be true. + assert.Equal(t, int32(0), resp.DocumentCount) + assert.True(t, resp.CanDelete) + }) + + t.Run("missing_id_returns_404", func(t *testing.T) { + missingID := openapi_types.UUID(uuid.New()) + ctx, _ := newCustomSchemaContext(t, http.MethodGet, nil) + err := cons.GetCustomSchema(ctx, missingID) + require.Error(t, err) + httpErr, ok := err.(*echo.HTTPError) + require.True(t, ok) + assert.Equal(t, http.StatusNotFound, httpErr.Code) + }) +} + +// ---------- CreateCustomSchemaVersion ---------- + +func TestCreateCustomSchemaVersion_Handler(t *testing.T) { + if testing.Short() { + t.SkipNow() + } + cfg := &ControllerConfig{} + test.CreateDB(t, cfg) + initializeTestConfig(t, cfg) + + svc := createControllerServices(cfg) + cons := queryapi.NewControllers(svc, cfg) + + const clientID = "HAND_CS_C4" + resetClientForSchemaTests(t, cfg, clientID) + test.CreateTestClient(t, cfg, clientID, "Hand CS Client 4") + + t.Run("happy_path_bumps_to_v2_and_supersedes_parent", func(t *testing.T) { + parent := seedCustomSchema(t, cons, clientID, "version-happy") + + body := queryapi.CustomSchemaVersionRequest{Schema: mustValidSchema(t)} + ctx, rec := newCustomSchemaContext(t, http.MethodPost, body) + require.NoError(t, cons.CreateCustomSchemaVersion(ctx, parent.Id)) + require.Equal(t, http.StatusCreated, rec.Code) + + var resp queryapi.CustomSchemaResponse + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp)) + assert.Equal(t, int32(2), resp.Version) + assert.Equal(t, queryapi.SchemaStatusActive, resp.Status) + assert.NotEqual(t, parent.Id, resp.Id, "version bump must produce a new row id") + + // Parent must now be superseded in the DB. + parentRow, err := cfg.GetDBQueries().GetClientMetadataSchema(t.Context(), uuid.UUID(parent.Id)) + require.NoError(t, err) + assert.Equal(t, repository.SchemaStatusTypeSuperseded, parentRow.Status) + }) + + t.Run("missing_parent_returns_404", func(t *testing.T) { + body := queryapi.CustomSchemaVersionRequest{Schema: mustValidSchema(t)} + ctx, _ := newCustomSchemaContext(t, http.MethodPost, body) + err := cons.CreateCustomSchemaVersion(ctx, openapi_types.UUID(uuid.New())) + require.Error(t, err) + httpErr, ok := err.(*echo.HTTPError) + require.True(t, ok) + assert.Equal(t, http.StatusNotFound, httpErr.Code) + }) + + t.Run("non_active_parent_returns_409", func(t *testing.T) { + // Seed v1, bump to v2 (making v1 superseded), then try to version + // off v1 again. + parent := seedCustomSchema(t, cons, clientID, "version-nonactive") + + bumpBody := queryapi.CustomSchemaVersionRequest{Schema: mustValidSchema(t)} + ctxBump, recBump := newCustomSchemaContext(t, http.MethodPost, bumpBody) + require.NoError(t, cons.CreateCustomSchemaVersion(ctxBump, parent.Id)) + require.Equal(t, http.StatusCreated, recBump.Code) + + ctxTry, _ := newCustomSchemaContext(t, http.MethodPost, bumpBody) + err := cons.CreateCustomSchemaVersion(ctxTry, parent.Id) + require.Error(t, err) + httpErr, ok := err.(*echo.HTTPError) + require.True(t, ok) + assert.Equal(t, http.StatusConflict, httpErr.Code) + }) +} + +// ---------- DeleteCustomSchema ---------- + +func TestDeleteCustomSchema_Handler(t *testing.T) { + if testing.Short() { + t.SkipNow() + } + cfg := &ControllerConfig{} + test.CreateDB(t, cfg) + initializeTestConfig(t, cfg) + + svc := createControllerServices(cfg) + cons := queryapi.NewControllers(svc, cfg) + + const clientID = "HAND_CS_C5" + resetClientForSchemaTests(t, cfg, clientID) + test.CreateTestClient(t, cfg, clientID, "Hand CS Client 5") + + t.Run("happy_path_unused_schema", func(t *testing.T) { + seeded := seedCustomSchema(t, cons, clientID, "delete-happy") + ctx, rec := newCustomSchemaContext(t, http.MethodDelete, nil) + require.NoError(t, cons.DeleteCustomSchema(ctx, seeded.Id)) + assert.Equal(t, http.StatusNoContent, rec.Code) + + // DB: row must now be retired. + row, err := cfg.GetDBQueries().GetClientMetadataSchema(t.Context(), uuid.UUID(seeded.Id)) + require.NoError(t, err) + assert.Equal(t, repository.SchemaStatusTypeRetired, row.Status) + }) + + t.Run("bound_to_document_returns_409", func(t *testing.T) { + seeded := seedCustomSchema(t, cons, clientID, "delete-bound") + + // Bind a document to the seeded schema. + docID, err := cfg.GetDBQueries().CreateDocument(t.Context(), &repository.CreateDocumentParams{ + Clientid: clientID, + Hash: "delete-bound-hash", + }) + require.NoError(t, err) + _, err = cfg.GetDBPool().Exec(t.Context(), + `UPDATE documents SET custom_schema_id = $1 WHERE id = $2`, uuid.UUID(seeded.Id), docID) + require.NoError(t, err) + + ctx, _ := newCustomSchemaContext(t, http.MethodDelete, nil) + err = cons.DeleteCustomSchema(ctx, seeded.Id) + require.Error(t, err) + httpErr, ok := err.(*echo.HTTPError) + require.True(t, ok) + assert.Equal(t, http.StatusConflict, httpErr.Code) + + // Unbind so subsequent subtests / reruns don't see leftover FK state. + _, err = cfg.GetDBPool().Exec(t.Context(), + `UPDATE documents SET custom_schema_id = NULL WHERE id = $1`, docID) + require.NoError(t, err) + }) + + t.Run("missing_returns_404", func(t *testing.T) { + ctx, _ := newCustomSchemaContext(t, http.MethodDelete, nil) + err := cons.DeleteCustomSchema(ctx, openapi_types.UUID(uuid.New())) + require.Error(t, err) + httpErr, ok := err.(*echo.HTTPError) + require.True(t, ok) + assert.Equal(t, http.StatusNotFound, httpErr.Code) + }) +} diff --git a/api/queryAPI/documents.go b/api/queryAPI/documents.go index d3ee1a98..80a22c19 100644 --- a/api/queryAPI/documents.go +++ b/api/queryAPI/documents.go @@ -126,6 +126,16 @@ func (s *Controllers) GetDocument(ctx echo.Context, id DocumentID, params GetDoc if document.FileSizeBytes != nil { response.FileSizeBytes = document.FileSizeBytes } + // Mutable-metadata feature (Milestone 2.3a/2.7): surface the two + // new fields directly from the DocumentEnriched service struct. + // No follow-up DB call here — the extended GetDocumentEnriched SQL + // query already populated both values in the single round-trip. + if document.CustomSchemaID != nil { + csid := openapi_types.UUID(*document.CustomSchemaID) + response.CustomSchemaId = &csid + } + hasCustomMetadata := document.HasCustomMetadata + response.HasCustomMetadata = &hasCustomMetadata // Include text record if requested and available if document.TextRecord != nil { diff --git a/api/queryAPI/documents_test.go b/api/queryAPI/documents_test.go index ca721aed..c2dd03f5 100644 --- a/api/queryAPI/documents_test.go +++ b/api/queryAPI/documents_test.go @@ -113,12 +113,18 @@ func TestGetDocument(t *testing.T) { err = cons.GetDocument(ctx, docId, queryapi.GetDocumentParams{}) require.NoError(t, err) assert.Equal(t, http.StatusOK, rec.Code) + // Milestone 2.3a adds two new fields to DocumentEnriched. They are + // populated unconditionally (HasCustomMetadata as a *bool pointing to + // false when the document has no metadata; CustomSchemaId stays nil + // when no schema is bound). + falseVal := false assertBody(t, rec, queryapi.DocumentEnriched{ - Id: docId, - ClientId: "client_id", - Hash: "hash", - HasTextRecord: false, - Labels: []queryapi.LabelRecord{}, + Id: docId, + ClientId: "client_id", + Hash: "hash", + HasTextRecord: false, + Labels: []queryapi.LabelRecord{}, + HasCustomMetadata: &falseVal, }) } diff --git a/api/queryAPI/fieldextractions.go b/api/queryAPI/fieldextractions.go index 8883a9dd..5bb2c989 100644 --- a/api/queryAPI/fieldextractions.go +++ b/api/queryAPI/fieldextractions.go @@ -1,6 +1,7 @@ package queryapi import ( + "errors" "fmt" "log/slog" "net/http" @@ -10,6 +11,7 @@ import ( "queryorchestration/internal/fieldextraction" "github.com/google/uuid" + "github.com/jackc/pgx/v5/pgconn" "github.com/jackc/pgx/v5/pgtype" "github.com/labstack/echo/v4" openapi_types "github.com/oapi-codegen/runtime/types" @@ -45,6 +47,24 @@ func (s *Controllers) CreateFieldExtraction(ctx echo.Context) error { // Create field extraction via service extraction, err := s.svc.FieldExtraction.CreateFieldExtraction(ctx.Request().Context(), input) if err != nil { + // Milestone 2.5: map the new typed sentinels from the service + // layer plus the Postgres check_violation (Trigger 2 backstop) + // onto clean HTTP status codes before falling through to 500. + if errors.Is(err, fieldextraction.ErrDocumentNotFound) { + return echo.NewHTTPError(http.StatusNotFound, "document not found") + } + if errors.Is(err, fieldextraction.ErrMutualExclusivityViolation) { + return echo.NewHTTPError(http.StatusConflict, + "document is bound to a custom schema and cannot accept legacy field extractions") + } + var pgErr *pgconn.PgError + if errors.As(err, &pgErr) && pgErr.Code == "23514" { + // Trigger 2 fired. Defense in depth — service-layer guard + // already should have caught this, but if the lock path + // was bypassed (regression) the DB enforces the same 409. + return echo.NewHTTPError(http.StatusConflict, + "document is bound to a custom schema and cannot accept legacy field extractions") + } slog.Error("failed to create field extraction", "error", err, "document_id", documentID) return echo.NewHTTPError(http.StatusInternalServerError, "failed to create field extraction") } diff --git a/api/queryAPI/testutils_test.go b/api/queryAPI/testutils_test.go index 72c52795..960432c6 100644 --- a/api/queryAPI/testutils_test.go +++ b/api/queryAPI/testutils_test.go @@ -7,6 +7,7 @@ import ( clientupdate "queryorchestration/internal/client/update" "queryorchestration/internal/collector" collectorset "queryorchestration/internal/collector/set" + "queryorchestration/internal/customschema" "queryorchestration/internal/document" documentbatch "queryorchestration/internal/document/batch" documentdownload "queryorchestration/internal/document/download" @@ -55,6 +56,7 @@ func createControllerServices(cfg *ControllerConfig) *queryapi.Services { lbl := label.New(cfg) eul := eula.New(cfg) uiSvc := uisettings.New(cfg) + customSchema := customschema.New(cfg, &customschema.SchemaValidator{}, customschema.NewSlogAuditSink(cfg.GetLogger())) return &queryapi.Services{ Export: exp, @@ -68,6 +70,7 @@ func createControllerServices(cfg *ControllerConfig) *queryapi.Services { DocumentBatch: docbatch, UISettings: uiSvc, FieldExtraction: fieldext, + CustomSchema: customSchema, Folder: fld, Label: lbl, Eula: eul, diff --git a/cmd/auth_related/permit.setup/list_roles.sh b/cmd/auth_related/permit.setup/list_roles.sh new file mode 100755 index 00000000..a86dfbba --- /dev/null +++ b/cmd/auth_related/permit.setup/list_roles.sh @@ -0,0 +1,12 @@ +#!/bin/bash +# List all roles in the production environment + +export PERMIT_API_KEY="permit_key_UiT5HGXS2kwsWJbZOnzEI86Y2ut4tGqHQcTJoMP6XaqlKmlLFWMVLPwlx0faz35ABigea2htQ6RpGjRDLkLIkT" + +PROJECT_ID="default" +ENV_ID="production" + +echo "Fetching roles from Production environment..." +curl -s -X GET "https://api.permit.io/v2/schema/${PROJECT_ID}/${ENV_ID}/roles" \ + -H "Authorization: Bearer ${PERMIT_API_KEY}" \ + -H "Content-Type: application/json" | jq '.' diff --git a/cmd/auth_related/permit.setup/permit_policies.yaml b/cmd/auth_related/permit.setup/permit_policies.yaml index 253cb138..5a4d052f 100644 --- a/cmd/auth_related/permit.setup/permit_policies.yaml +++ b/cmd/auth_related/permit.setup/permit_policies.yaml @@ -68,6 +68,25 @@ resources: actions: - get + # v4 (mutable metadata) — super-admin resource covers every /super-admin/* + # path. No "put" action: the v4 "create a new schema version" route uses + # POST /super-admin/custom-schemas/{schemaId}/versions, not PUT. + - name: super-admin + description: Super-admin-only surface (schema CRUD, schema assignment, reset-metadata, bulk folder assignment) + actions: + - get + - post + - patch + - delete + + # v4 (mutable metadata) — custom-metadata resource covers the user-facing + # /custom-metadata/* routes that read and write validated custom metadata. + - name: custom-metadata + description: User-facing custom metadata CRUD on documents (read + write) + actions: + - get + - post + # Roles to be created in Permit.io roles: - name: super_admin @@ -109,6 +128,18 @@ roles: - delete labels: - get + # v4 (mutable metadata) — full super-admin surface. + super-admin: + - get + - post + - patch + - delete + # v4 (mutable metadata) — super_admin is also allowed to read and + # write custom metadata; the role is a strict superset of user_admin + # and client_user on this resource. + custom-metadata: + - get + - post - name: user_admin description: User management - primarily for creating new client users @@ -128,6 +159,14 @@ roles: folders: - get - post + # v4 (mutable metadata) — user_admin may read and write custom + # metadata on documents it manages, but deliberately does NOT get any + # super-admin action. Schema CRUD, assignment, reset, and bulk folder + # assignment stay super_admin-only. Verify no `super-admin:` entry + # appears in this block. + custom-metadata: + - get + - post - name: auditor description: Full read-only access across the entire system for auditing and compliance @@ -151,6 +190,13 @@ roles: - get labels: - get + # v4 (mutable metadata) — auditor gets read on both resources so + # compliance reviewers can inspect schema CRUD history and read the + # custom metadata on any document. No write actions. + super-admin: + - get + custom-metadata: + - get - name: client_user description: Client-specific access for external users (requires tenant filtering) @@ -178,6 +224,13 @@ roles: - patch labels: - get + # v4 (mutable metadata) — client_user may read and write custom + # metadata on its own documents (tenant isolation still a TODO at + # the application layer; see notes at the bottom of this file). + # No super-admin access. + custom-metadata: + - get + - post notes: | This role requires tenant-level filtering by client_id. Currently, client_user will have access to all clients (no tenant isolation). @@ -296,6 +349,44 @@ policy_mappings: methods: GET: folders:get + # v4 (mutable metadata) — every /super-admin/* path maps to the + # super-admin resource. Documentation only; the dynamic first-segment + # resource extractor derives the same mapping at request time. + super_admin_endpoints: + - path: /super-admin/custom-schemas + methods: + GET: super-admin:get + POST: super-admin:post + - path: /super-admin/custom-schemas/{schemaId} + methods: + GET: super-admin:get + DELETE: super-admin:delete + - path: /super-admin/custom-schemas/{schemaId}/versions + methods: + POST: super-admin:post + - path: /super-admin/documents/{id}/schema + methods: + PATCH: super-admin:patch + - path: /super-admin/documents/{id}/reset-metadata + methods: + POST: super-admin:post + - path: /super-admin/folders/{folderId}/assign-schema + methods: + POST: super-admin:post + + # v4 (mutable metadata) — user-facing custom metadata CRUD. + custom_metadata_endpoints: + - path: /custom-metadata + methods: + GET: custom-metadata:get + POST: custom-metadata:post + - path: /custom-metadata/version + methods: + GET: custom-metadata:get + - path: /custom-metadata/history + methods: + GET: custom-metadata:get + eula_endpoints: - path: /eula public: true diff --git a/cmd/auth_related/permit.setup/run.tool.dev.sh b/cmd/auth_related/permit.setup/run.tool.dev.sh new file mode 100755 index 00000000..ce88357c --- /dev/null +++ b/cmd/auth_related/permit.setup/run.tool.dev.sh @@ -0,0 +1,18 @@ +# dev environment key +export PERMIT_API_KEY="permit_key_nwcB5ebXeeZyyt684otN2h70P7EVYWvFjp88QLObZK0uBFLArgPTidmkMxXGGoMh0aSbLTZ8lp9Qg72bYFde1B" + +# production key +# export PERMIT_API_KEY="permit_key_UiT5HGXS2kwsWJbZOnzEI86Y2ut4tGqHQcTJoMP6XaqlKmlLFWMVLPwlx0faz35ABigea2htQ6RpGjRDLkLIkT" + + +# this tool can be run locally since it does not need aws enviroment + +# permit uat key +#export PERMIT_API_KEY="permit_key_VjXBwoTFVhWWNecOt49BcAjyWWBaM8vzO9IvqNfJnqrLotz6BiSfr3hHV9v5zWqSQjYerqrpMlhz0WKYaLWMOg" +go run setup_permit.go -list +# for dev setup (this works) +go run setup_permit.go -config permit_policies.yaml -project default -env 0551401b0e9e4075b14957506f3d6752 + +# for production (this does not work) +#go run setup_permit.go -config permit_policies.yaml -project default -env production + diff --git a/cmd/auth_related/permit.setup/run.tool.prod.sh b/cmd/auth_related/permit.setup/run.tool.prod.sh new file mode 100755 index 00000000..78dfb651 --- /dev/null +++ b/cmd/auth_related/permit.setup/run.tool.prod.sh @@ -0,0 +1,15 @@ +# dev environment key +# export PERMIT_API_KEY="permit_key_nwcB5ebXeeZyyt684otN2h70P7EVYWvFjp88QLObZK0uBFLArgPTidmkMxXGGoMh0aSbLTZ8lp9Qg72bYFde1B" +# production key +# export PERMIT_API_KEY="permit_key_UiT5HGXS2kwsWJbZOnzEI86Y2ut4tGqHQcTJoMP6XaqlKmlLFWMVLPwlx0faz35ABigea2htQ6RpGjRDLkLIkT" +# permit uat key + +# this tool can be run locally since it does not need aws enviroment +export PERMIT_API_KEY="permit_key_UiT5HGXS2kwsWJbZOnzEI86Y2ut4tGqHQcTJoMP6XaqlKmlLFWMVLPwlx0faz35ABigea2htQ6RpGjRDLkLIkT" +go run setup_permit.go -list +# production +go run setup_permit.go -config permit_policies.yaml -project default -env ba1a6110b24f44c1b00d8d7a00b8c7b7 + +# for production (this does not work) +#go run setup_permit.go -config permit_policies.yaml -project default -env production + diff --git a/cmd/auth_related/permit.setup/run.tool.uat.sh b/cmd/auth_related/permit.setup/run.tool.uat.sh new file mode 100755 index 00000000..5c8c1a22 --- /dev/null +++ b/cmd/auth_related/permit.setup/run.tool.uat.sh @@ -0,0 +1,17 @@ +# dev environment key +# export PERMIT_API_KEY="permit_key_nwcB5ebXeeZyyt684otN2h70P7EVYWvFjp88QLObZK0uBFLArgPTidmkMxXGGoMh0aSbLTZ8lp9Qg72bYFde1B" +# production key +# export PERMIT_API_KEY="permit_key_UiT5HGXS2kwsWJbZOnzEI86Y2ut4tGqHQcTJoMP6XaqlKmlLFWMVLPwlx0faz35ABigea2htQ6RpGjRDLkLIkT" + + +# this tool can be run locally since it does not need aws enviroment + +# permit uat key +export PERMIT_API_KEY="permit_key_VjXBwoTFVhWWNecOt49BcAjyWWBaM8vzO9IvqNfJnqrLotz6BiSfr3hHV9v5zWqSQjYerqrpMlhz0WKYaLWMOg" +go run setup_permit.go -list +# for dev setup (this works) +go run setup_permit.go -config permit_policies.yaml -project default -env uat + +# for production (this does not work) +#go run setup_permit.go -config permit_policies.yaml -project default -env production + diff --git a/cmd/queryAPI/main.go b/cmd/queryAPI/main.go index d90c1b61..3cfefada 100644 --- a/cmd/queryAPI/main.go +++ b/cmd/queryAPI/main.go @@ -26,6 +26,7 @@ import ( "queryorchestration/internal/cognitoauth" "queryorchestration/internal/collector" collectorset "queryorchestration/internal/collector/set" + "queryorchestration/internal/customschema" "queryorchestration/internal/document" documentbatch "queryorchestration/internal/document/batch" documentdownload "queryorchestration/internal/document/download" @@ -83,6 +84,7 @@ func main() { lbl := label.New(cfg) eul := eula.New(cfg) uiSvc := uisettings.New(cfg) + customSchema := customschema.New(cfg, &customschema.SchemaValidator{}, customschema.NewSlogAuditSink(cfg.GetLogger())) services := &queryapi.Services{ Export: exp, @@ -96,6 +98,7 @@ func main() { DocumentBatch: docbatch, UISettings: uiSvc, FieldExtraction: fieldext, + CustomSchema: customSchema, Folder: fld, Label: lbl, Eula: eul, diff --git a/docs/ai.generated/01-system-overview.md b/docs/ai.generated/01-system-overview.md index d4d48162..9294be68 100644 --- a/docs/ai.generated/01-system-overview.md +++ b/docs/ai.generated/01-system-overview.md @@ -86,6 +86,14 @@ graph TB All_Services --> PROM ``` +### API Layer Architecture + +The diagram below shows the internal layered architecture of the queryAPI service — how an inbound HTTP request passes through middleware, controllers, and services before reaching the database. + +[![Query Orchestration API — Software Architecture Layers](architecture-diagram.png)](architecture-diagram.svg) + +*[View scalable SVG version](architecture-diagram.svg)* + ## Deployment Architecture ### Infrastructure Requirements diff --git a/docs/ai.generated/03-api-documentation.md b/docs/ai.generated/03-api-documentation.md index 9eaefee5..7cb24565 100644 --- a/docs/ai.generated/03-api-documentation.md +++ b/docs/ai.generated/03-api-documentation.md @@ -29,6 +29,8 @@ The API is organized into the following service groups: | AdminService | Operations related to user management and administration | | EulaService | Operations related to End User License Agreement management and tracking | | UISettingsService | Operations related to UI-scoped key-value settings (per user, client, or global) | +| SuperAdminSchemaService | Operations related to custom schema management (super_admin only) | +| CustomMetadataService | Operations related to per-document custom metadata (client_user and above) | ## Authentication and Authorization @@ -1783,6 +1785,369 @@ Soft-deletes a UI setting (sets `isDeleted` to true). Record is retained. Return --- +## Schema Management (SuperAdminSchemaService) + +All endpoints in this group require the `super_admin` role. Any other role receives `403 Forbidden`. The Permit.io resource is `super-admin`; auditor is granted `get` only. + +### `POST /super-admin/custom-schemas` + +Creates a new custom schema for a client. The schema definition must be a valid JSON Schema draft 2020-12 object with `additionalProperties` explicitly declared and a root `type` of `object`. Maximum definition size is 64 KB. + +**Security**: Requires JWT authentication. Requires `super_admin` role. + +**Request Body** (`CustomSchemaRequest`): + +```json +{ + "clientId": "AAA", + "name": "aircraft-engineering", + "description": "Schema for aircraft inspection documents", + "schemaDef": { + "type": "object", + "additionalProperties": false, + "properties": { + "aircraft_type": { "type": "string", "maxLength": 100 }, + "inspection_date": { "type": "string", "format": "date" } + }, + "required": ["aircraft_type", "inspection_date"] + } +} +``` + +**Response** (`201 Created`, `CustomSchemaResponse`): + +```json +{ + "id": "019580df-ef65-7676-8de9-94435a93337a", + "clientId": "AAA", + "name": "aircraft-engineering", + "description": "Schema for aircraft inspection documents", + "version": 1, + "status": "active", + "schemaDef": { ... }, + "createdAt": "2026-04-14T10:00:00Z", + "createdBy": "a1b2c3d4-e5f6-7890-abcd-ef1234567890" +} +``` + +**Notes**: +- `createdBy` is populated from the JWT `sub` claim (a Cognito subject UUID), not accepted from the request body. +- `schemaDef` must have `type: "object"` at the root. +- `additionalProperties` must be explicitly `true` or `false`. + +**Error Responses**: +- `400`: Invalid or oversized schema definition +- `401`: Authentication required +- `403`: Insufficient permissions +- `409`: A schema with this name already exists for the client (at version 1) + +### `GET /super-admin/custom-schemas` + +Lists schemas for a client with optional filtering. + +**Security**: Requires JWT authentication. Requires `super_admin` role (`auditor` may use GET). + +**Query Parameters**: + +| Parameter | Type | Required | Default | Description | +|---|---|---|---|---| +| clientId | string | Yes | — | Client ID to filter schemas | +| name | string | No | — | Filter by schema name | +| status | string | No | `active` | Filter by status: `active`, `superseded`, `retired`, `any` | +| includeAllVersions | boolean | No | `false` | When `true`, returns all versions per name; otherwise returns only the latest active version | +| limit | integer | No | 20 | Pagination page size (1–100) | +| offset | integer | No | 0 | Pagination offset | + +**Response** (`200 OK`, `CustomSchemaListResponse`): + +```json +{ + "schemas": [ + { + "id": "019580df-ef65-7676-8de9-94435a93337a", + "clientId": "AAA", + "name": "aircraft-engineering", + "version": 2, + "status": "active", + "documentCount": 14, + "canDelete": false, + "createdAt": "2026-04-14T10:00:00Z", + "createdBy": "a1b2c3d4-e5f6-7890-abcd-ef1234567890" + } + ], + "totalCount": 1 +} +``` + +**Notes**: +- Default list (no `status`, no `includeAllVersions`) returns only the latest active version per name. +- `canDelete` is `false` when any document has `custom_schema_id` pointing to this schema. +- `documentCount` is the count of documents currently bound to this specific schema version. + +### `GET /super-admin/custom-schemas/{schemaId}` + +Retrieves a single schema by ID, including the full `schemaDef`. + +**Security**: Requires JWT authentication. Requires `super_admin` role (`auditor` may use GET). + +**Path Parameters**: + +- `schemaId`: Schema UUID + +**Response** (`200 OK`, `CustomSchemaResponse`): Full schema object including `schemaDef`. + +**Error Responses**: +- `404`: Schema not found + +### `POST /super-admin/custom-schemas/{schemaId}/versions` + +Creates a new version of an existing schema. The parent schema must be `active`. On success: a new row is inserted with an incremented `version` number and `status=active`; the parent row's `status` is set to `superseded`. Both changes are applied in a single transaction. + +**Security**: Requires JWT authentication. Requires `super_admin` role. + +**Path Parameters**: + +- `schemaId`: UUID of the current active schema (the parent version) + +**Request Body** (`CustomSchemaVersionRequest`): Same shape as `CustomSchemaRequest` body (name, description, schemaDef — no `createdBy`). + +**Response** (`201 Created`, `CustomSchemaResponse`): The new schema version row. + +**Notes**: +- This is a `POST`, not a `PUT` — it creates a new row with a new ID. The old version is preserved and queryable. +- Passing a `schemaId` that is not `active` (e.g. `superseded` or `retired`) returns `409`. +- `createdBy` is derived from JWT, not the request body. + +**Error Responses**: +- `400`: Invalid schema definition +- `404`: Parent schema not found +- `409`: Parent schema is not active (already superseded or retired) + +### `DELETE /super-admin/custom-schemas/{schemaId}` + +Retires a schema. Sets `status` to `retired`. This operation is reversible only via a new version. + +**Security**: Requires JWT authentication. Requires `super_admin` role. + +**Path Parameters**: + +- `schemaId`: Schema UUID + +**Response**: `204 No Content` + +**Error Responses**: +- `404`: Schema not found +- `409`: One or more documents are currently bound to this schema (`documentCount > 0`) + +### `PATCH /super-admin/documents/{id}/schema` + +Assigns or clears a custom schema on a document. Once a document has custom metadata written to it, the schema binding is frozen — this endpoint will return `409` if metadata exists. To upgrade a schema, first use `POST .../reset-metadata`. + +**Security**: Requires JWT authentication. Requires `super_admin` role. + +**Path Parameters**: + +- `id`: Document UUID + +**Request Body** (`DocumentSchemaAssignRequest`): + +```json +{ + "schemaId": "019580df-ef65-7676-8de9-94435a93337a" +} +``` + +Pass `"schemaId": null` to clear an existing schema binding (allowed only when no metadata exists). + +**Response** (`200 OK`, `DocumentSchemaAssignResponse`): + +```json +{ + "documentId": "019580df-b3f8-7348-9ab1-1e55b3f18ed7", + "customSchemaId": "019580df-ef65-7676-8de9-94435a93337a", + "schemaName": "aircraft-engineering", + "schemaVersion": 2 +} +``` + +**Error Responses**: +- `400`: Schema is not active or document already has legacy field extractions +- `404`: Document or schema not found +- `409`: Document already has custom metadata (schema binding is frozen) + +### `POST /super-admin/documents/{id}/reset-metadata` + +Atomically deletes all custom metadata rows for a document and nullifies `custom_schema_id`. This is the required first step when upgrading a document to a newer schema version. The operation is irreversible — metadata history is lost. + +**Security**: Requires JWT authentication. Requires `super_admin` role. + +**Path Parameters**: + +- `id`: Document UUID + +**Request Body**: None. + +**Response** (`200 OK`, `DocumentMetadataResetResponse`): + +```json +{ + "documentId": "019580df-b3f8-7348-9ab1-1e55b3f18ed7", + "previousSchemaId": "019580df-ef65-7676-8de9-94435a93337a", + "previousSchemaName": "aircraft-engineering", + "previousSchemaVersion": 1, + "metadataVersionsDeleted": 3, + "resetAt": "2026-04-15T09:30:00Z", + "resetBy": "a1b2c3d4-e5f6-7890-abcd-ef1234567890" +} +``` + +This endpoint is idempotent. If the document has no schema binding and no metadata, `metadataVersionsDeleted` is `0` and all `previous*` fields are `null`. + +**Error Responses**: +- `404`: Document not found +- `409`: Document has legacy field extractions (mutual exclusivity — cannot reset a legacy document) + +--- + +## Custom Metadata Operations (CustomMetadataService) + +These endpoints are available to `client_user`, `user_admin`, and `super_admin` for both read and write. `auditor` has read-only access (GET endpoints only). The Permit.io resource is `custom-metadata`. + +### `POST /custom-metadata` + +Writes a new metadata version for a document. The document must already have a custom schema assigned. The metadata payload is validated against the bound schema. Each call appends a new version; the latest version is what `GET /custom-metadata` returns. + +**Security**: Requires JWT authentication. Requires `client_user`, `user_admin`, or `super_admin` role. + +**Request Body** (`CustomMetadataRequest`): + +```json +{ + "documentId": "019580df-b3f8-7348-9ab1-1e55b3f18ed7", + "metadata": { + "aircraft_type": "Boeing 737", + "inspection_date": "2026-04-01", + "flight_hours": 12450.5 + } +} +``` + +Maximum payload size is 1 MB. + +**Response** (`201 Created`, `CustomMetadataResponse`): + +```json +{ + "id": "019580df-ef65-7676-8de9-94435a93337c", + "documentId": "019580df-b3f8-7348-9ab1-1e55b3f18ed7", + "schemaId": "019580df-ef65-7676-8de9-94435a93337a", + "schemaName": "aircraft-engineering", + "schemaVersion": 2, + "version": 4, + "metadata": { ... }, + "createdAt": "2026-04-15T10:00:00Z", + "createdBy": "a1b2c3d4-e5f6-7890-abcd-ef1234567890" +} +``` + +**Notes**: +- `createdBy` is derived from JWT, not the request body. +- Returns `400` with validation errors if the payload does not conform to the schema. + +**Error Responses**: +- `400`: Document has no schema assigned, payload exceeds 1 MB, or payload fails schema validation +- `409`: Document has legacy field extractions (mutually exclusive with custom metadata) + +### `GET /custom-metadata` + +Returns the current (latest) metadata version for a document. + +**Security**: Requires JWT authentication. Requires `client_user`, `user_admin`, `super_admin`, or `auditor` role. + +**Query Parameters**: + +- `documentId` (required): Document UUID + +**Response** (`200 OK`, `CustomMetadataResponse`): The latest version row. + +**Error Responses**: +- `404`: Document not found or no metadata exists for the document + +### `GET /custom-metadata/version` + +Returns a specific historical metadata version for a document. + +**Security**: Requires JWT authentication. Requires `client_user`, `user_admin`, `super_admin`, or `auditor` role. + +**Query Parameters**: + +| Parameter | Type | Required | Description | +|---|---|---|---| +| documentId | uuid | Yes | Document UUID | +| version | integer | Yes | Version number (>= 1) | + +**Response** (`200 OK`, `CustomMetadataResponse`): The requested version row. + +**Error Responses**: +- `400`: `version` is less than 1 +- `404`: Document not found or the specified version does not exist + +### `GET /custom-metadata/history` + +Returns a paginated list of metadata version summaries for a document, ordered by version descending (newest first). Summaries include `version`, `createdAt`, and `createdBy` but not the full metadata payload. + +**Security**: Requires JWT authentication. Requires `client_user`, `user_admin`, `super_admin`, or `auditor` role. + +**Query Parameters**: + +| Parameter | Type | Required | Default | Description | +|---|---|---|---|---| +| documentId | uuid | Yes | — | Document UUID | +| limit | integer | No | 20 | Number of versions to return (1–200) | +| offset | integer | No | 0 | Pagination offset (>= 0) | + +**Response** (`200 OK`, `CustomMetadataHistoryResponse`): + +```json +{ + "versions": [ + { + "version": 3, + "createdAt": "2026-04-15T12:00:00Z", + "createdBy": "a1b2c3d4-e5f6-7890-abcd-ef1234567890" + }, + { + "version": 2, + "createdAt": "2026-04-14T15:00:00Z", + "createdBy": "a1b2c3d4-e5f6-7890-abcd-ef1234567890" + } + ] +} +``` + +**Error Responses**: +- `400`: `limit` is out of range (must be 1–200) or `offset` is negative +- `404`: Document not found + +**Notes**: +- If the document exists but has no metadata, returns `200` with an empty `versions` array. +- `limit=201` is clamped to 200. + +--- + +## GET /document/{id} — Custom Metadata Enrichment + +`GET /document/{id}` (documented under [Document Operations](#document-operations-documentsservice)) now returns two additional fields on the `DocumentEnriched` response: + +| Field | Type | Description | +|---|---|---| +| `customSchemaId` | uuid or null | UUID of the custom schema currently bound to the document. Null if no schema has been assigned. | +| `hasCustomMetadata` | boolean | `true` if at least one custom metadata version exists for the document. | + +These fields are present on every `GET /document/{id}` response regardless of whether the caller has any custom metadata role grants. A document that has never been assigned a schema returns `customSchemaId: null, hasCustomMetadata: false`. + +--- + ## Request/Response Schemas ### Standard Response Headers @@ -1970,6 +2335,20 @@ The following table shows what data is passed to Permit.io for each protected en | `GET` | `/ui-settings/{id}` | - | - | Auth-only (no Permit.io)| | `PATCH` | `/ui-settings/{id}` | - | - | Auth-only (no Permit.io)| | `DELETE` | `/ui-settings/{id}` | - | - | Auth-only (no Permit.io)| +| **Schema Management (SuperAdminSchemaService)** | +| `POST` | `/super-admin/custom-schemas` | `super-admin` | `post` | Create new schema (super_admin only) | +| `GET` | `/super-admin/custom-schemas` | `super-admin` | `get` | List schemas with filters | +| `GET` | `/super-admin/custom-schemas/{schemaId}` | `super-admin` | `get` | Get a schema by ID | +| `POST` | `/super-admin/custom-schemas/{schemaId}/versions` | `super-admin` | `post` | Create new version (supersedes parent) | +| `DELETE` | `/super-admin/custom-schemas/{schemaId}` | `super-admin` | `delete` | Retire schema (409 if documents bound) | +| **Document Schema Assignment (SuperAdminSchemaService)** | +| `PATCH` | `/super-admin/documents/{id}/schema` | `super-admin` | `patch` | Assign (or clear) custom schema for a document | +| `POST` | `/super-admin/documents/{id}/reset-metadata` | `super-admin` | `post` | Wipe custom metadata history and clear schema binding | +| **Custom Metadata Operations (CustomMetadataService)** | +| `GET` | `/custom-metadata` | `custom-metadata` | `get` | Get current (latest) metadata version for a document | +| `POST` | `/custom-metadata` | `custom-metadata` | `post` | Write new validated metadata version for a document | +| `GET` | `/custom-metadata/version` | `custom-metadata` | `get` | Get a specific historical metadata version | +| `GET` | `/custom-metadata/history` | `custom-metadata` | `get` | List metadata version summaries (paged, descending) | ### Permit.io Configuration Requirements @@ -1988,6 +2367,8 @@ To configure Permit.io to work with this API, administrators must create: | `field-extractions` | Field extraction operations | | `export` | Export operations | | `admin` | User administration and EULA admin operations | +| `super-admin` | Schema management — gates all `/super-admin/custom-schemas` routes (super_admin CRUD; auditor read-only) | +| `custom-metadata` | Custom document metadata operations (all roles except auditor can read and write; auditor read-only) | #### Actions @@ -2006,12 +2387,23 @@ The following roles are defined in the Permit.io configuration (`cmd/auth_relate | Role | Resources | Actions | Use Case | | -------------- | -------------------------------------------------------------------------------------- | -------------------------------- | --------------------------------------- | | `super_admin` | All resources | All actions including `delete` | Full administrative access | -| `user_admin` | `admin`, `documents`, `field-extractions`, `folders` | `get`, `post`, `patch`, `delete` (admin only) | User management | -| `auditor` | `admin`, `client`, `clients`, `document`, `export`, `documents`, `field-extractions`, `folders`, `labels` | `get` (and `head` for admin) | Read-only access for auditing | -| `client_user` | `client`, `clients`, `document`, `export`, `documents`, `field-extractions`, `folders`, `labels` | `get`, `post`, `patch` (varies by resource) | Client-specific access | +| `user_admin` | `admin`, `documents`, `field-extractions`, `folders`, `custom-metadata` | `get`, `post`, `patch`, `delete` (admin only); `get`, `post` (custom-metadata) | User management | +| `auditor` | `admin`, `client`, `clients`, `document`, `export`, `documents`, `field-extractions`, `folders`, `labels`, `super-admin`, `custom-metadata` | `get` (and `head` for admin) | Read-only access for auditing | +| `client_user` | `client`, `clients`, `document`, `export`, `documents`, `field-extractions`, `folders`, `labels`, `custom-metadata` | `get`, `post`, `patch` (varies by resource); `get`, `post` (custom-metadata) | Client-specific access | **Delete permissions**: Only `super_admin` has `delete` access to `client`, `document`, and `folders` resources. The `client_user` role does NOT have `client:delete` or any other delete permissions on these resources. +**Schema management permissions** (`super-admin` and `custom-metadata` resources): + +| Role | `super-admin` resource | `custom-metadata` resource | +| ------------- | ----------------------------------- | -------------------------- | +| `super_admin` | `get`, `post`, `patch`, `delete` | `get`, `post` | +| `user_admin` | none | `get`, `post` | +| `auditor` | `get` | `get` | +| `client_user` | none | `get`, `post` | + +The `super-admin` resource gates all `/super-admin/custom-schemas` CRUD endpoints. A `user_admin` has no `super-admin` grant and receives 403 on every schema management route. + ### Authorization Error Responses When authorization fails, the API returns: @@ -2040,6 +2432,22 @@ When authorization fails, the API returns: --- +## Mutable Metadata v4 — Design Decisions + +The following decisions shaped the Milestone 1 schema CRUD implementation and apply to all subsequent milestones. + +- **`POST .../versions` instead of `PUT`**: A `PUT` route implies in-place replacement, which would overwrite the existing row and lose the ability to audit what the previous definition was. `POST /super-admin/custom-schemas/{schemaId}/versions` creates a new row with a new `id`, increments the `version` counter, and marks the parent row as `superseded` — all in a single transaction. The old row is preserved permanently and remains queryable. + +- **`createdBy` is not in request bodies**: The actor identity comes from the JWT `sub` claim (the Cognito subject UUID), extracted server-side by the handler. Accepting `createdBy` in the request body would allow any caller to impersonate a different actor. The `sub` claim is cryptographically bound to the token and cannot be spoofed by the client. + +- **Composite FK replaces the v3 trigger**: v3 used a trigger (`trg_validate_schema_client_match`) to prevent a document from being bound to a schema owned by a different client. v4 replaces this with a composite foreign key `fk_documents_custom_schema_same_client` on `(custom_schema_id, clientId)` referencing `client_metadata_schemas(id, client_id)`. Structural enforcement fires before any application code runs, catches the violation even on direct SQL updates, and requires no ongoing maintenance. + +- **`additionalProperties` must be explicitly declared**: JSON Schema's default behavior when `additionalProperties` is absent is to allow all additional properties. Requiring an explicit declaration forces the schema author to make a deliberate choice and prevents accidental data loss due to schema drift. + +- **`documentFieldExtractions` and custom metadata are mutually exclusive**: A document may either have legacy field extractions or custom schema metadata — never both. This is enforced at three layers: service-layer `FOR UPDATE` parent-row locks, a Postgres trigger (`trg_prevent_schema_reassignment`), and a second trigger (`trg_prevent_legacy_extraction_on_custom_document`). Defense-in-depth ensures the invariant holds even if one layer is bypassed. + +--- + ## Code Generation ### OpenAPI Integration diff --git a/docs/ai.generated/04-data-architecture.md b/docs/ai.generated/04-data-architecture.md index e0b718ea..94ba39ce 100644 --- a/docs/ai.generated/04-data-architecture.md +++ b/docs/ai.generated/04-data-architecture.md @@ -10,6 +10,10 @@ The system uses PostgreSQL 17.2 with a schema designed for multi-tenant document ```mermaid erDiagram + clients ||--o{ client_metadata_schemas : "owns" + client_metadata_schemas ||--o{ documents : "bound to" + documents ||--o{ document_custom_metadata : "has metadata" + clients ||--o{ documents : "owns" clients ||--o{ batch_uploads : "owns" clients ||--|| clientCanSync : "has sync status" @@ -78,6 +82,27 @@ erDiagram timestamptz updatedAt } + client_metadata_schemas { + uuid id PK + varchar client_id FK + text name + text description + jsonb schema_def + int version + schema_status_type status + timestamptz created_at + varchar created_by + } + + document_custom_metadata { + uuid id PK + uuid document_id FK + jsonb metadata + bigint version + timestamptz created_at + varchar created_by + } + clients { varchar clientId PK text name UK @@ -106,6 +131,7 @@ erDiagram uuid batch_id FK uuid folderId FK text originalPath + uuid custom_schema_id FK } batch_uploads { @@ -389,16 +415,21 @@ CREATE TABLE documents ( batch_id uuid, folderId uuid, originalPath TEXT, + custom_schema_id uuid NULL, UNIQUE(clientId, hash), FOREIGN KEY (clientId) REFERENCES clients(clientId), FOREIGN KEY (batch_id) REFERENCES batch_uploads(id), - FOREIGN KEY (folderId) REFERENCES folders(id) + FOREIGN KEY (folderId) REFERENCES folders(id), + CONSTRAINT fk_documents_custom_schema_same_client + FOREIGN KEY (custom_schema_id, clientId) + REFERENCES client_metadata_schemas(id, client_id) ); -- Indexes CREATE INDEX idx_documents_filename ON documents(filename); CREATE INDEX idx_documents_batch_id ON documents(batch_id); CREATE INDEX idx_documents_folderid ON documents(folderId); +CREATE INDEX idx_documents_custom_schema_id ON documents(custom_schema_id); ``` **Key Features**: @@ -410,6 +441,7 @@ CREATE INDEX idx_documents_folderid ON documents(folderId); - Virtual folder organization via `folderId` (added in migration 111) - Immutable `originalPath` preserving exact upload path (added in migration 111) - Indexed on filename, batch_id, and folderId for efficient queries +- `custom_schema_id` (nullable UUID, added in migration 128): when non-null, opts the document into the custom metadata system; protected by a composite FK ensuring the schema belongs to the same client ### Batch Uploads (`batch_uploads`) Tracks batch document upload operations with ZIP archive processing. @@ -677,6 +709,146 @@ CREATE INDEX idx_uisettings_namespace ON "uiSettings"(namespace); - UNIQUE(namespace, key) enforces one record per scope+key and provides an implicit index for lookups by (namespace, key) - Single explicit index on `namespace` for listing/filtering by namespace +### Custom Schema System + +The custom schema system stores client-scoped JSON Schema definitions and per-document validated metadata blobs. Added in migrations 127–130. + +#### Client Metadata Schemas (`client_metadata_schemas`) + +Stores versioned JSON Schema definitions scoped to a client. Each unique `(client_id, name)` pair forms a schema lineage; versions within a lineage are numbered sequentially starting at 1. + +```sql +CREATE TYPE schema_status_type AS ENUM ('active', 'superseded', 'retired'); + +CREATE TABLE client_metadata_schemas ( + id uuid PRIMARY KEY DEFAULT uuid_generate_v7(), + client_id varchar(255) NOT NULL, + name text NOT NULL, + description text, + schema_def jsonb NOT NULL, + version integer NOT NULL DEFAULT 1, + status schema_status_type NOT NULL DEFAULT 'active', + created_at timestamptz NOT NULL DEFAULT NOW(), + created_by varchar(255) NOT NULL, + FOREIGN KEY (client_id) REFERENCES clients(clientId) ON DELETE CASCADE, + CONSTRAINT uq_client_schema_name_version UNIQUE (client_id, name, version), + CONSTRAINT uq_cms_id_client UNIQUE (id, client_id), + CONSTRAINT chk_schema_def_size CHECK (pg_column_size(schema_def) <= 70000) +); + +-- Indexes +CREATE INDEX idx_cms_client_id ON client_metadata_schemas(client_id); +CREATE INDEX idx_cms_client_name ON client_metadata_schemas(client_id, name); +CREATE INDEX idx_cms_client_status ON client_metadata_schemas(client_id, status); +``` + +**Key Features**: +- UUID v7 primary keys for time-ordered insertion +- `FOREIGN KEY (client_id) ... ON DELETE CASCADE` — deleting a client removes all its schemas +- `CONSTRAINT uq_client_schema_name_version` — no two rows can share the same `(client_id, name, version)` triple +- `CONSTRAINT uq_cms_id_client` — required as the target of the composite FK on `documents` (see composite FK section below) +- `CONSTRAINT chk_schema_def_size` — enforces the 64 KB maximum at the database layer (app layer enforces 65,536 bytes via `MaxSchemaDefinitionBytes`) +- `created_by` stores the JWT `sub` claim (Cognito subject UUID), not an email address +- Status lifecycle: `active` → `superseded` (when a new version is created) or `active` → `retired` (via DELETE endpoint) + +**Status Semantics**: + +| Status | Meaning | +|---|---| +| `active` | Current version; documents can be assigned to this schema; only one `active` row per `(client_id, name)` is allowed by application invariants | +| `superseded` | An older version of the same name; documents bound to it retain the binding; no new documents can be assigned | +| `retired` | Explicitly removed; no documents are bound (enforced by DELETE endpoint returning 409 if any are) | + +#### Document Custom Metadata (`document_custom_metadata`) + +Stores versioned JSONB metadata blobs for documents that have a custom schema assigned. Each write appends a new version row; the service always reads version rows ordered descending to get the current value. + +```sql +CREATE TABLE document_custom_metadata ( + id uuid PRIMARY KEY DEFAULT uuid_generate_v7(), + document_id uuid NOT NULL, + metadata jsonb NOT NULL, + version bigint NOT NULL, + created_at timestamptz NOT NULL DEFAULT NOW(), + created_by varchar(255) NOT NULL, + FOREIGN KEY (document_id) REFERENCES documents(id) ON DELETE CASCADE, + CONSTRAINT uq_doc_custom_metadata_version UNIQUE (document_id, version) +); + +-- Index +CREATE INDEX idx_dcm_doc_version_desc ON document_custom_metadata(document_id, version DESC); +``` + +**Key Features**: +- UUID v7 primary keys +- `FOREIGN KEY (document_id) ... ON DELETE CASCADE` — deleting a document removes all its metadata rows automatically; no application-level cleanup required +- `CONSTRAINT uq_doc_custom_metadata_version` — prevents duplicate version numbers per document +- `idx_dcm_doc_version_desc` — optimizes the common `ORDER BY version DESC LIMIT 1` query pattern for fetching the latest version +- No `schema_id` column — `documents.custom_schema_id` is the single source of truth for schema binding; metadata rows carry no independent schema reference +- `created_by` stores the JWT `sub` claim (Cognito subject UUID) +- Maximum payload enforced at application layer: 1 MB (`MaxMetadataPayloadBytes = 1048576`) + +#### `documents.custom_schema_id` Column + +Migration 128 adds a nullable UUID column `custom_schema_id` to the `documents` table: + +```sql +ALTER TABLE documents ADD COLUMN custom_schema_id uuid NULL; +CREATE INDEX idx_documents_custom_schema_id ON documents(custom_schema_id); +``` + +When `custom_schema_id` is `NULL`, the document uses the legacy field extraction system (or has no structured metadata at all). When non-null, the document is opted into the custom metadata system and mutual exclusivity is enforced by triggers and service-layer locks. + +### Composite Foreign Key — Same-Client Schema Binding + +A composite foreign key on `documents` prevents a document from being bound to a schema owned by a different client. This replaces the trigger-based approach used in earlier versions. + +```sql +ALTER TABLE documents + ADD CONSTRAINT fk_documents_custom_schema_same_client + FOREIGN KEY (custom_schema_id, clientId) + REFERENCES client_metadata_schemas(id, client_id); +``` + +**How it works**: +- `client_metadata_schemas` has `CONSTRAINT uq_cms_id_client UNIQUE (id, client_id)`, which is required for the composite FK target. +- Any attempt to set `documents.custom_schema_id` to a schema row whose `client_id` does not match the document's `clientId` fails at the database layer with a FK violation, before any application code runs. +- This enforcement fires on direct SQL updates as well, providing defense-in-depth beyond the application layer. + +### Schema Invariant Triggers + +Two triggers enforce the mutual exclusivity invariant between the custom metadata system and the legacy field extraction system. Added in migration 130. + +#### `trg_prevent_schema_reassignment` (Trigger 1) + +``` +BEFORE UPDATE OF custom_schema_id ON documents +FOR EACH ROW EXECUTE FUNCTION trg_prevent_schema_reassignment() +``` + +Fires whenever `custom_schema_id` is changed on an existing document row. The trigger reads the document's current state and raises an exception if: +- The document already has custom metadata rows in `document_custom_metadata`, **or** +- The document already has legacy field extraction rows in `documentFieldExtractions`. + +A schema reassignment is only allowed when neither system has recorded any data for the document. This ensures that once a document has committed to either extraction system, its schema binding cannot change without an explicit reset (see `POST /super-admin/documents/{id}/reset-metadata`). + +#### `trg_prevent_legacy_extraction_on_custom_document` (Trigger 2) + +``` +BEFORE INSERT ON documentFieldExtractions +FOR EACH ROW EXECUTE FUNCTION trg_prevent_legacy_extraction_on_custom_document() +``` + +Fires on every INSERT into `documentFieldExtractions`. The function reads `documents.custom_schema_id` using `SELECT ... FOR SHARE` on the parent row, and raises a `check_violation` (SQLSTATE `23514`) if the column is non-null. This prevents legacy field extractions from being added to documents already opted into the custom schema system. + +The `FOR SHARE` lock in the trigger function is the symmetric counterpart to the `FOR UPDATE` lock taken by `AssignSchema` in the service layer. This ensures that a concurrent race between `AssignSchema` and `CreateFieldExtraction` cannot leave the document in an inconsistent state where both systems have data. + +**Defense-in-depth summary**: +- Service layer: `FOR UPDATE` parent-row lock acquired before any writes in both `AssignSchema` and `CreateFieldExtraction` +- Trigger 1: blocks schema reassignment when data exists +- Trigger 2: blocks legacy extraction inserts on schema-bound documents +- Composite FK: blocks cross-client schema bindings structurally + ### Queries (`queries`) Defines data extraction logic with type-specific implementations. diff --git a/docs/ai.generated/09-authentication-guide.md b/docs/ai.generated/09-authentication-guide.md index 914d93fc..38a20988 100644 --- a/docs/ai.generated/09-authentication-guide.md +++ b/docs/ai.generated/09-authentication-guide.md @@ -316,6 +316,8 @@ Permissions follow the format `resource:action`: | `export` | `get`, `post` | Export operations | | `admin` | `get`, `post`, `patch`, `delete` | User and EULA administration | | `eula` | `get`, `post` | EULA status and agreement | +| `super-admin` | `get`, `post`, `patch`, `delete` | Schema management (CRUD for client_metadata_schemas; super_admin only for writes) | +| `custom-metadata` | `get`, `post` | Custom document metadata read/write (all roles have some access; auditor read-only) | ### Using Permissions in UI diff --git a/docs/ai.generated/11-custom-metadata-guide.md b/docs/ai.generated/11-custom-metadata-guide.md new file mode 100644 index 00000000..6de9e615 --- /dev/null +++ b/docs/ai.generated/11-custom-metadata-guide.md @@ -0,0 +1,331 @@ +# Custom Metadata Guide (Mutable Metadata v4) + +## Overview + +The custom metadata system lets platform operators define client-scoped JSON Schema definitions and allows end users to attach structured, validated metadata blobs to individual documents. This is distinct from — and mutually exclusive with — the legacy field extraction system (`documentFieldExtractions`). + +### When to Use Custom Metadata vs Legacy Field Extractions + +| | Custom Metadata | Legacy Field Extractions | +|---|---|---| +| Schema definition | Per-client JSON Schema (flexible) | Fixed set of ~19 single-value + 112 array fields | +| Schema evolution | Via `POST .../versions` (preserved history) | Schema is static | +| Validation | JSON Schema draft 2020-12 | Fixed column types | +| Who configures | `super_admin` | Platform deployment | +| Who writes | `client_user`, `user_admin`, `super_admin` | Any authenticated user | + +A document may use one system or the other, never both. The mutual exclusivity invariant is enforced at three layers: service-layer row locks, Trigger 1 (`trg_prevent_schema_reassignment`), and Trigger 2 (`trg_prevent_legacy_extraction_on_custom_document`). See the [Data Architecture doc](04-data-architecture.md) for trigger details. + +--- + +## Authorization Matrix + +| Role | `SuperAdminSchemaService` endpoints | `CustomMetadataService` endpoints | +|---|---|---| +| `super_admin` | Full (GET, POST, PATCH, DELETE) | Full (GET, POST) | +| `user_admin` | **No access (403 on all)** | Full (GET, POST) | +| `client_user` | **No access (403 on all)** | Full (GET, POST) | +| `auditor` | GET only (read-only) | GET only (no POST) | + +**Important**: `user_admin` does NOT have access to any `/super-admin/custom-schemas` or `/super-admin/documents/*/schema` endpoints. The `/super-admin/` path prefix is intentionally restricted to `super_admin` only. This is a deliberate difference from the legacy `/admin/` prefix, which did grant some access to `user_admin`. + +--- + +## Mutual Exclusivity + +A document may have either legacy field extractions OR custom metadata — never both. + +- Attempting to assign a custom schema to a document that already has legacy field extraction rows returns `409 Conflict`. +- Attempting to write legacy field extractions to a document that already has a custom schema assigned returns `409 Conflict`. +- The database enforces this via two triggers; the service layer reinforces it with explicit `FOR UPDATE` parent-row locks on the `documents` row before any DML. + +--- + +## Platform Operator Workflow (super_admin) + +This section describes how a platform operator sets up custom metadata for a client. + +### Step 1 — Define the schema + +```http +POST /super-admin/custom-schemas +Content-Type: application/json + +{ + "clientId": "AAA", + "name": "aircraft-engineering", + "description": "Schema for aircraft inspection reports", + "schemaDef": { + "type": "object", + "additionalProperties": false, + "properties": { + "aircraft_type": { "type": "string", "maxLength": 100 }, + "flight_hours": { "type": "number", "minimum": 0 }, + "inspection_date": { "type": "string", "format": "date" }, + "critical_findings": { + "type": "array", + "items": { "type": "string" } + } + }, + "required": ["aircraft_type", "inspection_date"] + } +} +``` + +The response includes `id` (the schema UUID), `version: 1`, and `status: "active"`. + +### Step 2 — Assign the schema to a document + +```http +PATCH /super-admin/documents/{documentId}/schema +Content-Type: application/json + +{ + "schemaId": "019580df-ef65-7676-8de9-94435a93337a" +} +``` + +The schema and document must belong to the same client. The document must not already have legacy field extractions or existing custom metadata. + +### Step 3 — Users write metadata + +Once a schema is assigned, users with `client_user`, `user_admin`, or `super_admin` roles can write metadata (see end-user workflow below). + +--- + +## End-User Workflow (client_user) + +### Write metadata + +```http +POST /custom-metadata +Content-Type: application/json + +{ + "documentId": "019580df-b3f8-7348-9ab1-1e55b3f18ed7", + "metadata": { + "aircraft_type": "Boeing 737", + "inspection_date": "2026-04-01", + "flight_hours": 12450.5, + "critical_findings": ["hydraulic leak in bay 3"] + } +} +``` + +Each call appends a new version. The metadata is validated against the schema currently bound to the document. Returns `400` with validation details if the payload does not conform. + +### Read current metadata + +```http +GET /custom-metadata?documentId=019580df-b3f8-7348-9ab1-1e55b3f18ed7 +``` + +Returns the latest version of the metadata along with `schemaId`, `schemaName`, and `schemaVersion` from the bound schema. + +### Read a specific version + +```http +GET /custom-metadata/version?documentId=019580df-b3f8-7348-9ab1-1e55b3f18ed7&version=2 +``` + +`version` must be >= 1. Returns `404` if that version does not exist. + +### List version history + +```http +GET /custom-metadata/history?documentId=019580df-b3f8-7348-9ab1-1e55b3f18ed7&limit=20&offset=0 +``` + +Returns version summaries (`version`, `createdAt`, `createdBy`) in descending order. Does not return the full metadata payloads. `limit` must be 1–200. + +--- + +## Schema Definition Requirements + +Custom schema definitions must conform to the following rules. Submissions that violate any rule are rejected with `400`. + +1. **Must be valid JSON** — malformed JSON is rejected immediately. +2. **Must be valid JSON Schema (draft 2020-12)** — the definition is meta-validated against the JSON Schema draft 2020-12 meta-schema. +3. **Root `type` must be `"object"`** — schemas with a different root type (e.g. `"array"`, `"string"`) are rejected. +4. **`additionalProperties` must be explicitly declared** — the absence of this key is rejected. The author must make a deliberate choice: `false` (strict, no extra keys) or `true` (open, any extra keys allowed). +5. **Maximum size is 64 KB** — definitions larger than 65,536 bytes are rejected. + +### Sample Schema — Strict (no additional properties) + +```json +{ + "type": "object", + "additionalProperties": false, + "properties": { + "aircraft_type": { "type": "string", "maxLength": 100 }, + "flight_hours": { "type": "number", "minimum": 0 }, + "inspection_date": { "type": "string", "format": "date" }, + "critical_findings": { + "type": "array", + "items": { "type": "string" } + } + }, + "required": ["aircraft_type", "inspection_date"] +} +``` + +### Sample Schema — Open (additional properties allowed) + +```json +{ + "type": "object", + "additionalProperties": true, + "properties": { + "document_class": { "type": "string" }, + "reviewed_by": { "type": "string" } + } +} +``` + +--- + +## Metadata Write Requirements + +- Payload must be a JSON object (JSONB). +- Maximum size is 1 MB (1,048,576 bytes). +- Payload is validated against the schema currently bound to the document at write time. +- If the document has no schema assigned, the write returns `400`. +- `createdBy` is not accepted in the request body — actor identity comes from the JWT `sub` claim. + +--- + +## Schema Versioning + +A schema name within a client forms a lineage. Each version in the lineage is an independent row with a sequential `version` integer. + +### Creating a new version + +```http +POST /super-admin/custom-schemas/{currentActiveSchemaId}/versions +Content-Type: application/json + +{ + "clientId": "AAA", + "name": "aircraft-engineering", + "description": "v2: added optional maintenance_notes field", + "schemaDef": { + "type": "object", + "additionalProperties": false, + "properties": { + "aircraft_type": { "type": "string", "maxLength": 100 }, + "flight_hours": { "type": "number", "minimum": 0 }, + "inspection_date": { "type": "string", "format": "date" }, + "critical_findings": { "type": "array", "items": { "type": "string" } }, + "maintenance_notes": { "type": "string" } + }, + "required": ["aircraft_type", "inspection_date"] + } +} +``` + +On success: +- A new row is inserted with `version = 2` and `status = "active"`. +- The parent row (`schemaId` in the path) is updated to `status = "superseded"`. +- Both changes are applied atomically in a single transaction. + +The HTTP method is `POST`, not `PUT`. Each call creates a new row with a new ID. The old version is preserved permanently and remains queryable. + +### Status values + +| Status | Meaning | +|---|---| +| `active` | The current version for this schema name. Documents can be assigned to it. Only one active version per `(client_id, name)` lineage at any time. | +| `superseded` | A previous version. Still queryable. Documents bound to it retain the binding. Cannot be assigned to new documents. | +| `retired` | Explicitly removed via DELETE. No documents are bound (DELETE returns 409 if any are). | + +### Passing a non-active parent to POST .../versions + +If the `schemaId` in the path points to a `superseded` or `retired` row, the request returns `409 Conflict`. The active version for the lineage must be used as the path parameter. + +--- + +## Schema Upgrade Runbook (Reset-Metadata Flow) + +Once a document has custom metadata written to it, its `custom_schema_id` is frozen by Trigger 1. To migrate a document to a newer schema version: + +**Warning**: This process is irreversible. All metadata history for the document is permanently deleted. + +### Step 1 — Create the new schema version (if not already done) + +```http +POST /super-admin/custom-schemas/{currentActiveSchemaId}/versions +``` + +Note the new schema's `id`. + +### Step 2 — Reset the document + +```http +POST /super-admin/documents/{documentId}/reset-metadata +``` + +This single atomic operation: +- Deletes all rows in `document_custom_metadata` for the document. +- Sets `documents.custom_schema_id` to `NULL`. +- Returns a summary including `metadataVersionsDeleted` and the `previousSchemaId`. + +The operation is idempotent. If the document has no schema and no metadata, it returns `200` with `metadataVersionsDeleted: 0`. + +### Step 3 — Assign the new schema version + +```http +PATCH /super-admin/documents/{documentId}/schema +Content-Type: application/json + +{ + "schemaId": "" +} +``` + +### Step 4 — Write new metadata + +```http +POST /custom-metadata +Content-Type: application/json + +{ + "documentId": "{documentId}", + "metadata": { ... } +} +``` + +--- + +## Cascade Deletion Behavior + +Custom metadata and schema data are cleaned up automatically via database FK cascades. No application-level cleanup steps are required. + +- `DELETE /document/{id}` (via `DeleteDocumentCascade`): deletes all `document_custom_metadata` rows for the document via `ON DELETE CASCADE` on the FK from `document_custom_metadata.document_id` to `documents.id`. +- `DELETE /client/{id}` (via `client.HardDelete`): deletes the client, which cascades to `client_metadata_schemas` (FK with `ON DELETE CASCADE`), then to `documents`, and then to `document_custom_metadata`. + +No additional steps are needed in the delete code paths for this feature. + +--- + +## Actor Fields + +`createdBy` and `resetBy` fields in all request bodies are ignored — they are absent from the v4 request schemas. Actor identity is always derived from the JWT `sub` claim (an opaque Cognito subject UUID). Response fields that carry the actor identity are plain strings, not email-formatted values. + +--- + +## Permit.io Deployment Note + +Before deploying the `SuperAdminSchemaService` or `CustomMetadataService` controllers, the Permit.io resource and role configuration must be applied. Run the setup tool for each environment: + +```bash +# Development +cmd/auth_related/permit.setup/run.tool.dev.sh + +# UAT +cmd/auth_related/permit.setup/run.tool.uat.sh + +# Production (requires explicit approval) +cmd/auth_related/permit.setup/run.tool.prod.sh +``` + +The setup tool applies the `super-admin` and `custom-metadata` resources and the role grants defined in `cmd/auth_related/permit.setup/permit_policies.yaml`. Without this step, all requests to the new endpoints will return `403` regardless of the user's Cognito role. diff --git a/docs/ai.generated/README.md b/docs/ai.generated/README.md index d043432b..2963aa62 100644 --- a/docs/ai.generated/README.md +++ b/docs/ai.generated/README.md @@ -2,9 +2,16 @@ ## Overview - This documentation provides comprehensive coverage of the DoczyAI Document Processing Platform - a cloud-native document processing system designed to extract structured information from documents. +## Architecture Diagram + +The diagram below shows the full API layer architecture — from HTTP endpoints through middleware, controllers, services, and the repository layer, ending at the database and external services. Request flow runs left to right. + +[![Query Orchestration API — Software Architecture Layers](architecture-diagram.png)](architecture-diagram.svg) + +*[View scalable SVG version](architecture-diagram.svg)* + ## Documentation Structure ### 📋 System Documentation @@ -69,6 +76,17 @@ This documentation provides comprehensive coverage of the DoczyAI Document Proce - Watching S3 for client document imports - Document recovery from S3 buckets +11. **[Custom Metadata Guide](11-custom-metadata-guide.md)** + - Overview: custom schemas vs legacy field extractions + - Authorization matrix (super_admin, user_admin, client_user, auditor) + - Platform operator workflow: define schema, assign to document + - End-user workflow: write, read current, read by version, read history + - Schema definition requirements and sample schemas + - Schema versioning: how POST .../versions works, status lifecycle + - Reset-metadata runbook: schema upgrade flow with metadata wipe warning + - Cascade deletion behavior (no special steps needed) + - Permit.io deployment note + ## Quick Start ### For New Developers diff --git a/docs/ai.generated/architecture-diagram.png b/docs/ai.generated/architecture-diagram.png new file mode 100644 index 0000000000000000000000000000000000000000..f49e138f55b89ab2d2c9847ffd87979065adea92 GIT binary patch literal 569241 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�E|1+w>ZF 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

`~Qnf!b@`>Y&Ee_vP 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 %HER_3b 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 }BRIiU)Ac57AdLb5f8E!YX}e-Hwm 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

=E%o!$w{1E&>~Hx4QCyeEfSI7X_7lraxW(Mk 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ⅈGuoL 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 zFAjoH00000000000C;f_i~;}v00000006*?gJ2W@0000000000UK|9Y0000000000 p0Px}<7zF?T00000004j&=Rb+mx + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + QUERY ORCHESTRATION API + Software Architecture · Request Flow: Left → Right + + + + HTTP ENDPOINTS + MIDDLEWARE + CONTROLLERS + SERVICE LAYER + REPOSITORY + DATABASE + + + + + + + + + + + + + + + + + + REST HTTP Endpoints + Echo v4 · OpenAPI-first + + + + + + Client Management + GET/POST /client · PATCH/DELETE /client/{id} + + + + + Document Operations + Upload · Download (S3 stream) · Batch + + + + + Folders & Labels + /folders · /documents/{id}/labels + + + + + Field Extractions + /field-extractions · version · history + + + + + Custom Schemas + /super-admin/custom-schemas · RBAC gated + + + + + Custom Metadata + /custom-metadata · version · history + + + + + Export Management + POST /client/{id}/export · GET /export/{id} + + + + + EULA Administration + /eula · /admin/eula · agreements + + + + + User & Identity + /admin/users · /identity · /home + + + + + Auth & UI Settings + /login · /logout · /ui-settings + + + + + Collector Configuration + GET/PATCH /client/{id}/collector + + + + + Health & Metrics + GET /health · GET /metrics (Prometheus) + + + + + + + + + + + + + + Middleware Stack + internal/server/api/listener.go + + + + + Config Injection + Injects runtime configuration into context + + + + Prometheus Metrics + HTTP metrics collection · /metrics + + + + Structured Logging + slog · per-endpoint log level control + + + + Panic Recovery + Catches runtime panics, returns 500 + + + + CORS + Cross-origin request handling + + + + Rate Limiting + Per-endpoint limits · ratelimit.LoadConfig + + + + JWT Authentication + Cognito token · Bearer / auth_token cookie + lestrrat-go/jwx v2 · COGNITO_ENV_ID scope + + + + RBAC Authorization + Permit.io PDP · Fine-grained permissions + permitio/permit-golang · super-admin bypass + + + + OpenAPI Validation + Request validation against OAS3 schema + + + + Swagger UI + /swagger/* · doc.json · doc.yaml + + + + + Test User Injection + DISABLE_AUTH=true · X-Test-User-Subject + + + + + + + + + + + + + + Controllers + api/queryAPI/ · Controllers struct + + + + + + Controllers { *Services } + + + + ClientController + client.go + + + DocumentController + documents.go + + + CollectorController + collector.go + + + FolderController + folders.go + + + LabelController + labels.go + + + FieldExtractionController + fieldextractions.go + + + CustomSchemaController + customschemas.go + + + CustomMetadataController + custommetadata.go + + + ExportController + export.go + + + EulaController + eulaHandlers.go + + + UISettingsController + uisettings.go + + + AdminController + adminHandlers.go + + + IdentityController + identityHandlers.go + + + + Background Task Runner + internal/backgroundtask/ · alitto/pond v2 + + + + + + + + + + + + + + Service Layer + internal/*/service.go · Domain business logic + + + + + Client Service + internal/client/ · CRUD, normalize, sync state + + + + Document Service + internal/document/ · Lifecycle management + upload/ · download/ · batch/ · init/ + sync/ · store/ · clean/ · delete.go + + + + Collector Service + internal/collector/ · internal/collector/set/ + + + + Folder & Label Services + internal/folder/ · internal/label/ + + + + Field Extraction Service + internal/fieldextraction/ · Versioned history + + + + Custom Schema Service + internal/customschema/ · Schema validation + + + + Export Service + internal/export/ · Result output management + + + + EULA Service + internal/eula/ · Versioning & compliance + + + + UI Settings & User Management + internal/uisettings/ · internal/usermanagement/ + + + + + Publishes to SQS Queues + Triggers async pipeline stages + store_event · document_init · client_sync + + + + + + + + + + + + + + Repository Layer + internal/database/repository/ · sqlc v1.27 + + + + *Queries struct + Type-safe SQL · No ORM · pgx/v5 + + + + client.sql.go + Client CRUD queries + GetClient · ListClients · CreateClient · UpdateClient + + + document.sql.go + Document queries + GetDocument · ListDocuments · SetState + + + batch.sql.go + Batch queries + CreateBatch · GetBatch · UpdateBatchItem + + + folder.sql.go · label.sql.go + Org queries + Folder hierarchy · label assignment + + + fieldextractions.sql.go + Extraction queries + Versioned field extraction records + + + customschemas.sql.go + Schema queries + CreateSchema · GetSchema · ListVersions + + + custommetadata.sql.go + Metadata queries + UpsertMetadata · GetMetadata · DeleteMetadata + + + eula.sql.go · uisettings.sql.go + Settings queries + EULA versioning · UI preference storage + + + models.go + Domain types + All generated entity structs & enums + + + + Transaction Support + Queries.WithTx(tx) · pgx.Tx wrapping + + + + Database Config Provider + database.ConfigProvider interface + GetDBQueries() · Connection pool + + + + + + + + + + + + + + Data & Storage Layer + PostgreSQL 17.2 · AWS S3 · AWS SQS + + + + + + PostgreSQL 17.2 + + Schema + 126+ golang-migrate migrations + UUID v7 primary keys throughout + Custom enums (document states, batch outcomes) + Views: fullClients · joined denormalized data + + + + Connection + Driver: pgx/v5 · Pooled via puddle + internal/serviceconfig/database/ + Mixed naming: camelCase (legacy) + snake_case + No caching layer — all reads direct to PG + Testcontainers for real DB in integration tests + + + + + + AWS S3 · Document Storage + BUCKET (main) · BUCKET_IN (inbound uploads) + internal/serviceconfig/objectstore/ + AWS SDK v2 · LocalStack for local dev + Streams original files (no format conversion) + AWS_ENDPOINT_URL_S3 override for local + + + + + + AWS SQS · Async Pipeline Queues + store_event → storeEventRunner + document_init → docInitRunner + document_sync → docSyncRunner + document_clean → docCleanRunner + document_text → docTextRunner + client_sync · query_runner · query_sync + LocalStack simulation · AWS_ENDPOINT_URL_SQS + 9 queues · runner.Server[B] generic polling loop + + + + + + + + EXTERNAL SERVICES & INFRASTRUCTURE + + + + + + AWS Cognito + JWT Authentication + User pool · us-east-2 region + Token: Authorization header / auth_token cookie + COGNITO_ENVIRONMENT_ID — environment-scoped access + Login/callback/logout OAuth2 flows + internal/cognitoauth/ · lestrrat-go/jwx v2 + Connects to: Middleware JWT Auth layer + + + + + + Permit.io PDP + RBAC Authorization + Policy Decision Point — runs locally port 7000 + Fine-grained permissions after JWT validation + Super-admin role bypasses env access control + permitio/permit-golang v1.2.5 SDK + internal/cognitoauth/permitio.go + Connects to: Middleware RBAC Authorization layer + + + + + + Prometheus + LocalStack + Observability & Local AWS Simulation + Prometheus metrics on /metrics · port 9090 (local) + LocalStack simulates S3 and SQS for local dev + Testcontainers: real PostgreSQL in integration tests + observability.ConfigProvider interface + task compose:refresh — spins up full local stack + Connects to: Middleware Prometheus layer + + + + + + Async Document Processing Pipeline + SQS Runner Services · cmd/*Runner/ · port range 8081–8090 + S3 Upload Event + + storeEventRunner + + docInitRunner + + docSyncRunner + + docCleanRunner + + docTextRunner + + PostgreSQL update + Each runner: runner.Controller[B] generic interface + Process(ctx, body B) bool → true=ACK, false=requeue + Also: clientSyncRunner · queryRunner · querySyncRunner · queryVersionSyncRunner + api/{runner}/runner.go · Polls SQS every ~1 second + + + + + + Query Orchestration API · feature/mutable-metadata1 · Go 1.23 · Echo v4 · sqlc v1.27 · PostgreSQL 17.2 · AWS SDK v2 + + diff --git a/docs/ai.generated/queryapi.summary.md b/docs/ai.generated/queryapi.summary.md index 67e32394..bc8a69d5 100644 --- a/docs/ai.generated/queryapi.summary.md +++ b/docs/ai.generated/queryapi.summary.md @@ -47,6 +47,14 @@ This document provides a comprehensive summary of all REST API endpoints availab | /admin/eula/compliance | GET | EulaService | | /ui-settings | GET, POST | UISettingsService | | /ui-settings/{id} | GET, PATCH, DELETE | UISettingsService | +| /super-admin/custom-schemas | POST, GET | SuperAdminSchemaService | +| /super-admin/custom-schemas/{schemaId} | GET, DELETE | SuperAdminSchemaService | +| /super-admin/custom-schemas/{schemaId}/versions | POST | SuperAdminSchemaService | +| /super-admin/documents/{id}/schema | PATCH | SuperAdminSchemaService | +| /super-admin/documents/{id}/reset-metadata | POST | SuperAdminSchemaService | +| /custom-metadata | GET, POST | CustomMetadataService | +| /custom-metadata/version | GET | CustomMetadataService | +| /custom-metadata/history | GET | CustomMetadataService | --- @@ -431,6 +439,103 @@ Rate limit headers included in all responses: - `GET /eula` - Current public EULA version - `GET /ui-settings`, `POST /ui-settings`, `GET /ui-settings/{id}`, `PATCH /ui-settings/{id}`, `DELETE /ui-settings/{id}` - UI settings (JWT required) +## Custom Metadata Service + +### Document Custom Metadata + +Routes accessible by `super_admin`, `user_admin`, and `client_user`. `auditor` may call GET routes only. The `POST` route requires the document to have a bound custom schema (`customSchemaId` non-null); writing to a document that has legacy field extractions returns 409. + +- **POST /custom-metadata** — Write a new custom metadata version for a document + - Request Body: `{documentId, metadata}` — `createdBy` is derived from JWT, not accepted in body + - `metadata` is validated against the document's bound schema (Draft 2020-12); max payload 1 MB + - Version number auto-assigned (monotonically increasing, serialized via parent-row `FOR UPDATE` lock) + - Returns: `CustomMetadataResponse` (201 Created) including `schemaId`, `schemaName`, `schemaVersion` + - 400 if document has no bound schema, or metadata fails validation + - 409 if document has legacy field extractions + +- **GET /custom-metadata** — Get the current (latest) metadata version for a document + - Query Parameters: + - `documentId` (required) — UUID of the target document + - Returns: `CustomMetadataResponse` with the highest-version row, decorated with `schemaId`, `schemaName`, `schemaVersion` + - 404 if document not found or has no metadata rows + +- **GET /custom-metadata/version** — Get a specific historical metadata version + - Query Parameters: + - `documentId` (required) — UUID of the target document + - `version` (required, integer >= 1) — The exact version number to retrieve + - Returns: `CustomMetadataResponse` for the requested version + - 404 if that version does not exist; 400 if `version < 1` + +- **GET /custom-metadata/history** — List version summaries for a document's metadata + - Query Parameters: + - `documentId` (required) — UUID of the target document + - `limit` (optional, default: 50, max: 200) — Values above 200 are clamped to 200 + - `offset` (optional, default: 0, min: 0) + - Returns: `CustomMetadataHistoryResponse` with `(version, createdAt, createdBy)` tuples in descending version order + - 400 if `limit < 1` or `offset < 0`; 200 with empty list if document exists but has no metadata + +--- + +## Super Admin Schema Service + +### Custom Metadata Schema Management + +All routes require the `super_admin` Permit.io role unless noted. `auditor` may call GET routes. `user_admin` and `client_user` have no access to the `super-admin` resource. + +- **POST /super-admin/custom-schemas** — Create a new client-scoped JSON Schema (version 1) + - Request Body: `{clientId, name, description?, schema}` — `createdBy` is derived from JWT, not accepted in body + - `schema` must be Draft 2020-12, root `type=object`, `additionalProperties` explicitly declared, max 64 KB + - Returns: `CustomSchemaResponse` (201 Created) + - 409 if a schema with the same name already exists for this client + +- **GET /super-admin/custom-schemas** — List schemas for a client + - Query Parameters: + - `clientId` (required) — The client whose schemas to list + - `name` (optional) — Exact-match name filter + - `status` (optional, default: `active`) — One of `active`, `superseded`, `retired`, `any` + - `includeAllVersions` (optional, default: `false`) — When `true`, returns all versions per lineage + - `limit` (optional, default: 50, max: 200) — Page size + - `offset` (optional, default: 0) — Pagination offset + - Returns: `CustomSchemaListResponse` (schema definitions omitted; use GET by ID to retrieve) + +- **GET /super-admin/custom-schemas/{schemaId}** — Get a schema by ID + - Returns: `CustomSchemaResponse` including the full JSON Schema document + - Available to `super_admin` and `auditor` + +- **POST /super-admin/custom-schemas/{schemaId}/versions** — Create a new version of an existing schema + - Path `schemaId` must be the currently `active` row for its lineage + - The parent row is marked `superseded`; the new row becomes `active` in the same transaction + - Request Body: `{description?, schema}` — `clientId` and `name` inherited from parent + - Returns: `CustomSchemaResponse` for the new version (201 Created) + - 409 if parent is not `active` (already `superseded` or `retired`) + +- **DELETE /super-admin/custom-schemas/{schemaId}** — Retire a schema + - Sets `status=retired`; preserves the row for audit history + - Returns: 204 No Content + - 409 if any document still references this schema version (`documentCount > 0`) + +### Document Schema Assignment + +- **PATCH /super-admin/documents/{id}/schema** — Bind (or clear) a custom schema for a document + - Restricted to `super_admin` role + - Request Body: `{customSchemaId}` — set to a UUID to bind, or `null` to clear + - The target schema must be `active` and belong to the same client as the document + - 409 if document already has custom metadata (Trigger 1 prevents reassignment); 409 if document has legacy field extractions + - Returns: `DocumentSchemaAssignResponse` with `documentId`, `customSchemaId`, `schemaName`, `schemaVersion` (200 OK) + +### Document Metadata Reset + +- **POST /super-admin/documents/{id}/reset-metadata** — Wipe a document's custom metadata history and clear its schema binding + - Restricted to `super_admin` role (user_admin, client_user, auditor all receive 403) + - No request body required + - Idempotent: returns 200 with `metadataVersionsDeleted: 0` on a document that has no metadata or schema binding + - 404 if the document does not exist + - 409 if the document has legacy field extractions (mutual-exclusivity invariant) + - Returns: `DocumentMetadataResetResponse` with `documentId`, `metadataVersionsDeleted`, `previousSchemaId`, `previousSchemaName`, `previousSchemaVersion`, `resetAt`, `resetBy` (200 OK) + - Typical use case: schema version upgrade — reset metadata on v1, reassign document to v2, then POST new metadata conforming to v2 + +--- + ## API Versioning - **Base URL**: `https://doczy.com/v1` (production) diff --git a/docs/ai.generated/software-architecture.md b/docs/ai.generated/software-architecture.md index 226612f3..525aa7e8 100644 --- a/docs/ai.generated/software-architecture.md +++ b/docs/ai.generated/software-architecture.md @@ -79,6 +79,14 @@ flowchart TB ServiceImpl --> SQS ``` +### Visual Layer Reference + +The diagram below renders all architectural layers side by side with their components labeled — a quick visual reference for understanding where each part of the system lives. + +[![Query Orchestration API — Software Architecture Layers](architecture-diagram.png)](architecture-diagram.svg) + +*[View scalable SVG version](architecture-diagram.svg)* + ## Component Layers ### 1. Entry Point (`cmd/queryAPI/main.go`) diff --git a/go.mod b/go.mod index e0e792f0..c09ef9fb 100644 --- a/go.mod +++ b/go.mod @@ -31,6 +31,7 @@ require ( github.com/richardlehane/mscfb v1.0.6 github.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd github.com/saintfish/chardet v0.0.0-20230101081208-5e3ef4b5456d + github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 github.com/stretchr/testify v1.10.0 github.com/swaggo/echo-swagger v1.4.1 github.com/testcontainers/testcontainers-go v0.35.0 diff --git a/go.sum b/go.sum index 1efb2d35..30a9be67 100644 --- a/go.sum +++ b/go.sum @@ -88,6 +88,8 @@ github.com/dimchansky/utfbom v1.1.1 h1:vV6w1AhK4VMnhBno/TPVCoK9U/LP0PkLCS9tbxHdi github.com/dimchansky/utfbom v1.1.1/go.mod h1:SxdoEBH5qIqFocHMyGOXVAybYJdr71b1Q/j0mACtrfE= github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= +github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI= +github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= github.com/docker/docker v28.0.1+incompatible h1:FCHjSRdXhNRFjlHMTv4jUNlIBbTeRjrWfeFuJp7jpo0= github.com/docker/docker v28.0.1+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= github.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj1Br63c= @@ -310,6 +312,8 @@ github.com/sagikazarmark/slog-shim v0.1.0 h1:diDBnUNK9N/354PgrxMywXnAwEr1QZcOr6g github.com/sagikazarmark/slog-shim v0.1.0/go.mod h1:SrcSrq8aKtyuqEI1uvTDTK1arOWRIczQRv+GVI1AkeQ= github.com/saintfish/chardet v0.0.0-20230101081208-5e3ef4b5456d h1:hrujxIzL1woJ7AwssoOcM/tq5JjjG2yYOc8odClEiXA= github.com/saintfish/chardet v0.0.0-20230101081208-5e3ef4b5456d/go.mod h1:uugorj2VCxiV1x+LzaIdVa9b4S4qGAcH6cbhh4qVxOU= +github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 h1:KRzFb2m7YtdldCEkzs6KqmJw4nqEVZGK7IN2kJkjTuQ= +github.com/santhosh-tekuri/jsonschema/v6 v6.0.2/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU= github.com/segmentio/asm v1.2.0 h1:9BQrFxC+YOHJlTlHGkTrFWf59nbL3XnCoFLTwDCI7ys= github.com/segmentio/asm v1.2.0/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs= github.com/shirou/gopsutil/v3 v3.24.5 h1:i0t8kL+kQTvpAYToeuiVk3TgDeKOFioZO3Ztz/iZ9pI= diff --git a/internal/customschema/audit.go b/internal/customschema/audit.go new file mode 100644 index 00000000..1751f481 --- /dev/null +++ b/internal/customschema/audit.go @@ -0,0 +1,82 @@ +package customschema + +import ( + "context" + "log/slog" + "time" + + "github.com/google/uuid" +) + +// AuditAction is the enum of auditable actions taken by customschema.Service. +type AuditAction string + +const ( + AuditActionSchemaCreate AuditAction = "schema.create" + AuditActionSchemaUpdate AuditAction = "schema.update" + AuditActionSchemaDelete AuditAction = "schema.delete" + AuditActionSchemaAssign AuditAction = "schema.assign" + AuditActionMetadataWrite AuditAction = "metadata.write" + AuditActionMetadataReset AuditAction = "metadata.reset" + AuditActionFolderAssignSchema AuditAction = "folder.assign_schema" +) + +// AuditRecord captures one auditable event emitted by customschema.Service. +// Actor is a Cognito subject UUID (not an email). ClientID is the +// varchar(255) Cognito-pool-style client identifier (not a uuid) because +// that is how it is stored in the clients table and in every FK that +// points to it. Details holds any per-action fields that do not fit the +// structured columns above. +type AuditRecord struct { + Actor string + Action AuditAction + ResourceID uuid.UUID + ClientID string + Timestamp time.Time + Details map[string]any +} + +// AuditSink is the narrow contract customschema.Service uses to record +// events. The default implementation wraps a *slog.Logger; tests can +// substitute an in-memory capturing sink. +type AuditSink interface { + Record(ctx context.Context, rec AuditRecord) error +} + +// NewSlogAuditSink returns a sink that writes every record to the provided +// logger with a fixed audit=true attribute so downstream log aggregation +// can filter audit events from general application logs. +// +// STUB — replaced by the golang engineer in the next dispatch. Currently +// returns a sink whose Record method does nothing but returns nil, so the +// tests compile and fail on behavior assertions (not on build errors). +func NewSlogAuditSink(logger *slog.Logger) AuditSink { + return &slogAuditSink{logger: logger} +} + +type slogAuditSink struct { + logger *slog.Logger +} + +// Record is the stub body. The real implementation must emit a single +// structured log event at info level with message "audit" and the +// following attributes: +// +// audit=true, action=, actor=, resource_id=, +// client_id=, timestamp=, plus every Details key as +// its own slog.Any attribute (merged flat onto the event). +func (s *slogAuditSink) Record(ctx context.Context, rec AuditRecord) error { + attrs := []slog.Attr{ + slog.Bool("audit", true), + slog.String("action", string(rec.Action)), + slog.String("actor", rec.Actor), + slog.String("resource_id", rec.ResourceID.String()), + slog.String("client_id", rec.ClientID), + slog.String("timestamp", rec.Timestamp.Format(time.RFC3339Nano)), + } + for k, v := range rec.Details { + attrs = append(attrs, slog.Any(k, v)) + } + s.logger.LogAttrs(ctx, slog.LevelInfo, "audit", attrs...) + return nil +} diff --git a/internal/customschema/audit_test.go b/internal/customschema/audit_test.go new file mode 100644 index 00000000..306d2e9c --- /dev/null +++ b/internal/customschema/audit_test.go @@ -0,0 +1,141 @@ +package customschema_test + +import ( + "bytes" + "context" + "encoding/json" + "log/slog" + "testing" + "time" + + "github.com/google/uuid" + "github.com/stretchr/testify/require" + + "queryorchestration/internal/customschema" +) + +// newCapturingLogger returns a *slog.Logger that writes JSON lines into +// the returned buffer. Tests parse the buffer to assert emitted fields. +func newCapturingLogger() (*slog.Logger, *bytes.Buffer) { + buf := &bytes.Buffer{} + handler := slog.NewJSONHandler(buf, &slog.HandlerOptions{Level: slog.LevelInfo}) + return slog.New(handler), buf +} + +// decodeSingleLine parses exactly one JSON line from the buffer into a +// map. It fails the test if the buffer is empty or contains more than +// one line, because the audit sink contract is one-event-per-Record. +func decodeSingleLine(t *testing.T, buf *bytes.Buffer) map[string]any { + t.Helper() + raw := bytes.TrimSpace(buf.Bytes()) + require.NotEmpty(t, raw, "expected sink to emit a log line, got empty buffer") + lines := bytes.Split(raw, []byte("\n")) + require.Len(t, lines, 1, "expected exactly one emitted line, got %d", len(lines)) + out := map[string]any{} + require.NoError(t, json.Unmarshal(lines[0], &out)) + return out +} + +// TestSlogAuditSink_Record locks in the contract for the default audit +// sink that wraps a *slog.Logger. These assertions are the strict +// specification the engineer must satisfy in the next dispatch. +// +// Expected slog event: +// +// level = INFO +// msg = "audit" +// attrs = audit=true, action, actor, resource_id, client_id, +// timestamp (RFC3339Nano), plus every Details key merged flat. +func TestSlogAuditSink_Record(t *testing.T) { + actor := "00000000-0000-0000-0000-0000000000aa" + resourceID := uuid.MustParse("11111111-1111-1111-1111-111111111111") + clientID := "CLIENT_AUDIT_TEST" + ts := time.Date(2026, 4, 14, 12, 34, 56, 789000000, time.UTC) + + buildRec := func(details map[string]any) customschema.AuditRecord { + return customschema.AuditRecord{ + Actor: actor, + Action: customschema.AuditActionSchemaCreate, + ResourceID: resourceID, + ClientID: clientID, + Timestamp: ts, + Details: details, + } + } + + t.Run("emits_audit_true_attribute", func(t *testing.T) { + logger, buf := newCapturingLogger() + sink := customschema.NewSlogAuditSink(logger) + + err := sink.Record(t.Context(), buildRec(map[string]any{ + "name": "alpha", + "version": 1, + })) + require.NoError(t, err) + + event := decodeSingleLine(t, buf) + + // slog JSON encodes booleans as literal true/false, but accept + // the string "true" as well in case a handler variant stringifies. + auditVal, ok := event["audit"] + require.True(t, ok, "missing audit attribute") + switch v := auditVal.(type) { + case bool: + require.True(t, v, "audit attribute must be true") + case string: + require.Equal(t, "true", v, "audit attribute must be true") + default: + t.Fatalf("audit attribute has unexpected type %T", v) + } + + require.Equal(t, "schema.create", event["action"]) + require.Equal(t, actor, event["actor"]) + require.Equal(t, resourceID.String(), event["resource_id"]) + require.Equal(t, clientID, event["client_id"]) + + tsAttr, ok := event["timestamp"].(string) + require.True(t, ok, "timestamp must be a string") + require.NotEmpty(t, tsAttr, "timestamp must not be empty") + + require.Equal(t, "alpha", event["name"], "Details.name must be merged flat") + // json.Unmarshal decodes numbers as float64. + require.EqualValues(t, 1, event["version"], "Details.version must be merged flat") + + require.Equal(t, "INFO", event["level"]) + require.Equal(t, "audit", event["msg"]) + }) + + t.Run("record_returns_nil_on_success", func(t *testing.T) { + logger, _ := newCapturingLogger() + sink := customschema.NewSlogAuditSink(logger) + + err := sink.Record(t.Context(), buildRec(map[string]any{"k": "v"})) + require.NoError(t, err) + }) + + t.Run("empty_details_accepted", func(t *testing.T) { + logger, buf := newCapturingLogger() + sink := customschema.NewSlogAuditSink(logger) + + require.NotPanics(t, func() { + err := sink.Record(t.Context(), buildRec(nil)) + require.NoError(t, err) + }) + + event := decodeSingleLine(t, buf) + _, hasAudit := event["audit"] + require.True(t, hasAudit, "audit attribute must be present even with nil Details") + require.Equal(t, "schema.create", event["action"]) + }) + + t.Run("context_cancellation_does_not_block_emission", func(t *testing.T) { + logger, _ := newCapturingLogger() + sink := customschema.NewSlogAuditSink(logger) + + ctx, cancel := context.WithCancel(t.Context()) + cancel() + + err := sink.Record(ctx, buildRec(map[string]any{"k": "v"})) + require.NoError(t, err) + }) +} diff --git a/internal/customschema/constants.go b/internal/customschema/constants.go new file mode 100644 index 00000000..cffff0b0 --- /dev/null +++ b/internal/customschema/constants.go @@ -0,0 +1,10 @@ +package customschema + +// MaxSchemaDefinitionBytes caps the size of a JSON Schema definition document (64KB). +const MaxSchemaDefinitionBytes = 65536 + +// MaxMetadataPayloadBytes caps the size of a custom metadata JSON payload (1MB). +const MaxMetadataPayloadBytes = 1048576 + +// MaxBulkAssignDocuments caps the number of documents updated in a single bulk folder schema assignment. +const MaxBulkAssignDocuments = 10000 diff --git a/internal/customschema/models.go b/internal/customschema/models.go new file mode 100644 index 00000000..82c87ee6 --- /dev/null +++ b/internal/customschema/models.go @@ -0,0 +1,124 @@ +package customschema + +import ( + "encoding/json" + "time" + + "github.com/google/uuid" +) + +// Schema is the full representation of a client_metadata_schemas row as +// returned by service methods that operate on a single schema. The status +// is represented as a plain string ("active", "superseded", "retired") so +// HTTP handlers can serialize it without pulling in the repository enum. +type Schema struct { + ID uuid.UUID + ClientID string + Name string + Description string + SchemaDef json.RawMessage + Version int + Status string + CreatedAt time.Time + CreatedBy string +} + +// SchemaSummary is a list-view row. Derived from ListClientMetadataSchemasRow +// and augmented with DocumentCount and CanDelete (canDelete is true iff +// DocumentCount == 0, mirroring plan Section 7.1). +type SchemaSummary struct { + Schema Schema + DocumentCount int64 + CanDelete bool +} + +// CreateSchemaInput carries the body of POST /super-admin/custom-schemas. +// v4 rule: the actor (Cognito subject) is passed as a separate argument +// to CreateSchema, NOT carried as a field on this struct. Do not add a +// CreatedBy field here. +type CreateSchemaInput struct { + ClientID string + Name string + Description string + SchemaDef json.RawMessage +} + +// CreateSchemaVersionInput carries the body of +// POST /super-admin/custom-schemas/{schemaId}/versions. Actor is passed +// as a separate argument to CreateSchemaVersion, not a field here. Name +// and ClientID are intentionally absent — both are derived from the +// parent schema row inside the version-creation transaction. +type CreateSchemaVersionInput struct { + Description string + SchemaDef json.RawMessage +} + +// CustomMetadata is the full representation of a document_custom_metadata +// row returned to handlers. The v4 join in GetCurrentDocumentCustomMetadata +// / GetDocumentCustomMetadataByVersion decorates each row with the document's +// currently-bound schema id, schema name, and schema version so handlers +// can populate their responses in a single round-trip. +// +// SchemaID, SchemaName, and SchemaVersion are pointer types because the LEFT +// JOIN on client_metadata_schemas returns NULL when a document's binding has +// been cleared (rare edge case: metadata already exists but the admin used +// schema/assign with a nil schemaID on an otherwise empty document). In the +// common case all three are populated. +type CustomMetadata struct { + ID uuid.UUID + DocumentID uuid.UUID + Metadata json.RawMessage + Version int + SchemaID *uuid.UUID + SchemaName *string + SchemaVersion *int + CreatedAt time.Time + CreatedBy string +} + +// MetadataVersion is a terse history row for GET /custom-metadata/history. +// No metadata payload, no schema decoration — just the audit fields that +// let a caller decide which version to fetch in full. +type MetadataVersion struct { + Version int + CreatedAt time.Time + CreatedBy string +} + +// SetMetadataInput carries the body of POST /custom-metadata. v4 rule: the +// actor (Cognito subject) is passed as a separate argument to +// SetDocumentMetadata, NOT carried as a field here. Do not add a CreatedBy +// field. +type SetMetadataInput struct { + DocumentID uuid.UUID + Metadata json.RawMessage +} + +// AssignSchemaResult is returned by AssignSchema so the handler can build +// the DocumentSchemaAssignResponse in one shot. When schemaID == nil (the +// "clear binding" case), all three schema pointers are nil. +type AssignSchemaResult struct { + DocumentID uuid.UUID + CustomSchemaID *uuid.UUID + SchemaName *string + SchemaVersion *int +} + +// ListFilters drives ListSchemas. +// +// Status semantics: +// - zero value ("") means the default "active" filter. +// - StatusAny == true means "return schemas of every status" and maps to +// the underlying query's include_all_statuses = true. When StatusAny is +// true the Status field is ignored. +// +// IncludeAllVersions == false collapses results to MAX(version) per +// (client_id, name). Limit/Offset drive pagination. +type ListFilters struct { + Name string + Status string + StatusAny bool + IncludeAllVersions bool + Limit int32 + Offset int32 +} diff --git a/internal/customschema/models_bulk.go b/internal/customschema/models_bulk.go new file mode 100644 index 00000000..859fc4f6 --- /dev/null +++ b/internal/customschema/models_bulk.go @@ -0,0 +1,4 @@ +// Package customschema: Milestone 4 (bulk assign) pre-partitioning +// stub. This file is intentionally empty so Milestone 4 owns a file +// disjoint from models.go. See tracking §2.9. +package customschema diff --git a/internal/customschema/models_reset.go b/internal/customschema/models_reset.go new file mode 100644 index 00000000..9294d892 --- /dev/null +++ b/internal/customschema/models_reset.go @@ -0,0 +1,65 @@ +// Package customschema: Milestone 3 (reset-metadata) models and sentinel +// errors. This file declares only the types and errors specific to the +// reset workstream so it stays disjoint from Milestone 2's +// models.go / service_metadata.go (see tracking §2.9). +package customschema + +import ( + "errors" + "time" + + "github.com/google/uuid" +) + +// ErrMutualExclusivityViolation is returned by ResetDocumentMetadata when +// the locked document row carries at least one legacy field-extraction +// entry. The reset endpoint is deliberately scoped to documents using the +// custom schema system; the handler maps this sentinel to 409. +// +// This sentinel is intentionally separate from +// ErrDocumentHasLegacyExtractions (which is returned by +// AssignSchema / SetDocumentMetadata) so callers that want to distinguish +// "cannot bind / cannot write metadata" from "cannot reset" get the more +// specific wording. Both errors map to the same HTTP status (409). +var ErrMutualExclusivityViolation = errors.New("document uses the legacy field extraction system; reset-metadata only applies to documents using the custom schema system") + +// ResetMetadataResult is the service-layer return value for +// ResetDocumentMetadata. Handlers translate this into the generated +// ResetDocumentMetadataResponse type; keeping the service return free of +// OpenAPI types lets the service stay decoupled from the generated +// package. +// +// All three PreviousSchema* pointers are nil when the document had no +// schema bound at reset time. This is the idempotent no-op case and is +// returned alongside MetadataVersionsDeleted == 0. +type ResetMetadataResult struct { + // DocumentID is the document whose metadata was reset. + DocumentID uuid.UUID + + // PreviousSchemaID is the document's custom_schema_id before the + // reset ran. nil when no schema was bound. + PreviousSchemaID *uuid.UUID + + // PreviousSchemaName mirrors the joined client_metadata_schemas.name + // of the previously-bound schema. nil when no schema was bound. + PreviousSchemaName *string + + // PreviousSchemaVersion mirrors the joined client_metadata_schemas.version + // of the previously-bound schema. nil when no schema was bound. + PreviousSchemaVersion *int + + // MetadataVersionsDeleted is the number of document_custom_metadata + // rows that existed (and were subsequently deleted) at reset time. + // Zero is a valid success value — the reset endpoint is idempotent + // on already-clean documents. + MetadataVersionsDeleted int + + // ResetAt is the wall-clock time the reset completed. Populated by + // the service layer at the moment the audit record is emitted so the + // response timestamp matches the audit entry. + ResetAt time.Time + + // ResetBy is the Cognito subject of the caller, passed through from + // the handler's JWT sub claim. + ResetBy string +} diff --git a/internal/customschema/service.go b/internal/customschema/service.go new file mode 100644 index 00000000..5a49b553 --- /dev/null +++ b/internal/customschema/service.go @@ -0,0 +1,409 @@ +package customschema + +import ( + "context" + "errors" + "fmt" + "time" + + "queryorchestration/internal/database/repository" + "queryorchestration/internal/serviceconfig" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgconn" +) + +// ErrSchemaNotFound is the typed sentinel returned when a schema lookup +// cannot find a row. The service layer maps pgx.ErrNoRows (and any other +// "row is missing" condition) onto this so HTTP handlers can translate a +// single sentinel into 404 without peeking at driver errors. +var ErrSchemaNotFound = errors.New("schema not found") + +// ErrSchemaInUse is the typed sentinel returned when a write operation is +// rejected because the schema is still referenced by one or more documents +// via documents.custom_schema_id. HTTP handlers map this to 409. +var ErrSchemaInUse = errors.New("schema is in use") + +// ErrSchemaNameConflict is the typed sentinel returned when CreateSchema is +// asked to insert a (client_id, name, version) tuple that already exists. +// HTTP handlers map this to 409. Surfaces whenever the unique constraint +// uq_client_schema_name_version rejects the insert. +var ErrSchemaNameConflict = errors.New("schema name already exists for client") + +// ErrSchemaParentNotActive is the typed sentinel returned when +// CreateSchemaVersion is asked to branch from a parent row whose status +// is not 'active' (but is also not 'retired'). The canonical case is +// "caller passed v1's id while v2 is the current active row for the +// lineage". HTTP handlers map this to 409. +var ErrSchemaParentNotActive = errors.New("parent schema is not the active version in its lineage") + +// ErrSchemaParentRetired is the typed sentinel returned when +// CreateSchemaVersion is asked to branch from a parent row whose status +// is 'retired'. It is distinct from ErrSchemaParentNotActive so the +// handler layer can surface a more specific reason. HTTP handlers map +// this to 409. +var ErrSchemaParentRetired = errors.New("parent schema is retired") + +// defaultListLimit is applied when a caller leaves ListFilters.Limit at +// its zero value so the underlying SQL LIMIT clause never receives 0. +const defaultListLimit int64 = 100 + +// maxListLimit is the hard ceiling on list results, matching the OpenAPI +// spec's maximum: 200 constraint. A service-layer backstop ensures no +// caller (including future non-HTTP paths) can exceed this value. +const maxListLimit int64 = 200 + +// Service owns schema CRUD and (later) metadata operations for the +// mutable-metadata feature. It holds the database-backed config provider, +// the schema validator, and an audit sink. See plan Section 7.1. +type Service struct { + cfg serviceconfig.ConfigProvider + validator *SchemaValidator + audit AuditSink +} + +// New constructs a Service. The validator and audit sink are injected so +// tests can substitute an in-memory capturing sink without mocking. +func New(cfg serviceconfig.ConfigProvider, validator *SchemaValidator, audit AuditSink) *Service { + return &Service{cfg: cfg, validator: validator, audit: audit} +} + +// CreateSchema validates the supplied schema_def, inserts a version 1 row +// with status='active', and emits a schema.create audit entry on success. +// Validation failures and unique-constraint rejections do NOT emit audit +// entries. The actor argument is the authenticated Cognito subject — it is +// never read from the input struct. +func (s *Service) CreateSchema(ctx context.Context, input CreateSchemaInput, actor string) (*Schema, error) { + if err := s.validator.ValidateSchemaDefinition(input.SchemaDef); err != nil { + return nil, fmt.Errorf("customschema: validate schema definition: %w", err) + } + + queries := s.cfg.GetDBQueries() + description := input.Description + params := &repository.CreateClientMetadataSchemaParams{ + ClientID: input.ClientID, + Name: input.Name, + Description: &description, + SchemaDef: []byte(input.SchemaDef), + Version: 1, + Status: repository.SchemaStatusTypeActive, + CreatedBy: actor, + } + + row, err := queries.CreateClientMetadataSchema(ctx, params) + if err != nil { + var pgErr *pgconn.PgError + if errors.As(err, &pgErr) && pgErr.Code == "23505" && + pgErr.ConstraintName == "uq_client_schema_name_version" { + return nil, ErrSchemaNameConflict + } + return nil, fmt.Errorf("customschema: create schema: %w", err) + } + + rec := AuditRecord{ + Actor: actor, + Action: AuditActionSchemaCreate, + ResourceID: row.ID, + ClientID: row.ClientID, + Timestamp: time.Now().UTC(), + Details: map[string]any{ + "name": row.Name, + "version": row.Version, + }, + } + if auditErr := s.audit.Record(ctx, rec); auditErr != nil { + return nil, fmt.Errorf("customschema: record create audit: %w", auditErr) + } + + return rowToSchema(row), nil +} + +// CreateSchemaVersion validates the supplied schema_def, then inside a +// single transaction locks the (client_id, name) lineage with FOR UPDATE, +// re-reads the parent row under the lock, verifies the parent is still +// 'active', inserts a new row at max(version)+1 with status='active', and +// flips the parent row to 'superseded'. On success it emits a schema.update +// audit entry whose ResourceID is the new version row's id. Missing parent +// surfaces as ErrSchemaNotFound. Non-active-but-not-retired parents surface +// as ErrSchemaParentNotActive. Retired parents surface as +// ErrSchemaParentRetired. The actor argument is the authenticated Cognito +// subject — it is never read from the input struct. +func (s *Service) CreateSchemaVersion(ctx context.Context, parentSchemaID uuid.UUID, input CreateSchemaVersionInput, actor string) (*Schema, error) { + // 1. Fail fast on an invalid schema_def before opening any tx. + if err := s.validator.ValidateSchemaDefinition(input.SchemaDef); err != nil { + return nil, fmt.Errorf("customschema: validate schema definition: %w", err) + } + + // 2. Outer-read the parent to get (client_id, name) so the tx can lock + // the right lineage. Missing row maps to ErrSchemaNotFound. + parent, err := s.cfg.GetDBQueries().GetClientMetadataSchema(ctx, parentSchemaID) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, ErrSchemaNotFound + } + return nil, fmt.Errorf("customschema: load parent schema: %w", err) + } + + // 3. Run the write inside a transaction. The closure captures newRow + // so we can emit the audit entry after a successful commit. + var newRow *repository.ClientMetadataSchema + txErr := s.cfg.ExecuteDBTransaction(ctx, func(txCtx context.Context, q *repository.Queries) error { + // 3a. Lock every row for (client_id, name). This serializes with + // any concurrent CreateSchemaVersion for the same lineage: + // the second caller blocks here until the first commits, then + // reads the now-superseded parent and bails out cleanly. + locked, lockErr := q.LockSchemaVersionsForName(txCtx, &repository.LockSchemaVersionsForNameParams{ + ClientID: parent.ClientID, + Name: parent.Name, + }) + if lockErr != nil { + return fmt.Errorf("customschema: lock schema versions: %w", lockErr) + } + + // 3b. Re-read the parent row from the locked set to get its + // authoritative status. Also track max(version) in the same + // pass so we don't need a second query. + var parentRow *repository.ClientMetadataSchema + var maxVersion int32 + for _, r := range locked { + if r.ID == parentSchemaID { + parentRow = r + } + if r.Version > maxVersion { + maxVersion = r.Version + } + } + if parentRow == nil { + return ErrSchemaNotFound + } + + // 3c. Parent status gate. Retired is checked first so it gets the + // more specific sentinel; any other non-active status (in + // practice 'superseded') maps to ErrSchemaParentNotActive. + switch parentRow.Status { + case repository.SchemaStatusTypeRetired: + return ErrSchemaParentRetired + case repository.SchemaStatusTypeActive: + // proceed + default: + return ErrSchemaParentNotActive + } + + // 3d. Insert the new version row at max+1. + description := input.Description + insertParams := &repository.CreateClientMetadataSchemaParams{ + ClientID: parent.ClientID, + Name: parent.Name, + Description: &description, + SchemaDef: []byte(input.SchemaDef), + Version: maxVersion + 1, + Status: repository.SchemaStatusTypeActive, + CreatedBy: actor, + } + inserted, insertErr := q.CreateClientMetadataSchema(txCtx, insertParams) + if insertErr != nil { + return fmt.Errorf("customschema: insert new version: %w", insertErr) + } + + // 3e. Supersede the parent row inside the same tx so the + // active-parent invariant holds at commit time. + if statusErr := q.SetSchemaStatus(txCtx, &repository.SetSchemaStatusParams{ + ID: parentSchemaID, + Status: repository.SchemaStatusTypeSuperseded, + }); statusErr != nil { + return fmt.Errorf("customschema: supersede parent: %w", statusErr) + } + + newRow = inserted + return nil + }) + if txErr != nil { + return nil, txErr + } + + // 4. Audit on success only. ResourceID is the new version's id per + // the writes_schema_update_audit_entry subtest contract. + rec := AuditRecord{ + Actor: actor, + Action: AuditActionSchemaUpdate, + ResourceID: newRow.ID, + ClientID: newRow.ClientID, + Timestamp: time.Now().UTC(), + Details: map[string]any{ + "name": newRow.Name, + "version": newRow.Version, + "parent_schema_id": parentSchemaID.String(), + }, + } + if auditErr := s.audit.Record(ctx, rec); auditErr != nil { + return nil, fmt.Errorf("customschema: record update audit: %w", auditErr) + } + + return rowToSchema(newRow), nil +} + +// GetSchema loads a single schema by primary key, decorated with its live +// document count so callers receive accurate DocumentCount and CanDelete +// values. Missing rows surface as ErrSchemaNotFound. +func (s *Service) GetSchema(ctx context.Context, schemaID uuid.UUID) (SchemaSummary, error) { + queries := s.cfg.GetDBQueries() + row, err := queries.GetClientMetadataSchema(ctx, schemaID) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return SchemaSummary{}, ErrSchemaNotFound + } + return SchemaSummary{}, fmt.Errorf("customschema: get schema: %w", err) + } + count, err := queries.GetSchemaDocumentCount(ctx, &schemaID) + if err != nil { + return SchemaSummary{}, fmt.Errorf("customschema: get schema document count: %w", err) + } + return SchemaSummary{ + Schema: *rowToSchema(row), + DocumentCount: count, + CanDelete: count == 0, + }, nil +} + +// ListSchemas translates ListFilters onto the generated sqlc parameter +// struct, runs the query, and decorates each row with DocumentCount and +// CanDelete. CanDelete is true iff DocumentCount == 0, mirroring plan +// Section 7.1. +func (s *Service) ListSchemas(ctx context.Context, clientID string, filters ListFilters) ([]SchemaSummary, error) { + queries := s.cfg.GetDBQueries() + + var nameFilter *string + if filters.Name != "" { + name := filters.Name + nameFilter = &name + } + + statusFilter := repository.SchemaStatusTypeActive + if !filters.StatusAny && filters.Status != "" { + statusFilter = repository.SchemaStatusType(filters.Status) + } + + limit := int64(filters.Limit) + switch { + case limit <= 0: + limit = defaultListLimit + case limit > maxListLimit: + limit = maxListLimit + } + + params := &repository.ListClientMetadataSchemasParams{ + ClientID: clientID, + NameFilter: nameFilter, + IncludeAllStatuses: filters.StatusAny, + StatusFilter: statusFilter, + IncludeAllVersions: filters.IncludeAllVersions, + LimitVal: limit, + OffsetVal: int64(filters.Offset), + } + + rows, err := queries.ListClientMetadataSchemas(ctx, params) + if err != nil { + return nil, fmt.Errorf("customschema: list schemas: %w", err) + } + + summaries := make([]SchemaSummary, 0, len(rows)) + for _, row := range rows { + summaries = append(summaries, listRowToSummary(row)) + } + return summaries, nil +} + +// DeleteSchema retires (does not physically delete) a schema. It refuses +// to retire a schema that still has documents bound to it via +// documents.custom_schema_id and surfaces ErrSchemaInUse in that case. +// Missing rows surface as ErrSchemaNotFound. On success it emits a +// schema.delete audit entry. +func (s *Service) DeleteSchema(ctx context.Context, schemaID uuid.UUID, actor string) error { + queries := s.cfg.GetDBQueries() + + row, err := queries.GetClientMetadataSchema(ctx, schemaID) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return ErrSchemaNotFound + } + return fmt.Errorf("customschema: load schema for delete: %w", err) + } + + count, err := queries.GetSchemaDocumentCount(ctx, &schemaID) + if err != nil { + return fmt.Errorf("customschema: count documents for delete: %w", err) + } + if count > 0 { + return ErrSchemaInUse + } + + if err := queries.SetSchemaStatus(ctx, &repository.SetSchemaStatusParams{ + ID: schemaID, + Status: repository.SchemaStatusTypeRetired, + }); err != nil { + return fmt.Errorf("customschema: retire schema: %w", err) + } + + rec := AuditRecord{ + Actor: actor, + Action: AuditActionSchemaDelete, + ResourceID: schemaID, + ClientID: row.ClientID, + Timestamp: time.Now().UTC(), + Details: map[string]any{ + "name": row.Name, + "version": row.Version, + }, + } + if auditErr := s.audit.Record(ctx, rec); auditErr != nil { + return fmt.Errorf("customschema: record delete audit: %w", auditErr) + } + return nil +} + +// rowToSchema translates a sqlc-generated ClientMetadataSchema row into +// the customschema.Schema model returned to handlers. The pointer-valued +// Description column collapses to "" when nil and the Timestamptz created_at +// is unwrapped via its Time field. +func rowToSchema(row *repository.ClientMetadataSchema) *Schema { + description := "" + if row.Description != nil { + description = *row.Description + } + return &Schema{ + ID: row.ID, + ClientID: row.ClientID, + Name: row.Name, + Description: description, + SchemaDef: append([]byte(nil), row.SchemaDef...), + Version: int(row.Version), + Status: string(row.Status), + CreatedAt: row.CreatedAt.Time, + CreatedBy: row.CreatedBy, + } +} + +// listRowToSummary translates a generated ListClientMetadataSchemasRow +// into a SchemaSummary with CanDelete derived from DocumentCount. +func listRowToSummary(row *repository.ListClientMetadataSchemasRow) SchemaSummary { + description := "" + if row.Description != nil { + description = *row.Description + } + return SchemaSummary{ + Schema: Schema{ + ID: row.ID, + ClientID: row.ClientID, + Name: row.Name, + Description: description, + SchemaDef: append([]byte(nil), row.SchemaDef...), + Version: int(row.Version), + Status: string(row.Status), + CreatedAt: row.CreatedAt.Time, + CreatedBy: row.CreatedBy, + }, + DocumentCount: row.DocumentCount, + CanDelete: row.DocumentCount == 0, + } +} diff --git a/internal/customschema/service_metadata.go b/internal/customschema/service_metadata.go new file mode 100644 index 00000000..6af943ee --- /dev/null +++ b/internal/customschema/service_metadata.go @@ -0,0 +1,537 @@ +package customschema + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "time" + + "queryorchestration/internal/database/repository" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgconn" +) + +// ErrDocumentNotFound is the typed sentinel returned when a metadata or +// schema-assign operation targets a document that does not exist. Handlers +// map this to 404. +var ErrDocumentNotFound = errors.New("document not found") + +// ErrDocumentNotBoundToSchema is returned when SetDocumentMetadata is +// called on a document whose custom_schema_id is NULL. Handlers map this +// to 400 — the client needs to bind a schema via PATCH +// /super-admin/documents/{id}/schema before writing metadata. +var ErrDocumentNotBoundToSchema = errors.New("document is not bound to a custom schema") + +// ErrDocumentHasLegacyExtractions is returned when any mutable-metadata +// write path rejects a document that already carries a +// documentFieldExtractionVersions row. Handlers map this to 409. +var ErrDocumentHasLegacyExtractions = errors.New("document has legacy field extractions") + +// ErrDocumentHasCustomMetadata is returned when AssignSchema is asked to +// change (or clear) a schema binding on a document that already has at +// least one document_custom_metadata row. Handlers map this to 409. This +// mirrors the DB-level Trigger 1 guard from migration 130. +var ErrDocumentHasCustomMetadata = errors.New("document already has custom metadata") + +// ErrMetadataPayloadTooLarge is returned when SetDocumentMetadata receives +// a payload whose length is strictly greater than MaxMetadataPayloadBytes. +// Handlers map this to 400. +var ErrMetadataPayloadTooLarge = errors.New("metadata payload exceeds maximum size") + +// ErrMetadataVersionNotFound is returned when GetMetadataByVersion cannot +// find the requested version. Handlers map this to 404. +var ErrMetadataVersionNotFound = errors.New("metadata version not found") + +// ErrSchemaClientMismatch is returned when AssignSchema is asked to bind a +// document to a schema owned by a different client. The composite FK +// fk_documents_custom_schema_same_client enforces this at the DB layer; +// this sentinel carries the violation up to the 409 handler branch. +var ErrSchemaClientMismatch = errors.New("schema and document belong to different clients") + +// ErrSchemaNotActive is returned when AssignSchema is asked to bind a +// document to a schema whose status is not 'active'. Handlers map this to +// 409. +var ErrSchemaNotActive = errors.New("schema is not active") + +// SetDocumentMetadata writes a new metadata version for a document. The +// write order is: +// +// 1. LockDocumentForMetadataWrite (parent-row lock on documents) — the +// FIRST DML inside the tx so concurrent writers serialize here. +// 2. Read documents.custom_schema_id — reject if NULL (no binding). +// 3. Check for legacy field extractions — reject if present. +// 4. Load schema_def from the bound schema row — used by the validator. +// 5. Validate the payload against the schema (after size ceiling check). +// 6. GetMaxDocumentMetadataVersion then CreateDocumentCustomMetadata at +// version = max + 1. +// 7. Emit metadata.write audit entry post-commit. +// +// The actor argument is the authenticated Cognito subject. It is the only +// source of the created_by column; the input struct has no such field. +func (s *Service) SetDocumentMetadata(ctx context.Context, input SetMetadataInput, actor string) (*CustomMetadata, error) { + // Fail fast on the trivial input rules before opening a tx. + if input.DocumentID == uuid.Nil { + return nil, fmt.Errorf("customschema: document id is required") + } + if len(input.Metadata) > MaxMetadataPayloadBytes { + return nil, ErrMetadataPayloadTooLarge + } + + var newRow *repository.DocumentCustomMetadatum + var schemaID *uuid.UUID + var schemaName *string + var schemaVersion *int + var schemaClientID string // captured for the post-commit audit record + + runTx := func() error { + return s.cfg.ExecuteDBTransaction(ctx, func(txCtx context.Context, q *repository.Queries) error { + // 1. Parent-row lock. Must be the first DML inside the tx. + if _, err := q.LockDocumentForMetadataWrite(txCtx, input.DocumentID); err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return ErrDocumentNotFound + } + return fmt.Errorf("customschema: lock document for metadata write: %w", err) + } + + // 2. Read the current binding. NULL means the document has not + // opted in to custom metadata yet. + boundSchemaID, err := q.GetDocumentCustomSchemaId(txCtx, input.DocumentID) + if err != nil { + return fmt.Errorf("customschema: read document schema binding: %w", err) + } + if boundSchemaID == nil { + return ErrDocumentNotBoundToSchema + } + + // 3. Legacy extraction guard. The existing HasFieldExtraction + // query checks documentFieldExtractionVersions which is the + // same table the DB trigger uses. + hasLegacy, err := q.HasFieldExtraction(txCtx, input.DocumentID) + if err != nil { + return fmt.Errorf("customschema: check legacy extractions: %w", err) + } + if hasLegacy { + return ErrDocumentHasLegacyExtractions + } + + // 4. Load the schema row so we can validate the payload and + // capture schema name/version for the response. + schemaRow, err := q.GetClientMetadataSchema(txCtx, *boundSchemaID) + if err != nil { + return fmt.Errorf("customschema: load bound schema: %w", err) + } + localSchemaID := schemaRow.ID + schemaID = &localSchemaID + localSchemaName := schemaRow.Name + schemaName = &localSchemaName + localSchemaVersion := int(schemaRow.Version) + schemaVersion = &localSchemaVersion + schemaClientID = schemaRow.ClientID + + // 5. Validate the metadata against the bound schema. + if err := s.validator.ValidateMetadata(input.Metadata, schemaRow.SchemaDef); err != nil { + return fmt.Errorf("customschema: validate metadata: %w", err) + } + + // 6. Compute next version and insert. + maxVersion, err := q.GetMaxDocumentMetadataVersion(txCtx, input.DocumentID) + if err != nil { + return fmt.Errorf("customschema: read max metadata version: %w", err) + } + nextVersion := maxVersion + 1 + + inserted, err := q.CreateDocumentCustomMetadata(txCtx, &repository.CreateDocumentCustomMetadataParams{ + DocumentID: input.DocumentID, + Metadata: []byte(input.Metadata), + Version: nextVersion, + CreatedBy: actor, + }) + if err != nil { + return fmt.Errorf("customschema: insert metadata row: %w", err) + } + newRow = inserted + return nil + }) + } + + // Belt-and-suspenders: retry once on (document_id, version) unique + // violation. The parent-row FOR UPDATE lock in step 1 serializes + // concurrent writers so this should never fire in practice, but a + // retry absorbs any edge case where two transactions somehow compute + // the same next_version before either commits. + txErr := runTx() + if txErr != nil && isUniqueVersionViolation(txErr) { + txErr = runTx() + } + if txErr != nil { + return nil, txErr + } + + // Post-commit audit — the tx already released its locks, so a failure + // to persist an audit record is a non-fatal warning, but we still + // propagate it as an error for visibility (matches Milestone 1 style). + rec := AuditRecord{ + Actor: actor, + Action: AuditActionMetadataWrite, + ResourceID: newRow.ID, + ClientID: schemaClientID, + Timestamp: time.Now().UTC(), + Details: map[string]any{ + "document_id": newRow.DocumentID.String(), + "version": newRow.Version, + }, + } + if auditErr := s.audit.Record(ctx, rec); auditErr != nil { + return nil, fmt.Errorf("customschema: record metadata audit: %w", auditErr) + } + + return &CustomMetadata{ + ID: newRow.ID, + DocumentID: newRow.DocumentID, + Metadata: append(json.RawMessage(nil), newRow.Metadata...), + Version: int(newRow.Version), + SchemaID: schemaID, + SchemaName: schemaName, + SchemaVersion: schemaVersion, + CreatedAt: newRow.CreatedAt.Time, + CreatedBy: newRow.CreatedBy, + }, nil +} + +// GetCurrentMetadata returns the most-recent metadata row for a document, +// decorated with schema id/name/version via the joined query. +// ErrMetadataVersionNotFound is returned when the document has no rows. +// ErrDocumentNotFound is returned when the document itself is missing. +func (s *Service) GetCurrentMetadata(ctx context.Context, documentID uuid.UUID) (*CustomMetadata, error) { + if documentID == uuid.Nil { + return nil, fmt.Errorf("customschema: document id is required") + } + queries := s.cfg.GetDBQueries() + + // Probe for the document existence first so a missing doc returns + // ErrDocumentNotFound rather than ErrMetadataVersionNotFound. + if _, err := queries.GetDocumentCustomSchemaId(ctx, documentID); err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, ErrDocumentNotFound + } + return nil, fmt.Errorf("customschema: probe document: %w", err) + } + + row, err := queries.GetCurrentDocumentCustomMetadata(ctx, documentID) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, ErrMetadataVersionNotFound + } + return nil, fmt.Errorf("customschema: get current metadata: %w", err) + } + return currentRowToCustomMetadata(row), nil +} + +// GetMetadataByVersion returns a specific historical version for a +// document. ErrMetadataVersionNotFound is returned when the version does +// not exist. ErrDocumentNotFound is returned when the document itself is +// missing. +func (s *Service) GetMetadataByVersion(ctx context.Context, documentID uuid.UUID, version int) (*CustomMetadata, error) { + if documentID == uuid.Nil { + return nil, fmt.Errorf("customschema: document id is required") + } + if version < 1 { + return nil, fmt.Errorf("customschema: version must be >= 1") + } + queries := s.cfg.GetDBQueries() + + if _, err := queries.GetDocumentCustomSchemaId(ctx, documentID); err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, ErrDocumentNotFound + } + return nil, fmt.Errorf("customschema: probe document: %w", err) + } + + row, err := queries.GetDocumentCustomMetadataByVersion(ctx, &repository.GetDocumentCustomMetadataByVersionParams{ + DocumentID: documentID, + Version: int32(version), //nolint:gosec // metadata version is a per-doc monotonic counter, far below int32 ceiling + }) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, ErrMetadataVersionNotFound + } + return nil, fmt.Errorf("customschema: get metadata by version: %w", err) + } + return byVersionRowToCustomMetadata(row), nil +} + +// MaxMetadataHistoryLimit clamps the maximum page size for +// GetMetadataHistory. Callers requesting more than this are silently +// capped at the ceiling — the error path is reserved for obviously +// invalid values like 0 or negative numbers. +const MaxMetadataHistoryLimit = 200 + +// GetMetadataHistory returns a paged list of version summaries for a +// document in descending version order. limit must be in [1, 200]; offset +// must be >= 0. Out-of-range limit returns an error; excessive limit is +// clamped to MaxMetadataHistoryLimit. +func (s *Service) GetMetadataHistory(ctx context.Context, documentID uuid.UUID, limit, offset int) ([]MetadataVersion, error) { + if documentID == uuid.Nil { + return nil, fmt.Errorf("customschema: document id is required") + } + if limit <= 0 { + return nil, fmt.Errorf("customschema: limit must be between 1 and %d", MaxMetadataHistoryLimit) + } + if offset < 0 { + return nil, fmt.Errorf("customschema: offset must be >= 0") + } + if limit > MaxMetadataHistoryLimit { + limit = MaxMetadataHistoryLimit + } + + queries := s.cfg.GetDBQueries() + if _, err := queries.GetDocumentCustomSchemaId(ctx, documentID); err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, ErrDocumentNotFound + } + return nil, fmt.Errorf("customschema: probe document: %w", err) + } + + rows, err := queries.GetDocumentCustomMetadataHistory(ctx, &repository.GetDocumentCustomMetadataHistoryParams{ + DocumentID: documentID, + LimitVal: int64(limit), + OffsetVal: int64(offset), + }) + if err != nil { + return nil, fmt.Errorf("customschema: get metadata history: %w", err) + } + + out := make([]MetadataVersion, 0, len(rows)) + for _, r := range rows { + out = append(out, MetadataVersion{ + Version: int(r.Version), + CreatedAt: r.CreatedAt.Time, + CreatedBy: r.CreatedBy, + }) + } + return out, nil +} + +// AssignSchema binds (or clears) a document's custom schema. The tx takes +// FOR UPDATE on the documents row as its FIRST statement so the +// mutual-exclusivity invariant holds under concurrent AssignSchema / +// CreateFieldExtraction calls. The rules enforced in order: +// +// 1. Document exists (pgx.ErrNoRows → ErrDocumentNotFound). +// 2. If schemaID is non-nil: +// a. Schema exists and is active. +// b. Schema belongs to the same client as the document (the composite +// FK enforces this at commit time; we convert the 23503 surface to a +// clean sentinel if it fires). +// 3. Document has no existing custom metadata (mirrors Trigger 1). +// 4. Document has no legacy field extractions (mirrors Trigger 1). +// 5. UPDATE documents SET custom_schema_id = ... WHERE id = ... . +// 6. Post-commit audit entry (schema.assign). +func (s *Service) AssignSchema(ctx context.Context, documentID uuid.UUID, schemaID *uuid.UUID, actor string) (*AssignSchemaResult, error) { + if documentID == uuid.Nil { + return nil, fmt.Errorf("customschema: document id is required") + } + + var ( + resultSchemaID *uuid.UUID + resultSchemaName *string + resultSchemaVersion *int + resultClientID string + ) + + txErr := s.cfg.ExecuteDBTransaction(ctx, func(txCtx context.Context, q *repository.Queries) error { + docClientID, err := s.assignSchemaLockAndCheck(txCtx, q, documentID, schemaID) + if err != nil { + return err + } + resultClientID = docClientID + + if schemaID != nil { + schemaRow, err := s.assignSchemaValidateTarget(txCtx, q, *schemaID, docClientID) + if err != nil { + return err + } + idCopy := schemaRow.ID + resultSchemaID = &idCopy + nameCopy := schemaRow.Name + resultSchemaName = &nameCopy + verCopy := int(schemaRow.Version) + resultSchemaVersion = &verCopy + } + + if err := q.SetDocumentCustomSchemaId(txCtx, &repository.SetDocumentCustomSchemaIdParams{ + ID: documentID, + CustomSchemaID: schemaID, + }); err != nil { + // The composite FK can fire here if a race changed the + // clientId between the check above and the UPDATE. Map it + // to a clean sentinel. + var pgErr *pgconn.PgError + if errors.As(err, &pgErr) && pgErr.Code == "23503" && + pgErr.ConstraintName == "fk_documents_custom_schema_same_client" { + return ErrSchemaClientMismatch + } + return fmt.Errorf("customschema: update document schema: %w", err) + } + return nil + }) + if txErr != nil { + return nil, txErr + } + + // Post-commit audit. Resource id is the document id (the thing being + // mutated); details carry the new schema id (or nil for clears). + details := map[string]any{} + if resultSchemaID != nil { + details["schema_id"] = resultSchemaID.String() + details["schema_name"] = *resultSchemaName + details["schema_version"] = *resultSchemaVersion + } else { + details["schema_id"] = nil + } + if err := s.audit.Record(ctx, AuditRecord{ + Actor: actor, + Action: AuditActionSchemaAssign, + ResourceID: documentID, + ClientID: resultClientID, + Timestamp: time.Now().UTC(), + Details: details, + }); err != nil { + return nil, fmt.Errorf("customschema: record schema.assign audit: %w", err) + } + + return &AssignSchemaResult{ + DocumentID: documentID, + CustomSchemaID: resultSchemaID, + SchemaName: resultSchemaName, + SchemaVersion: resultSchemaVersion, + }, nil +} + +// assignSchemaLockAndCheck runs the FOR UPDATE parent-row lock and the +// mutex invariant checks for AssignSchema. incomingSchemaID is the value +// being written; when it equals the document's current binding the call is +// idempotent and the mutation guards are skipped. Returns the document's +// client id on success. +func (s *Service) assignSchemaLockAndCheck(txCtx context.Context, q *repository.Queries, documentID uuid.UUID, incomingSchemaID *uuid.UUID) (string, error) { + lockRow, err := q.LockDocumentForLegacyExtractionWrite(txCtx, documentID) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return "", ErrDocumentNotFound + } + return "", fmt.Errorf("customschema: lock document for assign schema: %w", err) + } + docClientID, err := q.GetDocumentClientID(txCtx, documentID) + if err != nil { + return "", fmt.Errorf("customschema: read document client id: %w", err) + } + // Idempotency: if the incoming binding already equals the current value, + // skip the mutation guards. The DB trigger treats an unchanged + // custom_schema_id UPDATE as a no-op, so the later UPDATE is safe. + if schemaIDsEqual(incomingSchemaID, lockRow.CustomSchemaID) { + return docClientID, nil + } + hasMetadata, err := q.HasDocumentCustomMetadata(txCtx, documentID) + if err != nil { + return "", fmt.Errorf("customschema: check existing metadata: %w", err) + } + if hasMetadata { + return "", ErrDocumentHasCustomMetadata + } + hasLegacy, err := q.HasFieldExtraction(txCtx, documentID) + if err != nil { + return "", fmt.Errorf("customschema: check legacy extractions: %w", err) + } + if hasLegacy { + return "", ErrDocumentHasLegacyExtractions + } + return docClientID, nil +} + +// schemaIDsEqual returns true when both pointers represent the same schema +// binding: both nil (no schema) or both point to the same UUID. +func schemaIDsEqual(a, b *uuid.UUID) bool { + if a == nil && b == nil { + return true + } + if a == nil || b == nil { + return false + } + return *a == *b +} + +// assignSchemaValidateTarget loads the target schema row, verifies it is +// active, and checks the cross-client invariant. Returns the schema row +// on success. +func (s *Service) assignSchemaValidateTarget(txCtx context.Context, q *repository.Queries, schemaID uuid.UUID, docClientID string) (*repository.ClientMetadataSchema, error) { + schemaRow, err := q.GetClientMetadataSchema(txCtx, schemaID) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, ErrSchemaNotFound + } + return nil, fmt.Errorf("customschema: load target schema: %w", err) + } + if schemaRow.Status != repository.SchemaStatusTypeActive { + return nil, ErrSchemaNotActive + } + if schemaRow.ClientID != docClientID { + return nil, ErrSchemaClientMismatch + } + return schemaRow, nil +} + +// currentRowToCustomMetadata translates the joined row type into the +// service's CustomMetadata model. +func currentRowToCustomMetadata(row *repository.GetCurrentDocumentCustomMetadataRow) *CustomMetadata { + var schemaVersion *int + if row.SchemaVersion != nil { + v := int(*row.SchemaVersion) + schemaVersion = &v + } + return &CustomMetadata{ + ID: row.ID, + DocumentID: row.DocumentID, + Metadata: append(json.RawMessage(nil), row.Metadata...), + Version: int(row.Version), + SchemaID: row.SchemaID, + SchemaName: row.SchemaName, + SchemaVersion: schemaVersion, + CreatedAt: row.CreatedAt.Time, + CreatedBy: row.CreatedBy, + } +} + +// byVersionRowToCustomMetadata translates the version lookup row into the +// service's CustomMetadata model. Same shape as the current-version row +// type so the translation is near-identical. +func byVersionRowToCustomMetadata(row *repository.GetDocumentCustomMetadataByVersionRow) *CustomMetadata { + var schemaVersion *int + if row.SchemaVersion != nil { + v := int(*row.SchemaVersion) + schemaVersion = &v + } + return &CustomMetadata{ + ID: row.ID, + DocumentID: row.DocumentID, + Metadata: append(json.RawMessage(nil), row.Metadata...), + Version: int(row.Version), + SchemaID: row.SchemaID, + SchemaName: row.SchemaName, + SchemaVersion: schemaVersion, + CreatedAt: row.CreatedAt.Time, + CreatedBy: row.CreatedBy, + } +} + +// isUniqueVersionViolation returns true when err is a PostgreSQL 23505 +// (unique_violation) on the uq_doc_custom_metadata_version constraint. +// Used by SetDocumentMetadata to decide whether a single retry is warranted. +func isUniqueVersionViolation(err error) bool { + var pgErr *pgconn.PgError + return errors.As(err, &pgErr) && + pgErr.Code == "23505" && + pgErr.ConstraintName == "uq_doc_custom_metadata_version" +} diff --git a/internal/customschema/service_metadata_test.go b/internal/customschema/service_metadata_test.go new file mode 100644 index 00000000..5a5e65aa --- /dev/null +++ b/internal/customschema/service_metadata_test.go @@ -0,0 +1,444 @@ +package customschema_test + +import ( + "context" + "encoding/json" + "errors" + "testing" + + "queryorchestration/internal/customschema" + "queryorchestration/internal/database/repository" + + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// Mutable metadata feature, milestone 2.4: integration tests for the +// service layer's metadata + document-binding methods. No mocks — every +// test runs against a real Postgres testcontainer stood up by +// internal/test.CreateDB. + +// seedDocForMetadataTest creates a fresh document for the given clientID +// and returns its id. Relies on the fixture builder in service_test.go +// which has already cleaned up the client. +func seedDocForMetadataTest(t *testing.T, ctx context.Context, fx *testFixture, clientID, hash string) uuid.UUID { + t.Helper() + docID, err := fx.queries.CreateDocument(ctx, &repository.CreateDocumentParams{ + Clientid: clientID, + Hash: hash, + }) + require.NoError(t, err) + return docID +} + +// seedActiveSchemaForMetadataTest creates an active schema for a client. +// Uses the service's CreateSchema so the path exercises Milestone 1's +// happy flow too (defense in depth for test setup correctness). +func seedActiveSchemaForMetadataTest(t *testing.T, ctx context.Context, fx *testFixture, clientID, name string) *customschema.Schema { + t.Helper() + got, err := fx.service.CreateSchema(ctx, customschema.CreateSchemaInput{ + ClientID: clientID, + Name: name, + SchemaDef: json.RawMessage(`{"type":"object","additionalProperties":false,"properties":{"foo":{"type":"string"}}}`), + }, testActor) + require.NoError(t, err) + return got +} + +// TestService_AssignSchema covers the core AssignSchema paths: happy +// bind, clear (nil schemaID), 404 on missing document, 409 on cross- +// client, 409 on non-active schema, 409 on existing metadata, 409 on +// legacy extractions. +func TestService_AssignSchema(t *testing.T) { + if testing.Short() { + t.SkipNow() + } + ctx := t.Context() + const clientA = "TEST_SVC_ASSIGN_A" + const clientB = "TEST_SVC_ASSIGN_B" + fx := newTestService(t, clientA, clientB) + + t.Run("happy_bind", func(t *testing.T) { + sch := seedActiveSchemaForMetadataTest(t, ctx, fx, clientA, "assign-happy") + docID := seedDocForMetadataTest(t, ctx, fx, clientA, "assign-happy-doc") + + res, err := fx.service.AssignSchema(ctx, docID, &sch.ID, testActor) + require.NoError(t, err) + require.NotNil(t, res) + require.NotNil(t, res.CustomSchemaID) + assert.Equal(t, sch.ID, *res.CustomSchemaID) + require.NotNil(t, res.SchemaName) + assert.Equal(t, "assign-happy", *res.SchemaName) + require.NotNil(t, res.SchemaVersion) + assert.Equal(t, 1, *res.SchemaVersion) + + // DB round-trip: documents.custom_schema_id is now the schema id. + got, err := fx.queries.GetDocumentCustomSchemaId(ctx, docID) + require.NoError(t, err) + require.NotNil(t, got) + assert.Equal(t, sch.ID, *got) + }) + + t.Run("clear_binding_when_no_metadata", func(t *testing.T) { + sch := seedActiveSchemaForMetadataTest(t, ctx, fx, clientA, "assign-clear") + docID := seedDocForMetadataTest(t, ctx, fx, clientA, "assign-clear-doc") + // Bind first. + _, err := fx.service.AssignSchema(ctx, docID, &sch.ID, testActor) + require.NoError(t, err) + // Now clear. + res, err := fx.service.AssignSchema(ctx, docID, nil, testActor) + require.NoError(t, err) + require.NotNil(t, res) + assert.Nil(t, res.CustomSchemaID) + assert.Nil(t, res.SchemaName) + assert.Nil(t, res.SchemaVersion) + + got, err := fx.queries.GetDocumentCustomSchemaId(ctx, docID) + require.NoError(t, err) + assert.Nil(t, got) + }) + + t.Run("rejects_missing_document", func(t *testing.T) { + sch := seedActiveSchemaForMetadataTest(t, ctx, fx, clientA, "assign-missing-doc") + _, err := fx.service.AssignSchema(ctx, uuid.New(), &sch.ID, testActor) + require.Error(t, err) + assert.True(t, errors.Is(err, customschema.ErrDocumentNotFound), + "expected ErrDocumentNotFound, got %v", err) + }) + + t.Run("rejects_cross_client_schema", func(t *testing.T) { + schB := seedActiveSchemaForMetadataTest(t, ctx, fx, clientB, "assign-cross") + docA := seedDocForMetadataTest(t, ctx, fx, clientA, "assign-cross-docA") + _, err := fx.service.AssignSchema(ctx, docA, &schB.ID, testActor) + require.Error(t, err) + assert.True(t, errors.Is(err, customschema.ErrSchemaClientMismatch), + "expected ErrSchemaClientMismatch, got %v", err) + }) + + t.Run("rejects_non_active_schema", func(t *testing.T) { + sch := seedActiveSchemaForMetadataTest(t, ctx, fx, clientA, "assign-non-active") + // Retire the schema. + require.NoError(t, fx.service.DeleteSchema(ctx, sch.ID, testActor)) + + docID := seedDocForMetadataTest(t, ctx, fx, clientA, "assign-non-active-doc") + _, err := fx.service.AssignSchema(ctx, docID, &sch.ID, testActor) + require.Error(t, err) + assert.True(t, errors.Is(err, customschema.ErrSchemaNotActive), + "expected ErrSchemaNotActive, got %v", err) + }) + + t.Run("rejects_when_metadata_exists", func(t *testing.T) { + sch := seedActiveSchemaForMetadataTest(t, ctx, fx, clientA, "assign-has-meta") + docID := seedDocForMetadataTest(t, ctx, fx, clientA, "assign-has-meta-doc") + // Bind, then write metadata. + _, err := fx.service.AssignSchema(ctx, docID, &sch.ID, testActor) + require.NoError(t, err) + _, err = fx.service.SetDocumentMetadata(ctx, customschema.SetMetadataInput{ + DocumentID: docID, + Metadata: json.RawMessage(`{"foo":"bar"}`), + }, testActor) + require.NoError(t, err) + + // Try to change the binding — must 409. + other := seedActiveSchemaForMetadataTest(t, ctx, fx, clientA, "assign-has-meta-other") + _, err = fx.service.AssignSchema(ctx, docID, &other.ID, testActor) + require.Error(t, err) + assert.True(t, errors.Is(err, customschema.ErrDocumentHasCustomMetadata), + "expected ErrDocumentHasCustomMetadata, got %v", err) + }) + + t.Run("idempotent_rebind_same_schema", func(t *testing.T) { + // Contract: re-assigning the same schema to a document that already + // has metadata must succeed (no-op). The mutation guards only fire + // when the incoming value differs from the current binding. + sch := seedActiveSchemaForMetadataTest(t, ctx, fx, clientA, "assign-idem-rebind") + docID := seedDocForMetadataTest(t, ctx, fx, clientA, "assign-idem-rebind-doc") + _, err := fx.service.AssignSchema(ctx, docID, &sch.ID, testActor) + require.NoError(t, err) + _, err = fx.service.SetDocumentMetadata(ctx, customschema.SetMetadataInput{ + DocumentID: docID, + Metadata: json.RawMessage(`{"foo":"bar"}`), + }, testActor) + require.NoError(t, err) + + // Retry with the same schema — must succeed. + res, err := fx.service.AssignSchema(ctx, docID, &sch.ID, testActor) + require.NoError(t, err, "re-assigning the same schema must be idempotent") + require.NotNil(t, res) + require.NotNil(t, res.CustomSchemaID) + assert.Equal(t, sch.ID, *res.CustomSchemaID) + }) + + t.Run("idempotent_clear_already_unbound", func(t *testing.T) { + // Contract: clearing a schema binding on a document that is already + // unbound must succeed (no-op). nil → nil is idempotent. + docID := seedDocForMetadataTest(t, ctx, fx, clientA, "assign-idem-clear-doc") + + res, err := fx.service.AssignSchema(ctx, docID, nil, testActor) + require.NoError(t, err, "clearing an already-unbound document must be idempotent") + require.NotNil(t, res) + assert.Nil(t, res.CustomSchemaID) + }) +} + +// TestService_SetDocumentMetadata covers the happy path and each +// documented rejection rule for SetDocumentMetadata. +func TestService_SetDocumentMetadata(t *testing.T) { + if testing.Short() { + t.SkipNow() + } + ctx := t.Context() + const clientID = "TEST_SVC_SET_META" + fx := newTestService(t, clientID) + + t.Run("rejects_when_document_has_no_schema", func(t *testing.T) { + docID := seedDocForMetadataTest(t, ctx, fx, clientID, "set-meta-no-schema") + _, err := fx.service.SetDocumentMetadata(ctx, customschema.SetMetadataInput{ + DocumentID: docID, + Metadata: json.RawMessage(`{"foo":"bar"}`), + }, testActor) + require.Error(t, err) + assert.True(t, errors.Is(err, customschema.ErrDocumentNotBoundToSchema), + "expected ErrDocumentNotBoundToSchema, got %v", err) + }) + + t.Run("happy_path_then_version_increment", func(t *testing.T) { + sch := seedActiveSchemaForMetadataTest(t, ctx, fx, clientID, "set-happy") + docID := seedDocForMetadataTest(t, ctx, fx, clientID, "set-happy-doc") + _, err := fx.service.AssignSchema(ctx, docID, &sch.ID, testActor) + require.NoError(t, err) + + // First write → version 1. + m1, err := fx.service.SetDocumentMetadata(ctx, customschema.SetMetadataInput{ + DocumentID: docID, + Metadata: json.RawMessage(`{"foo":"one"}`), + }, testActor) + require.NoError(t, err) + require.NotNil(t, m1) + assert.Equal(t, 1, m1.Version) + require.NotNil(t, m1.SchemaID) + assert.Equal(t, sch.ID, *m1.SchemaID) + require.NotNil(t, m1.SchemaName) + assert.Equal(t, "set-happy", *m1.SchemaName) + + // Second write → version 2. + m2, err := fx.service.SetDocumentMetadata(ctx, customschema.SetMetadataInput{ + DocumentID: docID, + Metadata: json.RawMessage(`{"foo":"two"}`), + }, testActor) + require.NoError(t, err) + assert.Equal(t, 2, m2.Version) + + // Third write → version 3. + m3, err := fx.service.SetDocumentMetadata(ctx, customschema.SetMetadataInput{ + DocumentID: docID, + Metadata: json.RawMessage(`{"foo":"three"}`), + }, testActor) + require.NoError(t, err) + assert.Equal(t, 3, m3.Version) + }) + + t.Run("rejects_invalid_payload_against_schema", func(t *testing.T) { + // Schema requires foo to be a string. + sch, err := fx.service.CreateSchema(ctx, customschema.CreateSchemaInput{ + ClientID: clientID, + Name: "set-invalid", + SchemaDef: json.RawMessage( + `{"type":"object","additionalProperties":false,"required":["foo"],"properties":{"foo":{"type":"string"}}}`), + }, testActor) + require.NoError(t, err) + docID := seedDocForMetadataTest(t, ctx, fx, clientID, "set-invalid-doc") + _, err = fx.service.AssignSchema(ctx, docID, &sch.ID, testActor) + require.NoError(t, err) + + _, err = fx.service.SetDocumentMetadata(ctx, customschema.SetMetadataInput{ + DocumentID: docID, + Metadata: json.RawMessage(`{"foo": 42}`), // number, not string + }, testActor) + require.Error(t, err) + // Neither ErrDocumentNotFound nor ErrDocumentNotBoundToSchema. + assert.False(t, errors.Is(err, customschema.ErrDocumentNotFound)) + assert.False(t, errors.Is(err, customschema.ErrDocumentNotBoundToSchema)) + }) + + t.Run("rejects_oversize_payload", func(t *testing.T) { + sch := seedActiveSchemaForMetadataTest(t, ctx, fx, clientID, "set-oversize") + docID := seedDocForMetadataTest(t, ctx, fx, clientID, "set-oversize-doc") + _, err := fx.service.AssignSchema(ctx, docID, &sch.ID, testActor) + require.NoError(t, err) + + // Build a payload strictly larger than MaxMetadataPayloadBytes + // (1 MiB). Shape: `{"foo":""}`. + overhead := len(`{"foo":""}`) + padding := customschema.MaxMetadataPayloadBytes - overhead + 1 + big := make([]byte, 0, customschema.MaxMetadataPayloadBytes+1) + big = append(big, `{"foo":"`...) + for i := 0; i < padding; i++ { + big = append(big, 'x') + } + big = append(big, `"}`...) + require.Greater(t, len(big), customschema.MaxMetadataPayloadBytes) + + _, err = fx.service.SetDocumentMetadata(ctx, customschema.SetMetadataInput{ + DocumentID: docID, + Metadata: json.RawMessage(big), + }, testActor) + require.Error(t, err) + assert.True(t, errors.Is(err, customschema.ErrMetadataPayloadTooLarge), + "expected ErrMetadataPayloadTooLarge, got %v", err) + }) +} + +// TestService_GetCurrentMetadata covers the read path for the most-recent +// version of a document's metadata, including the schema decoration from +// the joined query. +func TestService_GetCurrentMetadata(t *testing.T) { + if testing.Short() { + t.SkipNow() + } + ctx := t.Context() + const clientID = "TEST_SVC_GET_CURRENT" + fx := newTestService(t, clientID) + + t.Run("returns_current_with_schema_decoration", func(t *testing.T) { + sch := seedActiveSchemaForMetadataTest(t, ctx, fx, clientID, "get-current") + docID := seedDocForMetadataTest(t, ctx, fx, clientID, "get-current-doc") + _, err := fx.service.AssignSchema(ctx, docID, &sch.ID, testActor) + require.NoError(t, err) + _, err = fx.service.SetDocumentMetadata(ctx, customschema.SetMetadataInput{ + DocumentID: docID, + Metadata: json.RawMessage(`{"foo":"v1"}`), + }, testActor) + require.NoError(t, err) + _, err = fx.service.SetDocumentMetadata(ctx, customschema.SetMetadataInput{ + DocumentID: docID, + Metadata: json.RawMessage(`{"foo":"v2"}`), + }, testActor) + require.NoError(t, err) + + got, err := fx.service.GetCurrentMetadata(ctx, docID) + require.NoError(t, err) + require.NotNil(t, got) + assert.Equal(t, 2, got.Version, "must return the latest version") + assert.JSONEq(t, `{"foo":"v2"}`, string(got.Metadata)) + require.NotNil(t, got.SchemaID) + assert.Equal(t, sch.ID, *got.SchemaID) + require.NotNil(t, got.SchemaName) + assert.Equal(t, "get-current", *got.SchemaName) + require.NotNil(t, got.SchemaVersion) + assert.Equal(t, 1, *got.SchemaVersion) + }) + + t.Run("returns_not_found_for_missing_document", func(t *testing.T) { + _, err := fx.service.GetCurrentMetadata(ctx, uuid.New()) + require.Error(t, err) + assert.True(t, errors.Is(err, customschema.ErrDocumentNotFound)) + }) + + t.Run("returns_version_not_found_for_empty_history", func(t *testing.T) { + docID := seedDocForMetadataTest(t, ctx, fx, clientID, "get-current-empty-doc") + _, err := fx.service.GetCurrentMetadata(ctx, docID) + require.Error(t, err) + assert.True(t, errors.Is(err, customschema.ErrMetadataVersionNotFound)) + }) +} + +// TestService_GetMetadataByVersion covers the version-scoped read path. +func TestService_GetMetadataByVersion(t *testing.T) { + if testing.Short() { + t.SkipNow() + } + ctx := t.Context() + const clientID = "TEST_SVC_GET_VERSION" + fx := newTestService(t, clientID) + + sch := seedActiveSchemaForMetadataTest(t, ctx, fx, clientID, "get-version") + docID := seedDocForMetadataTest(t, ctx, fx, clientID, "get-version-doc") + _, err := fx.service.AssignSchema(ctx, docID, &sch.ID, testActor) + require.NoError(t, err) + for i, payload := range []string{`{"foo":"v1"}`, `{"foo":"v2"}`, `{"foo":"v3"}`} { + _, err := fx.service.SetDocumentMetadata(ctx, customschema.SetMetadataInput{ + DocumentID: docID, + Metadata: json.RawMessage(payload), + }, testActor) + require.NoError(t, err, "seed version %d", i+1) + } + + t.Run("returns_exact_version_2", func(t *testing.T) { + got, err := fx.service.GetMetadataByVersion(ctx, docID, 2) + require.NoError(t, err) + require.NotNil(t, got) + assert.Equal(t, 2, got.Version) + assert.JSONEq(t, `{"foo":"v2"}`, string(got.Metadata)) + }) + + t.Run("returns_version_not_found_for_too_high", func(t *testing.T) { + _, err := fx.service.GetMetadataByVersion(ctx, docID, 99) + require.Error(t, err) + assert.True(t, errors.Is(err, customschema.ErrMetadataVersionNotFound)) + }) +} + +// TestService_GetMetadataHistory covers pagination and edge-case handling. +func TestService_GetMetadataHistory(t *testing.T) { + if testing.Short() { + t.SkipNow() + } + ctx := t.Context() + const clientID = "TEST_SVC_GET_HIST" + fx := newTestService(t, clientID) + + sch := seedActiveSchemaForMetadataTest(t, ctx, fx, clientID, "get-hist") + docID := seedDocForMetadataTest(t, ctx, fx, clientID, "get-hist-doc") + _, err := fx.service.AssignSchema(ctx, docID, &sch.ID, testActor) + require.NoError(t, err) + for i := 0; i < 5; i++ { + _, err := fx.service.SetDocumentMetadata(ctx, customschema.SetMetadataInput{ + DocumentID: docID, + Metadata: json.RawMessage(`{"foo":"x"}`), + }, testActor) + require.NoError(t, err) + } + + t.Run("returns_newest_first_with_limit", func(t *testing.T) { + got, err := fx.service.GetMetadataHistory(ctx, docID, 2, 0) + require.NoError(t, err) + require.Len(t, got, 2) + assert.Equal(t, 5, got[0].Version) + assert.Equal(t, 4, got[1].Version) + }) + + t.Run("paginates_through_offset", func(t *testing.T) { + got, err := fx.service.GetMetadataHistory(ctx, docID, 2, 2) + require.NoError(t, err) + require.Len(t, got, 2) + assert.Equal(t, 3, got[0].Version) + assert.Equal(t, 2, got[1].Version) + }) + + t.Run("rejects_limit_zero", func(t *testing.T) { + _, err := fx.service.GetMetadataHistory(ctx, docID, 0, 0) + require.Error(t, err) + }) + + t.Run("rejects_negative_offset", func(t *testing.T) { + _, err := fx.service.GetMetadataHistory(ctx, docID, 1, -1) + require.Error(t, err) + }) + + t.Run("clamps_limit_above_ceiling", func(t *testing.T) { + // 201 is just above the ceiling; result is capped at + // MaxMetadataHistoryLimit rows. We only seeded 5, so we still + // get 5 rows — but the request must not error out. + got, err := fx.service.GetMetadataHistory(ctx, docID, 201, 0) + require.NoError(t, err) + assert.Len(t, got, 5) + }) + + t.Run("not_found_for_missing_document", func(t *testing.T) { + _, err := fx.service.GetMetadataHistory(ctx, uuid.New(), 10, 0) + require.Error(t, err) + assert.True(t, errors.Is(err, customschema.ErrDocumentNotFound)) + }) +} diff --git a/internal/customschema/service_reset.go b/internal/customschema/service_reset.go new file mode 100644 index 00000000..8cc968e7 --- /dev/null +++ b/internal/customschema/service_reset.go @@ -0,0 +1,170 @@ +// Package customschema: Milestone 3 (reset-metadata) service +// implementation. Lives in its own file so Milestone 3 owns a disjoint +// partition from Milestone 2's service_metadata.go (see tracking §2.9). +package customschema + +import ( + "context" + "errors" + "fmt" + "time" + + "queryorchestration/internal/database/repository" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5" +) + +// ResetDocumentMetadata wipes a document's custom metadata history and +// clears its custom_schema_id binding in a single transaction. The +// operation is idempotent: calling it on a document that has no schema +// bound and no metadata rows succeeds with MetadataVersionsDeleted == 0 +// and all PreviousSchema* fields nil. +// +// Transaction sequence (single tx, in exact order): +// +// 1. LockDocumentForReset — FOR UPDATE on the documents row. +// ErrDocumentNotFound when the row is missing. +// 2. HasFieldExtraction — mutual-exclusivity guard. A document carrying +// any legacy field extraction rows is rejected with +// ErrMutualExclusivityViolation before any DELETE runs. +// 3. GetDocumentSchemaBindingForReset — capture PreviousSchema* fields +// for the response. +// 4. CountDocumentCustomMetadataVersions — capture count for the +// response under the same lock so it is authoritative at commit. +// 5. DeleteDocumentCustomMetadataForReset — DELETE every metadata row. +// 6. NullifyDocumentCustomSchemaIdForReset — UPDATE custom_schema_id +// to NULL. Trigger 1 allows this because step 5 already cleared +// metadata and step 2 already verified no legacy extractions. +// 7. Post-commit audit entry (metadata.reset). +// +// The actor argument is the authenticated Cognito subject and is the +// only source of the ResetBy field on the result; the handler never +// reads an actor from the request body. +func (s *Service) ResetDocumentMetadata(ctx context.Context, documentID uuid.UUID, actor string) (*ResetMetadataResult, error) { + if documentID == uuid.Nil { + return nil, fmt.Errorf("customschema: document id is required") + } + + result := &ResetMetadataResult{ + DocumentID: documentID, + ResetBy: actor, + } + + var docClientID string + + txErr := s.cfg.ExecuteDBTransaction(ctx, func(txCtx context.Context, q *repository.Queries) error { + clientID, err := s.resetDocumentMetadataTx(txCtx, q, documentID, result) + if err != nil { + return err + } + docClientID = clientID + return nil + }) + if txErr != nil { + return nil, txErr + } + + // Populate ResetAt at commit time so the response timestamp and the + // audit entry agree. This value is also written into the audit + // record below. + result.ResetAt = time.Now().UTC() + + // Post-commit audit. Resource id is the document id (the thing being + // mutated). Details carry previous_schema_id (nilable) and the + // version count so downstream log aggregation can compute "how much + // history was discarded" without re-reading the tracking plan. + details := map[string]any{ + "metadata_versions_deleted": result.MetadataVersionsDeleted, + } + if result.PreviousSchemaID != nil { + details["previous_schema_id"] = result.PreviousSchemaID.String() + } else { + details["previous_schema_id"] = nil + } + if err := s.audit.Record(ctx, AuditRecord{ + Actor: actor, + Action: AuditActionMetadataReset, + ResourceID: documentID, + ClientID: docClientID, + Timestamp: result.ResetAt, + Details: details, + }); err != nil { + return nil, fmt.Errorf("customschema: record metadata.reset audit: %w", err) + } + return result, nil +} + +// resetDocumentMetadataTx runs the transactional body of +// ResetDocumentMetadata. Pulled out so the top-level method stays under +// 100 lines and the transaction sequence reads top-to-bottom. Mutates +// `result` in place so captured fields survive after the closure +// returns. Returns the document's client id so the caller can populate +// the post-commit audit record. +func (s *Service) resetDocumentMetadataTx( + txCtx context.Context, + q *repository.Queries, + documentID uuid.UUID, + result *ResetMetadataResult, +) (string, error) { + // 1. Parent-row lock. Must be the first DML inside the tx. + lockRow, err := q.LockDocumentForReset(txCtx, documentID) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return "", ErrDocumentNotFound + } + return "", fmt.Errorf("customschema: lock document for reset: %w", err) + } + + docClientID := lockRow.Clientid + + // 2. Mutual-exclusivity guard. A document with any legacy extraction + // rows is not a custom-schema document and must not be reset. + hasLegacy, err := q.HasFieldExtraction(txCtx, documentID) + if err != nil { + return "", fmt.Errorf("customschema: check legacy extractions: %w", err) + } + if hasLegacy { + return "", ErrMutualExclusivityViolation + } + + // 3. Capture previousSchema* for the response. The LEFT JOIN returns + // NULL name/version when no schema is bound — propagate as nil. + binding, err := q.GetDocumentSchemaBindingForReset(txCtx, documentID) + if err != nil { + return "", fmt.Errorf("customschema: read document schema binding: %w", err) + } + if binding.CustomSchemaID != nil { + idCopy := *binding.CustomSchemaID + result.PreviousSchemaID = &idCopy + } + if binding.SchemaName != nil { + nameCopy := *binding.SchemaName + result.PreviousSchemaName = &nameCopy + } + if binding.SchemaVersion != nil { + v := int(*binding.SchemaVersion) + result.PreviousSchemaVersion = &v + } + + // 4. Count metadata rows BEFORE the delete so the caller learns how + // many versions were discarded. + count, err := q.CountDocumentCustomMetadataVersions(txCtx, documentID) + if err != nil { + return "", fmt.Errorf("customschema: count metadata versions: %w", err) + } + result.MetadataVersionsDeleted = int(count) + + // 5. Delete every metadata row for this document. + if err := q.DeleteDocumentCustomMetadataForReset(txCtx, documentID); err != nil { + return "", fmt.Errorf("customschema: delete metadata rows: %w", err) + } + + // 6. Clear the schema binding. Trigger 1 fires on this UPDATE — see + // the function-level comment for why both EXISTS checks inside + // the trigger return false at this point. + if err := q.NullifyDocumentCustomSchemaIdForReset(txCtx, documentID); err != nil { + return "", fmt.Errorf("customschema: nullify custom_schema_id: %w", err) + } + return docClientID, nil +} diff --git a/internal/customschema/service_reset_test.go b/internal/customschema/service_reset_test.go new file mode 100644 index 00000000..c2db9eca --- /dev/null +++ b/internal/customschema/service_reset_test.go @@ -0,0 +1,544 @@ +package customschema_test + +import ( + "context" + "encoding/json" + "errors" + "sync" + "testing" + + "queryorchestration/internal/customschema" + "queryorchestration/internal/database/repository" + + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// Mutable metadata feature, milestone 3 (reset-metadata): integration +// tests for the customschema service's ResetDocumentMetadata method. No +// mocks — the DB is the real Postgres testcontainer produced by +// test.CreateDB and every fixture goes through the real service. + +// seedLegacyExtractionForResetTest inserts one documentFieldExtractions +// row and one documentFieldExtractionVersions row so HasFieldExtraction +// returns true for the supplied document. Used by the mutual-exclusivity +// reset test. Runs as two direct SQL exec statements so the test does not +// need to wire in the full fieldextraction service. +func seedLegacyExtractionForResetTest(t *testing.T, ctx context.Context, fx *testFixture, docID uuid.UUID) { + t.Helper() + row, err := fx.queries.AddFieldExtraction(ctx, &repository.AddFieldExtractionParams{ + Documentid: docID, + Createdby: "reset-test-seed", + }) + require.NoError(t, err) + require.NoError(t, fx.queries.AddFieldExtractionEntry(ctx, &repository.AddFieldExtractionEntryParams{ + Fieldextractionid: row.ID, + Documentid: docID, + Version: 1, + Createdby: "reset-test-seed", + })) +} + +// countMetadataRows is a tiny helper that peeks directly at the table to +// assert reset-metadata actually wiped rows. +func countMetadataRows(t *testing.T, ctx context.Context, fx *testFixture, docID uuid.UUID) int { + t.Helper() + var n int + require.NoError(t, + fx.cfg.GetDBPool().QueryRow(ctx, + `SELECT COUNT(*) FROM document_custom_metadata WHERE document_id = $1`, + docID).Scan(&n)) + return n +} + +// TestResetDocumentMetadata_HappyPath covers the canonical flow: bind a +// schema, write 3 metadata versions, reset, then assert every row is +// gone and the binding is cleared. +func TestResetDocumentMetadata_HappyPath(t *testing.T) { + if testing.Short() { + t.SkipNow() + } + ctx := t.Context() + const clientID = "TEST_SVC_RESET_HAPPY" + fx := newTestService(t, clientID) + + sch := seedActiveSchemaForMetadataTest(t, ctx, fx, clientID, "reset-happy") + docID := seedDocForMetadataTest(t, ctx, fx, clientID, "reset-happy-doc") + _, err := fx.service.AssignSchema(ctx, docID, &sch.ID, testActor) + require.NoError(t, err) + for i := 0; i < 3; i++ { + _, err := fx.service.SetDocumentMetadata(ctx, customschema.SetMetadataInput{ + DocumentID: docID, + Metadata: json.RawMessage(`{"foo":"x"}`), + }, testActor) + require.NoError(t, err, "seed version %d", i+1) + } + require.Equal(t, 3, countMetadataRows(t, ctx, fx, docID)) + + result, err := fx.service.ResetDocumentMetadata(ctx, docID, testActor) + require.NoError(t, err) + require.NotNil(t, result) + assert.Equal(t, docID, result.DocumentID) + assert.Equal(t, 3, result.MetadataVersionsDeleted) + require.NotNil(t, result.PreviousSchemaID) + assert.Equal(t, sch.ID, *result.PreviousSchemaID) + assert.Equal(t, testActor, result.ResetBy) + assert.False(t, result.ResetAt.IsZero()) + + // DB state: no metadata rows, custom_schema_id IS NULL. + assert.Equal(t, 0, countMetadataRows(t, ctx, fx, docID)) + bound, err := fx.queries.GetDocumentCustomSchemaId(ctx, docID) + require.NoError(t, err) + assert.Nil(t, bound, "custom_schema_id must be NULL after reset") +} + +// TestResetDocumentMetadata_PopulatesPreviousFields asserts that the +// previousSchema{Name,Version} fields on the result mirror the bound +// schema row. +func TestResetDocumentMetadata_PopulatesPreviousFields(t *testing.T) { + if testing.Short() { + t.SkipNow() + } + ctx := t.Context() + const clientID = "TEST_SVC_RESET_PREV" + fx := newTestService(t, clientID) + + sch := seedActiveSchemaForMetadataTest(t, ctx, fx, clientID, "reset-prev-fields") + docID := seedDocForMetadataTest(t, ctx, fx, clientID, "reset-prev-fields-doc") + _, err := fx.service.AssignSchema(ctx, docID, &sch.ID, testActor) + require.NoError(t, err) + _, err = fx.service.SetDocumentMetadata(ctx, customschema.SetMetadataInput{ + DocumentID: docID, + Metadata: json.RawMessage(`{"foo":"bar"}`), + }, testActor) + require.NoError(t, err) + + result, err := fx.service.ResetDocumentMetadata(ctx, docID, testActor) + require.NoError(t, err) + require.NotNil(t, result.PreviousSchemaName) + assert.Equal(t, "reset-prev-fields", *result.PreviousSchemaName) + require.NotNil(t, result.PreviousSchemaVersion) + assert.Equal(t, 1, *result.PreviousSchemaVersion) +} + +// TestResetDocumentMetadata_Idempotent proves a reset against a +// never-bound, never-written document succeeds with zero-valued +// previous fields and a zero deletion count. +func TestResetDocumentMetadata_Idempotent(t *testing.T) { + if testing.Short() { + t.SkipNow() + } + ctx := t.Context() + const clientID = "TEST_SVC_RESET_IDEM" + fx := newTestService(t, clientID) + + docID := seedDocForMetadataTest(t, ctx, fx, clientID, "reset-idempotent-doc") + + result, err := fx.service.ResetDocumentMetadata(ctx, docID, testActor) + require.NoError(t, err) + require.NotNil(t, result) + assert.Equal(t, 0, result.MetadataVersionsDeleted) + assert.Nil(t, result.PreviousSchemaID) + assert.Nil(t, result.PreviousSchemaName) + assert.Nil(t, result.PreviousSchemaVersion) +} + +// TestResetDocumentMetadata_LegacyExtractionsRejected proves the +// mutual-exclusivity guard: a document carrying legacy field-extraction +// rows must be rejected with ErrMutualExclusivityViolation and no state +// mutated. +func TestResetDocumentMetadata_LegacyExtractionsRejected(t *testing.T) { + if testing.Short() { + t.SkipNow() + } + ctx := t.Context() + const clientID = "TEST_SVC_RESET_LEGACY" + fx := newTestService(t, clientID) + + docID := seedDocForMetadataTest(t, ctx, fx, clientID, "reset-legacy-doc") + seedLegacyExtractionForResetTest(t, ctx, fx, docID) + + _, err := fx.service.ResetDocumentMetadata(ctx, docID, testActor) + require.Error(t, err) + assert.True(t, errors.Is(err, customschema.ErrMutualExclusivityViolation), + "expected ErrMutualExclusivityViolation, got %v", err) +} + +// TestResetDocumentMetadata_DocumentNotFound proves that a reset against +// a random UUID returns ErrDocumentNotFound — i.e., the FOR UPDATE lock +// detects the missing row. +func TestResetDocumentMetadata_DocumentNotFound(t *testing.T) { + if testing.Short() { + t.SkipNow() + } + ctx := t.Context() + const clientID = "TEST_SVC_RESET_NOTFOUND" + fx := newTestService(t, clientID) + + _, err := fx.service.ResetDocumentMetadata(ctx, uuid.New(), testActor) + require.Error(t, err) + assert.True(t, errors.Is(err, customschema.ErrDocumentNotFound), + "expected ErrDocumentNotFound, got %v", err) +} + +// TestResetDocumentMetadata_Atomicity proves that a DB failure inside +// the reset transaction (injected by deleting the document mid-way +// using a separate connection is impractical under row locks, so we +// instead verify rollback semantics by corrupting the row lock set +// with a trigger-incompatible state: seed metadata, then drop the +// schema binding from under the reset by retiring the schema row. +// +// This sub-case documents the atomicity contract without requiring a +// bespoke failpoint: if any step in the tx returns an error, the +// closure returns and ExecuteDBTransaction rolls back. For a direct +// assertion we simply run a reset against a doc in a state where step +// 6 (the nullify UPDATE) is known to succeed, then assert the +// post-commit state is atomically consistent: either both wipes +// occurred or neither did. The "neither did" branch is exercised by +// TestResetDocumentMetadata_LegacyExtractionsRejected (which returns +// before any DELETE) and TestResetDocumentMetadata_DocumentNotFound +// (which returns before step 2). This test covers the positive side: +// every successful reset leaves zero metadata rows AND a NULL binding, +// never one without the other. +func TestResetDocumentMetadata_Atomicity(t *testing.T) { + if testing.Short() { + t.SkipNow() + } + ctx := t.Context() + const clientID = "TEST_SVC_RESET_ATOMIC" + fx := newTestService(t, clientID) + + sch := seedActiveSchemaForMetadataTest(t, ctx, fx, clientID, "reset-atomic") + docID := seedDocForMetadataTest(t, ctx, fx, clientID, "reset-atomic-doc") + _, err := fx.service.AssignSchema(ctx, docID, &sch.ID, testActor) + require.NoError(t, err) + _, err = fx.service.SetDocumentMetadata(ctx, customschema.SetMetadataInput{ + DocumentID: docID, + Metadata: json.RawMessage(`{"foo":"bar"}`), + }, testActor) + require.NoError(t, err) + + // Atomicity rejection branch: seed a legacy extraction AFTER the + // metadata write so the reset path hits step 2 (HasFieldExtraction) + // and rolls back. Under Trigger 2 the legacy insert on a + // custom-schema-bound document would normally be blocked, so we + // clear the binding first, seed the extraction, then rebind — but + // the binding-set step also fires Trigger 1 because metadata + // exists. The simpler test is to check the rejection branch on a + // fresh document: + docID2 := seedDocForMetadataTest(t, ctx, fx, clientID, "reset-atomic-doc-2") + seedLegacyExtractionForResetTest(t, ctx, fx, docID2) + before := countMetadataRows(t, ctx, fx, docID2) + _, err = fx.service.ResetDocumentMetadata(ctx, docID2, testActor) + require.Error(t, err) + after := countMetadataRows(t, ctx, fx, docID2) + assert.Equal(t, before, after, + "rejected reset must not delete metadata rows (atomicity)") + + // Happy branch atomicity: the successful reset above left BOTH + // invariants true (no metadata AND no binding), never one of the + // two. Verify both in one breath so a future refactor that leaks a + // partial state (e.g., swapping the order of steps 5 and 6 without + // updating the rollback path) shows up as a test failure. + result, err := fx.service.ResetDocumentMetadata(ctx, docID, testActor) + require.NoError(t, err) + require.Equal(t, 1, result.MetadataVersionsDeleted) + assert.Equal(t, 0, countMetadataRows(t, ctx, fx, docID)) + bound, err := fx.queries.GetDocumentCustomSchemaId(ctx, docID) + require.NoError(t, err) + assert.Nil(t, bound) +} + +// TestResetDocumentMetadata_ReassignAfterReset proves the "upgrade to +// new schema version" flow: reset clears state, caller rebinds a new +// schema, writes metadata, then a SECOND rebind attempt fails due to +// Trigger 1 (existing metadata blocks re-binding). +func TestResetDocumentMetadata_ReassignAfterReset(t *testing.T) { + if testing.Short() { + t.SkipNow() + } + ctx := t.Context() + const clientID = "TEST_SVC_RESET_REASSIGN" + fx := newTestService(t, clientID) + + sch1 := seedActiveSchemaForMetadataTest(t, ctx, fx, clientID, "reassign-v1") + docID := seedDocForMetadataTest(t, ctx, fx, clientID, "reassign-doc") + _, err := fx.service.AssignSchema(ctx, docID, &sch1.ID, testActor) + require.NoError(t, err) + _, err = fx.service.SetDocumentMetadata(ctx, customschema.SetMetadataInput{ + DocumentID: docID, + Metadata: json.RawMessage(`{"foo":"v1"}`), + }, testActor) + require.NoError(t, err) + + // Reset wipes the old state. + _, err = fx.service.ResetDocumentMetadata(ctx, docID, testActor) + require.NoError(t, err) + + // Rebind to a brand new schema and write fresh metadata. + sch2 := seedActiveSchemaForMetadataTest(t, ctx, fx, clientID, "reassign-v2") + _, err = fx.service.AssignSchema(ctx, docID, &sch2.ID, testActor) + require.NoError(t, err) + _, err = fx.service.SetDocumentMetadata(ctx, customschema.SetMetadataInput{ + DocumentID: docID, + Metadata: json.RawMessage(`{"foo":"v2"}`), + }, testActor) + require.NoError(t, err) + + // Second rebind must now fail: metadata exists, Trigger 1 / the + // application-layer guard in AssignSchema rejects the change. + sch3 := seedActiveSchemaForMetadataTest(t, ctx, fx, clientID, "reassign-v3") + _, err = fx.service.AssignSchema(ctx, docID, &sch3.ID, testActor) + require.Error(t, err) + assert.True(t, errors.Is(err, customschema.ErrDocumentHasCustomMetadata), + "expected ErrDocumentHasCustomMetadata blocking second rebind, got %v", err) +} + +// TestReset_NoSchemaIdColumnInMetadataTable is a structural v4 invariant +// test: the document_custom_metadata table must NOT have a schema_id +// column. v3 carried one; v4 removed it. This test runs straight +// information_schema so any regression that re-introduces the column +// fails here before any reset-path test even runs. +func TestReset_NoSchemaIdColumnInMetadataTable(t *testing.T) { + if testing.Short() { + t.SkipNow() + } + ctx := t.Context() + fx := newTestService(t) + + rows, err := fx.cfg.GetDBPool().Query(ctx, + `SELECT column_name FROM information_schema.columns WHERE table_name = 'document_custom_metadata'`) + require.NoError(t, err) + defer rows.Close() + + columns := make([]string, 0, 16) + for rows.Next() { + var name string + require.NoError(t, rows.Scan(&name)) + columns = append(columns, name) + } + require.NoError(t, rows.Err()) + require.NotEmpty(t, columns, "document_custom_metadata must have at least one column") + for _, name := range columns { + assert.NotEqual(t, "schema_id", name, + "v4 forbids schema_id column on document_custom_metadata; found: %v", columns) + } +} + +// TestResetDocumentMetadata_AuditEntry proves a successful reset emits +// exactly one audit record with action=metadata.reset and the document +// id as the resource id. +func TestResetDocumentMetadata_AuditEntry(t *testing.T) { + if testing.Short() { + t.SkipNow() + } + ctx := t.Context() + const clientID = "TEST_SVC_RESET_AUDIT" + fx := newTestService(t, clientID) + + sch := seedActiveSchemaForMetadataTest(t, ctx, fx, clientID, "reset-audit") + docID := seedDocForMetadataTest(t, ctx, fx, clientID, "reset-audit-doc") + _, err := fx.service.AssignSchema(ctx, docID, &sch.ID, testActor) + require.NoError(t, err) + _, err = fx.service.SetDocumentMetadata(ctx, customschema.SetMetadataInput{ + DocumentID: docID, + Metadata: json.RawMessage(`{"foo":"bar"}`), + }, testActor) + require.NoError(t, err) + + before := len(fx.sink.all()) + _, err = fx.service.ResetDocumentMetadata(ctx, docID, testActor) + require.NoError(t, err) + + records := fx.sink.all() + require.Equal(t, before+1, len(records), + "reset must emit exactly one audit entry") + last := records[len(records)-1] + assert.Equal(t, customschema.AuditActionMetadataReset, last.Action) + assert.Equal(t, testActor, last.Actor) + assert.Equal(t, docID, last.ResourceID) + assert.Equal(t, clientID, last.ClientID, "audit record must carry the document's client id") + require.NotNil(t, last.Details) + assert.Contains(t, last.Details, "previous_schema_id") + assert.Contains(t, last.Details, "metadata_versions_deleted") +} + +// TestResetDocumentMetadata_Concurrency_TwoResets fires two parallel +// resets at the same document. Both must succeed (reset is idempotent +// and serializes on the parent-row lock) and the final state has zero +// metadata rows. +func TestResetDocumentMetadata_Concurrency_TwoResets(t *testing.T) { + if testing.Short() { + t.SkipNow() + } + ctx := t.Context() + const clientID = "TEST_SVC_RESET_CONC_RR" + fx := newTestService(t, clientID) + + sch := seedActiveSchemaForMetadataTest(t, ctx, fx, clientID, "reset-conc-rr") + docID := seedDocForMetadataTest(t, ctx, fx, clientID, "reset-conc-rr-doc") + _, err := fx.service.AssignSchema(ctx, docID, &sch.ID, testActor) + require.NoError(t, err) + for i := 0; i < 3; i++ { + _, err := fx.service.SetDocumentMetadata(ctx, customschema.SetMetadataInput{ + DocumentID: docID, + Metadata: json.RawMessage(`{"foo":"x"}`), + }, testActor) + require.NoError(t, err) + } + + var wg sync.WaitGroup + errs := make([]error, 2) + for i := 0; i < 2; i++ { + wg.Add(1) + go func(idx int) { + defer wg.Done() + _, errs[idx] = fx.service.ResetDocumentMetadata(ctx, docID, testActor) + }(i) + } + wg.Wait() + + for i, e := range errs { + assert.NoError(t, e, "goroutine %d must succeed", i) + } + assert.Equal(t, 0, countMetadataRows(t, ctx, fx, docID)) + bound, err := fx.queries.GetDocumentCustomSchemaId(ctx, docID) + require.NoError(t, err) + assert.Nil(t, bound) +} + +// TestResetDocumentMetadata_Concurrency_ResetVsAssignSchema fires an +// AssignSchema and a Reset against the same document in parallel. The +// two must serialize on the parent-row lock; the final state is +// consistent: either the reassign won (schema bound, zero metadata) or +// the reset won (schema cleared, zero metadata). +func TestResetDocumentMetadata_Concurrency_ResetVsAssignSchema(t *testing.T) { + if testing.Short() { + t.SkipNow() + } + ctx := t.Context() + const clientID = "TEST_SVC_RESET_CONC_RA" + fx := newTestService(t, clientID) + + sch1 := seedActiveSchemaForMetadataTest(t, ctx, fx, clientID, "reset-conc-ra-1") + sch2 := seedActiveSchemaForMetadataTest(t, ctx, fx, clientID, "reset-conc-ra-2") + docID := seedDocForMetadataTest(t, ctx, fx, clientID, "reset-conc-ra-doc") + _, err := fx.service.AssignSchema(ctx, docID, &sch1.ID, testActor) + require.NoError(t, err) + _, err = fx.service.SetDocumentMetadata(ctx, customschema.SetMetadataInput{ + DocumentID: docID, + Metadata: json.RawMessage(`{"foo":"bar"}`), + }, testActor) + require.NoError(t, err) + + var wg sync.WaitGroup + var assignErr, resetErr error + wg.Add(2) + go func() { + defer wg.Done() + _, assignErr = fx.service.AssignSchema(ctx, docID, &sch2.ID, testActor) + }() + go func() { + defer wg.Done() + _, resetErr = fx.service.ResetDocumentMetadata(ctx, docID, testActor) + }() + wg.Wait() + + // Final state must be internally consistent. Two acceptable + // outcomes: + // + // A. Reset acquired the lock first: metadata rows wiped, + // binding cleared. Assign then runs on an unbound doc with + // zero metadata — must succeed and bind sch2. + // B. Assign acquired the lock first: it hits + // ErrDocumentHasCustomMetadata because metadata exists, so + // assignErr is non-nil. Reset then runs and wipes the + // metadata + clears the (still sch1) binding. + // + // In either case the final state must have zero metadata rows. + // The binding is either nil (case B) or sch2 (case A). + assert.Equal(t, 0, countMetadataRows(t, ctx, fx, docID)) + + bound, err := fx.queries.GetDocumentCustomSchemaId(ctx, docID) + require.NoError(t, err) + + if assignErr == nil { + // Case A: assign won. Binding must be sch2. + require.NoError(t, resetErr, "reset must succeed in case A") + require.NotNil(t, bound, "binding must be set in case A") + assert.Equal(t, sch2.ID, *bound) + } else { + // Case B: assign failed because metadata still existed when + // it ran. + assert.True(t, errors.Is(assignErr, customschema.ErrDocumentHasCustomMetadata), + "assign must fail with ErrDocumentHasCustomMetadata in case B, got %v", assignErr) + require.NoError(t, resetErr, "reset must succeed in case B") + assert.Nil(t, bound, "binding must be cleared in case B") + } +} + +// TestResetDocumentMetadata_Concurrency_WriteThenReset: SetDocumentMetadata +// commits a row first, then reset fires. Reset must delete the row and +// report at least one deleted version. +func TestResetDocumentMetadata_Concurrency_WriteThenReset(t *testing.T) { + if testing.Short() { + t.SkipNow() + } + ctx := t.Context() + const clientID = "TEST_SVC_RESET_CONC_WR" + fx := newTestService(t, clientID) + + sch := seedActiveSchemaForMetadataTest(t, ctx, fx, clientID, "reset-conc-wr") + docID := seedDocForMetadataTest(t, ctx, fx, clientID, "reset-conc-wr-doc") + _, err := fx.service.AssignSchema(ctx, docID, &sch.ID, testActor) + require.NoError(t, err) + + // Write commits first. + _, err = fx.service.SetDocumentMetadata(ctx, customschema.SetMetadataInput{ + DocumentID: docID, + Metadata: json.RawMessage(`{"foo":"first"}`), + }, testActor) + require.NoError(t, err) + + // Now reset fires. + result, err := fx.service.ResetDocumentMetadata(ctx, docID, testActor) + require.NoError(t, err) + assert.GreaterOrEqual(t, result.MetadataVersionsDeleted, 1) + assert.Equal(t, 0, countMetadataRows(t, ctx, fx, docID)) +} + +// TestResetDocumentMetadata_Concurrency_ResetThenWrite: reset clears the +// binding, then SetDocumentMetadata races in. The writer must observe +// custom_schema_id = NULL and fail with ErrDocumentNotBoundToSchema. +func TestResetDocumentMetadata_Concurrency_ResetThenWrite(t *testing.T) { + if testing.Short() { + t.SkipNow() + } + ctx := t.Context() + const clientID = "TEST_SVC_RESET_CONC_RW" + fx := newTestService(t, clientID) + + sch := seedActiveSchemaForMetadataTest(t, ctx, fx, clientID, "reset-conc-rw") + docID := seedDocForMetadataTest(t, ctx, fx, clientID, "reset-conc-rw-doc") + _, err := fx.service.AssignSchema(ctx, docID, &sch.ID, testActor) + require.NoError(t, err) + _, err = fx.service.SetDocumentMetadata(ctx, customschema.SetMetadataInput{ + DocumentID: docID, + Metadata: json.RawMessage(`{"foo":"one"}`), + }, testActor) + require.NoError(t, err) + + // Reset commits first. + _, err = fx.service.ResetDocumentMetadata(ctx, docID, testActor) + require.NoError(t, err) + + // Writer now runs — must observe NULL binding and fail. + _, err = fx.service.SetDocumentMetadata(ctx, customschema.SetMetadataInput{ + DocumentID: docID, + Metadata: json.RawMessage(`{"foo":"post-reset"}`), + }, testActor) + require.Error(t, err) + assert.True(t, errors.Is(err, customschema.ErrDocumentNotBoundToSchema), + "expected ErrDocumentNotBoundToSchema after reset, got %v", err) +} diff --git a/internal/customschema/service_test.go b/internal/customschema/service_test.go new file mode 100644 index 00000000..a2060ee7 --- /dev/null +++ b/internal/customschema/service_test.go @@ -0,0 +1,925 @@ +package customschema_test + +import ( + "context" + "encoding/json" + "errors" + "strings" + "sync" + "testing" + + "queryorchestration/internal/customschema" + "queryorchestration/internal/database/repository" + "queryorchestration/internal/serviceconfig" + "queryorchestration/internal/test" + + "github.com/google/uuid" + "github.com/stretchr/testify/require" +) + +// Mutable metadata feature, milestone 1.6 (Part A): integration tests for +// customschema.Service CRUD methods. Red phase — these tests are written +// before the method bodies exist. Every test here is expected to fail on +// the "not implemented" sentinel in service.go until the golang engineer +// fills in the bodies in the next dispatch. +// +// No mocks. The capturingAuditSink below is a real implementation of +// customschema.AuditSink that buffers records in memory. The DB is the +// real Postgres testcontainer that test.CreateDB stands up. + +// trivialValidSchemaJSON is the minimal schema_def that passes every +// rule in customschema.SchemaValidator: type=object, additionalProperties +// explicitly present, well under the 64KB cap. +const trivialValidSchemaJSON = `{"type":"object","additionalProperties":false}` + +// capturingAuditSink buffers every AuditRecord in memory so tests can +// assert on what the service emitted. It is a real AuditSink, not a mock. +type capturingAuditSink struct { + mu sync.Mutex + records []customschema.AuditRecord +} + +// Record appends the record under the mutex. Never returns an error so +// the service's happy path is never disturbed by the sink. +func (c *capturingAuditSink) Record(ctx context.Context, rec customschema.AuditRecord) error { + c.mu.Lock() + defer c.mu.Unlock() + c.records = append(c.records, rec) + return nil +} + +// all returns a defensive copy of the captured records so callers can +// iterate without holding the sink's mutex. +func (c *capturingAuditSink) all() []customschema.AuditRecord { + c.mu.Lock() + defer c.mu.Unlock() + out := make([]customschema.AuditRecord, len(c.records)) + copy(out, c.records) + return out +} + +// testFixture bundles everything a subtest needs from newTestService so +// tests can inspect DB rows directly and call the service simultaneously. +type testFixture struct { + service *customschema.Service + sink *capturingAuditSink + cfg *serviceconfig.BaseConfig + queries *repository.Queries +} + +// testActor is the canonical actor string every test passes to write +// methods. Writing to AuditRecord.Actor must use THIS value (not anything +// read off the input struct) because the v4 rule is "actor is a separate +// argument, never a body field". +const testActor = "test-actor-00000000-0000-4000-8000-000000000001" + +// newTestService boots a fresh DB via test.CreateDB, wipes leftover rows +// for the test's clients, re-creates the clients, and returns a ready +// Service wired to a capturing sink. Tests pass the set of client IDs +// they need so unrelated runs don't clobber each other's fixtures. +func newTestService(t *testing.T, clientIDs ...string) *testFixture { + t.Helper() + cfg := &serviceconfig.BaseConfig{} + test.CreateDB(t, cfg) + pool := cfg.GetDBPool() + require.NotNil(t, pool) + queries := cfg.GetDBQueries() + require.NotNil(t, queries) + + ctx := t.Context() + for _, clientID := range clientIDs { + // Same idempotency dance as resetClient() in the repository tests: + // documents first (no cascade from clients), then clients (cascade + // wipes client_metadata_schemas via the FK from migration 127). + _, err := pool.Exec(ctx, `DELETE FROM documents WHERE clientId = $1`, clientID) + require.NoError(t, err, "pre-test DELETE documents for clientID=%q must succeed", clientID) + _, err = pool.Exec(ctx, `DELETE FROM clients WHERE clientId = $1`, clientID) + require.NoError(t, err, "pre-test DELETE clients for clientID=%q must succeed", clientID) + + err = queries.CreateClient(ctx, &repository.CreateClientParams{ + Name: strings.ToLower(clientID), + Clientid: clientID, + }) + require.NoError(t, err, "CreateClient for clientID=%q must succeed", clientID) + } + + sink := &capturingAuditSink{} + svc := customschema.New(cfg, &customschema.SchemaValidator{}, sink) + return &testFixture{ + service: svc, + sink: sink, + cfg: cfg, + queries: queries, + } +} + +// TestService_CreateSchema covers the happy path and every documented +// rejection rule for CreateSchema (tracking lines 126–130). +func TestService_CreateSchema(t *testing.T) { + if testing.Short() { + t.SkipNow() + } + ctx := t.Context() + const clientID = "TEST_SVC_CREATE_A" + fx := newTestService(t, clientID) + + t.Run("happy_path_valid_schema", func(t *testing.T) { + input := customschema.CreateSchemaInput{ + ClientID: clientID, + Name: "happy_path", + Description: "unit test schema", + SchemaDef: json.RawMessage(trivialValidSchemaJSON), + } + got, err := fx.service.CreateSchema(ctx, input, testActor) + require.NoError(t, err) + require.NotNil(t, got) + require.NotEqual(t, uuid.Nil, got.ID, "ID must be assigned") + require.Equal(t, clientID, got.ClientID) + require.Equal(t, "happy_path", got.Name) + require.Equal(t, 1, got.Version) + require.Equal(t, "active", got.Status) + require.False(t, got.CreatedAt.IsZero(), "CreatedAt must be populated") + require.Equal(t, testActor, got.CreatedBy, + "CreatedBy must be the actor arg, never a body field") + + // DB round-trip: the row must exist under this id. + row, err := fx.queries.GetClientMetadataSchema(ctx, got.ID) + require.NoError(t, err) + require.Equal(t, clientID, row.ClientID) + require.Equal(t, "happy_path", row.Name) + }) + + t.Run("writes_schema_create_audit_entry", func(t *testing.T) { + // Seed a second schema (distinct name so the happy-path row above + // does not collide with this one) and verify exactly one audit + // entry was emitted for this subtest's write. + before := len(fx.sink.all()) + input := customschema.CreateSchemaInput{ + ClientID: clientID, + Name: "audit_entry_probe", + Description: "", + SchemaDef: json.RawMessage(trivialValidSchemaJSON), + } + got, err := fx.service.CreateSchema(ctx, input, testActor) + require.NoError(t, err) + require.NotNil(t, got) + + records := fx.sink.all() + require.Equal(t, before+1, len(records), + "exactly one audit entry must be emitted by CreateSchema") + last := records[len(records)-1] + require.Equal(t, customschema.AuditActionSchemaCreate, last.Action) + require.Equal(t, testActor, last.Actor) + require.Equal(t, got.ID, last.ResourceID) + // AuditRecord.ClientID is uuid.UUID, but client_metadata_schemas.client_id + // is a varchar(255) Cognito-style identifier. This is a type mismatch + // the team lead needs to resolve. For the red-phase test we simply + // assert the schema's string ClientID is threaded into Details under + // a well-known key so the engineer has freedom to pick either + // Details.client_id_legacy or uuid.Nil in the ClientID column. + if last.Details != nil { + // If the engineer chose the Details route, assert the string + // shows up there. If they chose uuid.Nil in ClientID, Details + // may be nil — that's still acceptable for this red phase and + // the assertion simply short-circuits. + if v, ok := last.Details["client_id"]; ok { + require.Equal(t, clientID, v, + "Details.client_id must mirror the legacy varchar client id") + } + } + }) + + t.Run("rejects_invalid_json_schema", func(t *testing.T) { + before := len(fx.sink.all()) + input := customschema.CreateSchemaInput{ + ClientID: clientID, + Name: "invalid_json", + SchemaDef: json.RawMessage("{this is not json"), + } + _, err := fx.service.CreateSchema(ctx, input, testActor) + require.Error(t, err, "malformed JSON must be rejected") + + // No DB row written for this name. + _, getErr := fx.queries.GetLatestSchemaByName(ctx, &repository.GetLatestSchemaByNameParams{ + ClientID: clientID, + Name: "invalid_json", + }) + require.Error(t, getErr, "no row must exist for the rejected name") + + // No audit entry emitted. + require.Equal(t, before, len(fx.sink.all()), + "no audit entry must be emitted on validation failure") + }) + + t.Run("rejects_oversize_schema_def", func(t *testing.T) { + // Build a JSON document whose length is strictly greater than + // MaxSchemaDefinitionBytes. The validator rejects on strict >. + var b strings.Builder + b.WriteString(`{"type":"object","additionalProperties":false,"description":"`) + padding := customschema.MaxSchemaDefinitionBytes + 1 - b.Len() - 2 + for i := 0; i < padding; i++ { + b.WriteByte('x') + } + b.WriteString(`"}`) + oversize := b.String() + require.Greater(t, len(oversize), customschema.MaxSchemaDefinitionBytes, + "test fixture must actually exceed the schema-definition ceiling") + + before := len(fx.sink.all()) + input := customschema.CreateSchemaInput{ + ClientID: clientID, + Name: "oversize_probe", + SchemaDef: json.RawMessage(oversize), + } + _, err := fx.service.CreateSchema(ctx, input, testActor) + require.Error(t, err, "oversize schema_def must be rejected") + + _, getErr := fx.queries.GetLatestSchemaByName(ctx, &repository.GetLatestSchemaByNameParams{ + ClientID: clientID, + Name: "oversize_probe", + }) + require.Error(t, getErr, "no row must exist for the rejected oversize schema") + + require.Equal(t, before, len(fx.sink.all()), + "no audit entry must be emitted on oversize rejection") + }) + + t.Run("rejects_duplicate_name_at_version_1", func(t *testing.T) { + const schemaName = "duplicate_name_probe" + input := customschema.CreateSchemaInput{ + ClientID: clientID, + Name: schemaName, + SchemaDef: json.RawMessage(trivialValidSchemaJSON), + } + _, err := fx.service.CreateSchema(ctx, input, testActor) + require.NoError(t, err, "first insert must succeed") + + before := len(fx.sink.all()) + _, err = fx.service.CreateSchema(ctx, input, testActor) + require.Error(t, err, + "second insert with same (client, name) must fail at the service layer") + + // Count rows for this (client, name). Should still be exactly one. + var count int + err = fx.cfg.GetDBPool().QueryRow(ctx, + `SELECT COUNT(*) FROM client_metadata_schemas WHERE client_id = $1 AND name = $2`, + clientID, schemaName, + ).Scan(&count) + require.NoError(t, err) + require.Equal(t, 1, count, + "duplicate rejection must not write a second row") + + // No second audit entry (the first audit entry from the happy + // call in this subtest is still there; the rejected call must + // have emitted nothing). + require.Equal(t, before, len(fx.sink.all()), + "rejected duplicate must emit no audit entry") + }) +} + +// TestService_GetSchema covers the GetSchema happy path and the 404 +// sentinel (tracking lines 139–140). +func TestService_GetSchema(t *testing.T) { + if testing.Short() { + t.SkipNow() + } + ctx := t.Context() + const clientID = "TEST_SVC_GET_A" + fx := newTestService(t, clientID) + + t.Run("happy_path", func(t *testing.T) { + // Seed directly via the repository (no need to exercise CreateSchema here). + desc := "get-happy" + row, err := fx.queries.CreateClientMetadataSchema(ctx, &repository.CreateClientMetadataSchemaParams{ + ClientID: clientID, + Name: "get_happy", + Description: &desc, + SchemaDef: []byte(trivialValidSchemaJSON), + Version: 1, + Status: repository.SchemaStatusTypeActive, + CreatedBy: "seed-user", + }) + require.NoError(t, err) + require.NotNil(t, row) + + got, err := fx.service.GetSchema(ctx, row.ID) + require.NoError(t, err) + require.Equal(t, row.ID, got.Schema.ID) + require.Equal(t, clientID, got.Schema.ClientID) + require.Equal(t, "get_happy", got.Schema.Name) + require.Equal(t, 1, got.Schema.Version) + require.Equal(t, "active", got.Schema.Status) + require.Equal(t, "seed-user", got.Schema.CreatedBy) + // Freshly created schema has no bound documents. + require.Equal(t, int64(0), got.DocumentCount) + require.True(t, got.CanDelete) + }) + + t.Run("returns_not_found_sentinel", func(t *testing.T) { + missing := uuid.New() + _, err := fx.service.GetSchema(ctx, missing) + require.Error(t, err) + require.True(t, errors.Is(err, customschema.ErrSchemaNotFound), + "GetSchema must wrap ErrSchemaNotFound on missing row, got: %v", err) + }) +} + +// TestService_ListSchemas covers the filter matrix plus DocumentCount / +// CanDelete decoration (tracking lines 141–144). +func TestService_ListSchemas(t *testing.T) { + if testing.Short() { + t.SkipNow() + } + ctx := t.Context() + const clientID = "TEST_SVC_LIST_A" + fx := newTestService(t, clientID) + + // Shared fixture seeded once per top-level test run. Subtests that + // mutate DocumentCount use their own distinct schema name. + seedDirect := func(name string, version int32, status repository.SchemaStatusType) *repository.ClientMetadataSchema { + desc := "" + row, err := fx.queries.CreateClientMetadataSchema(ctx, &repository.CreateClientMetadataSchemaParams{ + ClientID: clientID, + Name: name, + Description: &desc, + SchemaDef: []byte(trivialValidSchemaJSON), + Version: version, + Status: status, + CreatedBy: "seed-user", + }) + require.NoError(t, err) + return row + } + seedDirect("alpha", 1, repository.SchemaStatusTypeActive) + alphaV2 := seedDirect("alpha", 2, repository.SchemaStatusTypeActive) + betaV1 := seedDirect("beta", 1, repository.SchemaStatusTypeActive) + seedDirect("gamma", 1, repository.SchemaStatusTypeSuperseded) + + t.Run("default_filters_returns_active_latest", func(t *testing.T) { + got, err := fx.service.ListSchemas(ctx, clientID, customschema.ListFilters{ + Limit: 100, + Offset: 0, + }) + require.NoError(t, err) + + // Build a (name, version) set for assertion. + type tup struct { + name string + version int + } + seen := map[tup]customschema.SchemaSummary{} + for _, s := range got { + seen[tup{s.Schema.Name, s.Schema.Version}] = s + } + require.Contains(t, seen, tup{"alpha", 2}, + "default filters must include alpha v2") + require.Contains(t, seen, tup{"beta", 1}, + "default filters must include beta v1") + require.NotContains(t, seen, tup{"alpha", 1}, + "default filters must collapse to MAX(version)") + require.NotContains(t, seen, tup{"gamma", 1}, + "default filters must drop superseded rows") + + // Each summary from the fixture is unbound, so canDelete is true. + require.Equal(t, int64(0), seen[tup{"alpha", 2}].DocumentCount) + require.True(t, seen[tup{"alpha", 2}].CanDelete) + require.Equal(t, int64(0), seen[tup{"beta", 1}].DocumentCount) + require.True(t, seen[tup{"beta", 1}].CanDelete) + _ = alphaV2 + _ = betaV1 + }) + + t.Run("status_any_returns_all", func(t *testing.T) { + got, err := fx.service.ListSchemas(ctx, clientID, customschema.ListFilters{ + StatusAny: true, + IncludeAllVersions: true, + Limit: 100, + }) + require.NoError(t, err) + foundGamma := false + for _, s := range got { + if s.Schema.Name == "gamma" && s.Schema.Version == 1 { + foundGamma = true + require.Equal(t, "superseded", s.Schema.Status) + } + } + require.True(t, foundGamma, + "StatusAny+IncludeAllVersions must surface the superseded row") + }) + + t.Run("include_all_versions_returns_every_version", func(t *testing.T) { + got, err := fx.service.ListSchemas(ctx, clientID, customschema.ListFilters{ + IncludeAllVersions: true, + Limit: 100, + }) + require.NoError(t, err) + versions := map[int]bool{} + for _, s := range got { + if s.Schema.Name == "alpha" { + versions[s.Schema.Version] = true + } + } + require.True(t, versions[1], "alpha v1 must be returned when IncludeAllVersions=true") + require.True(t, versions[2], "alpha v2 must be returned when IncludeAllVersions=true") + }) + + t.Run("document_count_drives_can_delete", func(t *testing.T) { + // Dedicated schema + two bound documents so the fixture above is + // not disturbed. Use the SAME client so the FK is satisfied. + bound := seedDirect("bound_schema", 1, repository.SchemaStatusTypeActive) + pool := fx.cfg.GetDBPool() + doc1, err := fx.queries.CreateDocument(ctx, &repository.CreateDocumentParams{ + Clientid: clientID, + Hash: "svc_list_bound_doc_1", + }) + require.NoError(t, err) + _, err = pool.Exec(ctx, + `UPDATE documents SET custom_schema_id = $1 WHERE id = $2`, + bound.ID, doc1) + require.NoError(t, err) + doc2, err := fx.queries.CreateDocument(ctx, &repository.CreateDocumentParams{ + Clientid: clientID, + Hash: "svc_list_bound_doc_2", + }) + require.NoError(t, err) + _, err = pool.Exec(ctx, + `UPDATE documents SET custom_schema_id = $1 WHERE id = $2`, + bound.ID, doc2) + require.NoError(t, err) + + got, err := fx.service.ListSchemas(ctx, clientID, customschema.ListFilters{ + Name: "bound_schema", + Limit: 100, + Offset: 0, + }) + require.NoError(t, err) + require.Len(t, got, 1, "name filter must narrow to exactly one row") + require.Equal(t, int64(2), got[0].DocumentCount) + require.False(t, got[0].CanDelete, "CanDelete must be false when documents are bound") + }) + + t.Run("name_filter_narrows_results", func(t *testing.T) { + // Name filter is EXACT match (the underlying query uses `name = $`, + // not ILIKE). Asserted by the repository tests too; re-asserted here + // at the service layer so handlers know they cannot pass a prefix. + got, err := fx.service.ListSchemas(ctx, clientID, customschema.ListFilters{ + Name: "alpha", + Limit: 100, + }) + require.NoError(t, err) + for _, s := range got { + require.Equal(t, "alpha", s.Schema.Name, + "name filter must return only rows whose name matches exactly") + } + }) +} + +// TestService_DeleteSchema covers the retire-on-delete happy path, the +// audit entry, the in-use rejection, and the not-found sentinel +// (tracking lines 145–147). +func TestService_DeleteSchema(t *testing.T) { + if testing.Short() { + t.SkipNow() + } + ctx := t.Context() + const clientID = "TEST_SVC_DELETE_A" + fx := newTestService(t, clientID) + + seed := func(name string) *repository.ClientMetadataSchema { + desc := "" + row, err := fx.queries.CreateClientMetadataSchema(ctx, &repository.CreateClientMetadataSchemaParams{ + ClientID: clientID, + Name: name, + Description: &desc, + SchemaDef: []byte(trivialValidSchemaJSON), + Version: 1, + Status: repository.SchemaStatusTypeActive, + CreatedBy: "seed-user", + }) + require.NoError(t, err) + return row + } + + t.Run("happy_path_retires", func(t *testing.T) { + schema := seed("delete_happy") + err := fx.service.DeleteSchema(ctx, schema.ID, testActor) + require.NoError(t, err) + + // Row must still exist with status=retired. + reloaded, err := fx.queries.GetClientMetadataSchema(ctx, schema.ID) + require.NoError(t, err) + require.Equal(t, repository.SchemaStatusTypeRetired, reloaded.Status, + "DeleteSchema must retire the row, not physically delete it") + }) + + t.Run("writes_schema_delete_audit_entry", func(t *testing.T) { + schema := seed("delete_audit") + before := len(fx.sink.all()) + err := fx.service.DeleteSchema(ctx, schema.ID, testActor) + require.NoError(t, err) + + records := fx.sink.all() + require.Equal(t, before+1, len(records), + "exactly one audit entry must be emitted by DeleteSchema") + last := records[len(records)-1] + require.Equal(t, customschema.AuditActionSchemaDelete, last.Action) + require.Equal(t, testActor, last.Actor) + require.Equal(t, schema.ID, last.ResourceID) + }) + + t.Run("rejects_when_document_bound_returns_conflict", func(t *testing.T) { + schema := seed("delete_in_use") + doc, err := fx.queries.CreateDocument(ctx, &repository.CreateDocumentParams{ + Clientid: clientID, + Hash: "svc_delete_bound_doc", + }) + require.NoError(t, err) + _, err = fx.cfg.GetDBPool().Exec(ctx, + `UPDATE documents SET custom_schema_id = $1 WHERE id = $2`, + schema.ID, doc) + require.NoError(t, err) + + before := len(fx.sink.all()) + err = fx.service.DeleteSchema(ctx, schema.ID, testActor) + require.Error(t, err) + require.True(t, errors.Is(err, customschema.ErrSchemaInUse), + "DeleteSchema must wrap ErrSchemaInUse when a document is bound, got: %v", err) + + // Status must still be active — no state change on rejection. + reloaded, err := fx.queries.GetClientMetadataSchema(ctx, schema.ID) + require.NoError(t, err) + require.Equal(t, repository.SchemaStatusTypeActive, reloaded.Status, + "rejected delete must not mutate status") + + require.Equal(t, before, len(fx.sink.all()), + "rejected delete must emit no audit entry") + }) + + t.Run("returns_not_found_on_missing_id", func(t *testing.T) { + before := len(fx.sink.all()) + missing := uuid.New() + err := fx.service.DeleteSchema(ctx, missing, testActor) + require.Error(t, err) + require.True(t, errors.Is(err, customschema.ErrSchemaNotFound), + "DeleteSchema must wrap ErrSchemaNotFound on missing id, got: %v", err) + require.Equal(t, before, len(fx.sink.all()), + "missing-id delete must emit no audit entry") + }) +} + +// TestService_CreateSchemaVersion is Part B of mutable-metadata milestone +// 1.6: integration tests for CreateSchemaVersion. Red phase — the stub in +// service.go returns errNotImplemented so every subtest below fails until +// the golang engineer fills in the body in the next dispatch. +// +// Tracking §1.6 lines 131-138 enumerate the rules this test codifies: +// +// 131 happy path produces v2 +// 132 concurrency produces distinct versions +// 133 rejects when parent does not exist +// 134 non-active parent rejection (caller passed v1 while v2 is active) +// 135 retired parent rejection +// 136 active-parent invariant after every write +// 137 rejects invalid schema definition +// 138 writes schema.update audit entry +// +// Sentinel mapping decision (made by the test engineer for this dispatch, +// the golang engineer must match): +// +// - ErrSchemaParentNotActive -> parent row's status is not 'active' and +// not 'retired' (typical: 'superseded'). +// - ErrSchemaParentRetired -> parent row's status is 'retired'. More +// specific than ErrSchemaParentNotActive so the handler layer can +// surface a more actionable reason. +func TestService_CreateSchemaVersion(t *testing.T) { + if testing.Short() { + t.SkipNow() + } + ctx := t.Context() + const clientID = "TEST_SVC_V" + fx := newTestService(t, clientID) + + // seedV1 creates a fresh (clientID, name) lineage at version 1 via the + // real CreateSchema code path so the test exercises the same insert + // route the handler layer will take. Returns the new schema's id. + seedV1 := func(t *testing.T, name string) *customschema.Schema { + t.Helper() + // Idempotency: wipe any leftover lineage rows under this name. + _, err := fx.cfg.GetDBPool().Exec(ctx, + `DELETE FROM client_metadata_schemas WHERE client_id = $1 AND name = $2`, + clientID, name) + require.NoError(t, err) + got, err := fx.service.CreateSchema(ctx, customschema.CreateSchemaInput{ + ClientID: clientID, + Name: name, + Description: "seed v1", + SchemaDef: json.RawMessage(trivialValidSchemaJSON), + }, testActor) + require.NoError(t, err) + require.NotNil(t, got) + require.Equal(t, 1, got.Version) + require.Equal(t, "active", got.Status) + return got + } + + // countRows returns the number of rows in client_metadata_schemas for + // a (client, name) pair. Used by the "no row inserted" assertions. + countRows := func(t *testing.T, name string) int { + t.Helper() + var n int + err := fx.cfg.GetDBPool().QueryRow(ctx, + `SELECT COUNT(*) FROM client_metadata_schemas WHERE client_id = $1 AND name = $2`, + clientID, name).Scan(&n) + require.NoError(t, err) + return n + } + + // countActiveRows returns the number of 'active' rows for a (client, + // name) pair. Used by the active-parent invariant assertions. + countActiveRows := func(t *testing.T, name string) int { + t.Helper() + var n int + err := fx.cfg.GetDBPool().QueryRow(ctx, + `SELECT COUNT(*) FROM client_metadata_schemas WHERE client_id = $1 AND name = $2 AND status = 'active'`, + clientID, name).Scan(&n) + require.NoError(t, err) + return n + } + + freshInput := func(desc string) customschema.CreateSchemaVersionInput { + return customschema.CreateSchemaVersionInput{ + Description: desc, + SchemaDef: json.RawMessage(trivialValidSchemaJSON), + } + } + + t.Run("happy_path_produces_v2", func(t *testing.T) { + v1 := seedV1(t, "invoice") + + got, err := fx.service.CreateSchemaVersion(ctx, v1.ID, freshInput("v2"), testActor) + require.NoError(t, err) + require.NotNil(t, got) + require.Equal(t, 2, got.Version, "new row must land at version 2") + require.Equal(t, "active", got.Status, "new row must be active") + require.Equal(t, testActor, got.CreatedBy, + "CreatedBy must be the actor arg, never a body field") + require.NotEqual(t, v1.ID, got.ID, "v2 must have a distinct id from v1") + + // Parent should now be superseded. + parent, err := fx.queries.GetClientMetadataSchema(ctx, v1.ID) + require.NoError(t, err) + require.Equal(t, repository.SchemaStatusTypeSuperseded, parent.Status, + "parent v1 must transition to superseded inside the same tx") + + // New row should be active. + child, err := fx.queries.GetClientMetadataSchema(ctx, got.ID) + require.NoError(t, err) + require.Equal(t, repository.SchemaStatusTypeActive, child.Status, + "new v2 row must be active") + require.Equal(t, int32(2), child.Version) + }) + + t.Run("rejects_parent_not_found", func(t *testing.T) { + // Use a schema name that has no rows so the count assertion is + // unambiguous. Count BEFORE the call and after to prove nothing + // was inserted. + const probeName = "ghost_parent" + require.Equal(t, 0, countRows(t, probeName), + "fixture precondition: no rows for probe lineage") + + missing := uuid.New() + _, err := fx.service.CreateSchemaVersion(ctx, missing, freshInput("ghost"), testActor) + require.Error(t, err) + require.True(t, errors.Is(err, customschema.ErrSchemaNotFound), + "missing parent must surface ErrSchemaNotFound, got: %v", err) + + // No row was inserted under the probe name — obvious because no + // lineage row ever existed for it. + require.Equal(t, 0, countRows(t, probeName), + "missing-parent rejection must not insert any row") + }) + + t.Run("rejects_non_active_parent", func(t *testing.T) { + v1 := seedV1(t, "alpha") + // Supersede v1 by creating v2 from v1. + v2, err := fx.service.CreateSchemaVersion(ctx, v1.ID, freshInput("alpha v2"), testActor) + require.NoError(t, err) + require.NotNil(t, v2) + require.Equal(t, 2, v2.Version) + + // Now v1 is superseded. Calling CreateSchemaVersion with v1.ID + // must be rejected with ErrSchemaParentNotActive. + _, err = fx.service.CreateSchemaVersion(ctx, v1.ID, freshInput("should fail"), testActor) + require.Error(t, err) + require.True(t, errors.Is(err, customschema.ErrSchemaParentNotActive), + "non-active parent must surface ErrSchemaParentNotActive, got: %v", err) + + // v2 is still active, v1 is still superseded, total rows still 2. + v2reload, err := fx.queries.GetClientMetadataSchema(ctx, v2.ID) + require.NoError(t, err) + require.Equal(t, repository.SchemaStatusTypeActive, v2reload.Status, + "v2 must remain active after rejection") + v1reload, err := fx.queries.GetClientMetadataSchema(ctx, v1.ID) + require.NoError(t, err) + require.Equal(t, repository.SchemaStatusTypeSuperseded, v1reload.Status, + "v1 must remain superseded after rejection") + require.Equal(t, 2, countRows(t, "alpha"), + "rejected call must not insert a third row") + }) + + t.Run("rejects_retired_parent", func(t *testing.T) { + // The test engineer chose ErrSchemaParentRetired as the expected + // sentinel here (more specific than ErrSchemaParentNotActive so + // the handler layer can surface a more actionable reason). The + // golang engineer must map retired parents to this sentinel. + v1 := seedV1(t, "beta") + + // Retire v1 via the real DeleteSchema path. + err := fx.service.DeleteSchema(ctx, v1.ID, testActor) + require.NoError(t, err) + + _, err = fx.service.CreateSchemaVersion(ctx, v1.ID, freshInput("should fail"), testActor) + require.Error(t, err) + require.True(t, errors.Is(err, customschema.ErrSchemaParentRetired), + "retired parent must surface ErrSchemaParentRetired, got: %v", err) + + // v1 is still retired, no new row inserted. + v1reload, err := fx.queries.GetClientMetadataSchema(ctx, v1.ID) + require.NoError(t, err) + require.Equal(t, repository.SchemaStatusTypeRetired, v1reload.Status, + "retired v1 must remain retired after rejection") + require.Equal(t, 1, countRows(t, "beta"), + "rejected call against retired parent must not insert a row") + }) + + t.Run("invalid_schema_definition_rejected", func(t *testing.T) { + v1 := seedV1(t, "gamma") + before := len(fx.sink.all()) + + _, err := fx.service.CreateSchemaVersion(ctx, v1.ID, customschema.CreateSchemaVersionInput{ + Description: "malformed", + SchemaDef: json.RawMessage("{this is not json"), + }, testActor) + require.Error(t, err, "malformed schema_def must be rejected") + + // Parent is untouched, no new row inserted, no audit entry. + v1reload, err := fx.queries.GetClientMetadataSchema(ctx, v1.ID) + require.NoError(t, err) + require.Equal(t, repository.SchemaStatusTypeActive, v1reload.Status, + "parent must remain active after validation failure") + require.Equal(t, 1, countRows(t, "gamma"), + "validation failure must not insert a new row") + require.Equal(t, before, len(fx.sink.all()), + "validation failure must not emit an audit entry") + }) + + t.Run("writes_schema_update_audit_entry", func(t *testing.T) { + // ResourceID choice: the new version's id. Justification: the + // audit event is reporting "a new version landed", so the row + // it names is the new version's id, not the (now superseded) + // parent's id. The golang engineer must match this. + v1 := seedV1(t, "delta") + before := len(fx.sink.all()) + + got, err := fx.service.CreateSchemaVersion(ctx, v1.ID, freshInput("delta v2"), testActor) + require.NoError(t, err) + require.NotNil(t, got) + + records := fx.sink.all() + require.Equal(t, before+1, len(records), + "exactly one audit entry must be emitted by CreateSchemaVersion") + last := records[len(records)-1] + require.Equal(t, customschema.AuditActionSchemaUpdate, last.Action, + "audit action must be schema.update") + require.Equal(t, testActor, last.Actor, + "audit actor must be the actor arg, never a body field") + require.Equal(t, got.ID, last.ResourceID, + "audit ResourceID must name the new version's id, not the parent's id") + require.Equal(t, clientID, last.ClientID, + "audit ClientID must be the schema's varchar client id") + }) + + t.Run("active_parent_invariant_after_each_write", func(t *testing.T) { + v1 := seedV1(t, "epsilon") + + // Three sequential calls produce v2 -> v3 -> v4. After each call + // there must be exactly one active row for (clientID, epsilon). + currentParentID := v1.ID + for i := 0; i < 3; i++ { + next, err := fx.service.CreateSchemaVersion(ctx, currentParentID, freshInput("epsilon step"), testActor) + require.NoError(t, err, "sequential call %d must succeed", i+1) + require.NotNil(t, next) + require.Equal(t, i+2, next.Version, "sequential call %d must land at version %d", i+1, i+2) + + require.Equal(t, 1, countActiveRows(t, "epsilon"), + "active-parent invariant must hold after sequential call %d", i+1) + currentParentID = next.ID + } + + // Total of 4 rows: v1/v2/v3 superseded, v4 active. + require.Equal(t, 4, countRows(t, "epsilon"), + "three sequential version creations plus v1 must produce 4 rows") + + // Verify the per-status breakdown via a direct DB count so the + // test doesn't depend on ListSchemas filter semantics. + var superseded int + err := fx.cfg.GetDBPool().QueryRow(ctx, + `SELECT COUNT(*) FROM client_metadata_schemas + WHERE client_id = $1 AND name = 'epsilon' AND status = 'superseded'`, + clientID).Scan(&superseded) + require.NoError(t, err) + require.Equal(t, 3, superseded, + "exactly three rows must be superseded after the loop") + }) + + t.Run("active_parent_invariant_holds_after_rejection", func(t *testing.T) { + v1 := seedV1(t, "zeta") + v2, err := fx.service.CreateSchemaVersion(ctx, v1.ID, freshInput("zeta v2"), testActor) + require.NoError(t, err) + require.NotNil(t, v2) + + // Branching from v1 again must be rejected. + _, err = fx.service.CreateSchemaVersion(ctx, v1.ID, freshInput("should fail"), testActor) + require.Error(t, err) + require.True(t, errors.Is(err, customschema.ErrSchemaParentNotActive), + "branching from superseded v1 must surface ErrSchemaParentNotActive, got: %v", err) + + require.Equal(t, 1, countActiveRows(t, "zeta"), + "active-parent invariant must still hold after rejection (exactly one active row)") + + v1reload, err := fx.queries.GetClientMetadataSchema(ctx, v1.ID) + require.NoError(t, err) + require.Equal(t, repository.SchemaStatusTypeSuperseded, v1reload.Status, + "v1 must remain superseded after rejection") + v2reload, err := fx.queries.GetClientMetadataSchema(ctx, v2.ID) + require.NoError(t, err) + require.Equal(t, repository.SchemaStatusTypeActive, v2reload.Status, + "v2 must remain active after rejection") + require.Equal(t, 2, countRows(t, "zeta"), + "rejected call must not insert a third row") + }) + + t.Run("concurrency_produces_distinct_versions", func(t *testing.T) { + // Race design: two goroutines fire CreateSchemaVersion against + // the same (clientID, "eta") v1 parent. Tracking line 132 says + // "concurrent calls produce distinct version numbers". The v4 + // implementation achieves this by the first committer winning + // with v2, and the second committer seeing the (now superseded) + // parent and failing cleanly with ErrSchemaParentNotActive (per + // tracking line 134). The expected end state is exactly two + // rows: v1 superseded + v2 active. No v3, no duplicate v2. + // + // NOTE ON REGIME: if a future version of the service implements + // a retry path where the loser re-reads the lineage and promotes + // itself to v3, this subtest must be relaxed to "both succeed + // with versions (2, 3)". The current v4 plan explicitly does + // NOT implement that retry path — winner/loser is the contract. + // Do not assert a specific winner; either goroutine may win. + v1 := seedV1(t, "eta") + + var ( + wg sync.WaitGroup + mu sync.Mutex + oks []*customschema.Schema + fails []error + ) + wg.Add(2) + for _, desc := range []string{"v2-racer-A", "v2-racer-B"} { + desc := desc + go func() { + defer wg.Done() + got, err := fx.service.CreateSchemaVersion(ctx, v1.ID, freshInput(desc), testActor) + mu.Lock() + defer mu.Unlock() + if err != nil { + fails = append(fails, err) + return + } + oks = append(oks, got) + }() + } + wg.Wait() + + require.Len(t, oks, 1, + "exactly one goroutine must succeed (winner-takes-all); got %d winners", len(oks)) + require.Len(t, fails, 1, + "exactly one goroutine must fail; got %d failures", len(fails)) + require.True(t, errors.Is(fails[0], customschema.ErrSchemaParentNotActive), + "loser must fail with ErrSchemaParentNotActive, got: %v", fails[0]) + require.Equal(t, 2, oks[0].Version, + "winner must land at version 2") + require.Equal(t, "active", oks[0].Status, + "winner row must be active") + + // End state: exactly two rows for eta — v1 superseded + v2 active. + require.Equal(t, 2, countRows(t, "eta"), + "concurrency must leave exactly two rows (v1 + v2); no v3 and no duplicate v2") + require.Equal(t, 1, countActiveRows(t, "eta"), + "active-parent invariant must hold after the race") + + v1reload, err := fx.queries.GetClientMetadataSchema(ctx, v1.ID) + require.NoError(t, err) + require.Equal(t, repository.SchemaStatusTypeSuperseded, v1reload.Status, + "v1 must be superseded after the race") + }) +} diff --git a/internal/customschema/validator.go b/internal/customschema/validator.go new file mode 100644 index 00000000..6587b02f --- /dev/null +++ b/internal/customschema/validator.go @@ -0,0 +1,128 @@ +package customschema + +import ( + "bytes" + "encoding/json" + "fmt" + + "github.com/santhosh-tekuri/jsonschema/v6" +) + +// SchemaValidator validates JSON Schema definitions and metadata payloads +// against the rules defined in Plan Section 6.2 / Tracking Section 1.2. +type SchemaValidator struct{} + +// ValidateSchemaDefinition enforces the mutable-metadata schema rules on +// an incoming JSON Schema document. The rules are applied in this order +// so each failure mode surfaces a specific, greppable error substring: +// 1. Size ceiling (strict greater-than MaxSchemaDefinitionBytes). +// 2. JSON well-formedness. +// 3. JSON Schema draft 2020-12 meta-validation. +// 4. Root "type" must equal "object". +// 5. Root "additionalProperties" key must be present (true or false). +// +// Returns nil when every rule passes. +func (v *SchemaValidator) ValidateSchemaDefinition(schemaDef json.RawMessage) error { + // Rule 1: size ceiling. Strict greater-than — a payload whose length + // is exactly MaxSchemaDefinitionBytes must still be accepted. + if len(schemaDef) > MaxSchemaDefinitionBytes { + return fmt.Errorf( + "schema definition exceeds maximum size of %d bytes", + MaxSchemaDefinitionBytes, + ) + } + + // Rule 2: JSON well-formedness. Decode into a generic map so we can + // inspect the root keys after meta-validation. + var root map[string]any + if err := json.Unmarshal(schemaDef, &root); err != nil { + return fmt.Errorf("invalid JSON: %w", err) + } + + // Rule 3: JSON Schema draft 2020-12 meta-validation. Use the + // santhosh-tekuri compiler, which validates the supplied schema + // against the 2020-12 meta-schema during Compile(). + compiler := jsonschema.NewCompiler() + parsed, err := jsonschema.UnmarshalJSON(bytes.NewReader(schemaDef)) + if err != nil { + return fmt.Errorf("invalid JSON: %w", err) + } + if err := compiler.AddResource("schema.json", parsed); err != nil { + return fmt.Errorf("schema does not compile as a valid JSON Schema (type check failed): %w", err) + } + if _, err := compiler.Compile("schema.json"); err != nil { + return fmt.Errorf("schema does not compile as a valid JSON Schema (type check failed): %w", err) + } + + // Rule 4: root "type" must equal "object". This check runs after + // meta-validation so a clearly-malformed schema surfaces a type + // error from the library rather than our generic "object" message. + rawType, hasType := root["type"] + if !hasType { + return fmt.Errorf(`root schema must declare type "object" (type key missing)`) + } + typeStr, ok := rawType.(string) + if !ok || typeStr != "object" { + return fmt.Errorf(`root schema type must be "object"`) + } + + // Rule 5: root "additionalProperties" must be present as a boolean. + // Only true or false are accepted — a sub-schema object is not. + // The feature's strict/open switch is a binary choice per plan Section 6.2. + apVal, present := root["additionalProperties"] + if !present { + return fmt.Errorf( + "root schema must explicitly declare additionalProperties (true or false)", + ) + } + if _, isBool := apVal.(bool); !isBool { + return fmt.Errorf( + "root schema must explicitly declare additionalProperties (true or false)", + ) + } + + return nil +} + +// ValidateMetadata validates a metadata payload against a schema definition. +// Both arguments must be well-formed JSON. The schema definition is assumed +// to have already passed ValidateSchemaDefinition — ValidateMetadata only +// runs the instance check, not the meta-validation pass. +// +// Returns nil when the payload conforms to the schema. Returns a wrapped +// error whose message contains "invalid JSON" for malformed inputs or the +// underlying jsonschema validation error for structural mismatches. +func (v *SchemaValidator) ValidateMetadata(metadata json.RawMessage, schemaDef json.RawMessage) error { + // Reject empty payloads before touching the compiler. An empty body is + // never a valid JSON Schema instance — the test expects a clear error. + if len(metadata) == 0 { + return fmt.Errorf("invalid JSON: metadata payload is empty") + } + + // Compile the schema. The schema is re-parsed on every call because + // the library's compiler holds references to the parsed resource. + compiler := jsonschema.NewCompiler() + parsedSchema, err := jsonschema.UnmarshalJSON(bytes.NewReader(schemaDef)) + if err != nil { + return fmt.Errorf("invalid schema definition: %w", err) + } + if err := compiler.AddResource("schema.json", parsedSchema); err != nil { + return fmt.Errorf("invalid schema definition: %w", err) + } + compiled, err := compiler.Compile("schema.json") + if err != nil { + return fmt.Errorf("invalid schema definition: %w", err) + } + + // Parse the metadata payload. + parsedMetadata, err := jsonschema.UnmarshalJSON(bytes.NewReader(metadata)) + if err != nil { + return fmt.Errorf("invalid JSON: %w", err) + } + + // Run instance validation. + if err := compiled.Validate(parsedMetadata); err != nil { + return fmt.Errorf("metadata does not conform to schema: %w", err) + } + return nil +} diff --git a/internal/customschema/validator_test.go b/internal/customschema/validator_test.go new file mode 100644 index 00000000..a90bfc55 --- /dev/null +++ b/internal/customschema/validator_test.go @@ -0,0 +1,189 @@ +package customschema_test + +import ( + "encoding/json" + "strings" + "testing" + + "github.com/stretchr/testify/require" + + "queryorchestration/internal/customschema" +) + +// TestValidateSchemaDefinition exercises every rule listed in Plan +// Section 6.2 / Tracking Section 1.2 against the SchemaValidator. Every +// reject-case subtest is expected to FAIL against the current stub +// (which returns nil unconditionally) and PASS once the real +// implementation lands. Every accept-case subtest must pass against +// both the stub and the real implementation. +func TestValidateSchemaDefinition(t *testing.T) { + v := &customschema.SchemaValidator{} + + // ------------------------------------------------------------------- + // Accept-case subtests. These will "pass" against the stub and must + // remain green under the real implementation. + // ------------------------------------------------------------------- + + t.Run("valid_minimal_object_accepted", func(t *testing.T) { + schema := json.RawMessage(`{"type":"object","additionalProperties":false}`) + + err := v.ValidateSchemaDefinition(schema) + t.Logf("stub err = %v", err) + + require.NoError(t, err, "minimal valid object schema must be accepted") + }) + + t.Run("valid_object_with_properties_accepted", func(t *testing.T) { + schema := json.RawMessage(`{"type":"object","additionalProperties":false,"properties":{"name":{"type":"string","maxLength":100},"count":{"type":"integer","minimum":0}}}`) + + err := v.ValidateSchemaDefinition(schema) + t.Logf("stub err = %v", err) + + require.NoError(t, err, "valid object schema with typed properties must be accepted") + }) + + t.Run("additional_properties_true_accepted", func(t *testing.T) { + // The rule is that additionalProperties must be *present*, not that + // it must be false. Explicit `true` must also be accepted. + schema := json.RawMessage(`{"type":"object","additionalProperties":true}`) + + err := v.ValidateSchemaDefinition(schema) + t.Logf("stub err = %v", err) + + require.NoError(t, err, "explicit additionalProperties=true must be accepted") + }) + + t.Run("at_max_size_accepted", func(t *testing.T) { + // Construct a schema whose serialized length is exactly + // MaxSchemaDefinitionBytes. This proves the size rule is strict + // greater-than, not >=. + // + // Fixed scaffold (63 bytes): + // {"type":"object","additionalProperties":false,"description":""} + // Padding lives inside the description string. + const scaffoldLen = 63 + padLen := customschema.MaxSchemaDefinitionBytes - scaffoldLen + pad := strings.Repeat("a", padLen) + raw := `{"type":"object","additionalProperties":false,"description":"` + pad + `"}` + require.Equal(t, customschema.MaxSchemaDefinitionBytes, len(raw), + "test setup: payload must be exactly MaxSchemaDefinitionBytes bytes") + + schema := json.RawMessage(raw) + err := v.ValidateSchemaDefinition(schema) + t.Logf("stub err = %v", err) + + require.NoError(t, err, "schema exactly at MaxSchemaDefinitionBytes must be accepted") + }) + + // ------------------------------------------------------------------- + // Reject-case subtests. These MUST FAIL against the stub (which + // returns nil) and MUST PASS against the real implementation. + // Expected-substring contract is documented inline so the golang + // engineer knows what error text to emit. + // ------------------------------------------------------------------- + + t.Run("invalid_json_rejected", func(t *testing.T) { + // Contract: error message must mention "invalid" (the golang + // engineer should wrap the json.Unmarshal error with a prefix + // containing the word "invalid", e.g. "invalid JSON: ..."). + schema := json.RawMessage(`{this is not json`) + + err := v.ValidateSchemaDefinition(schema) + t.Logf("stub err = %v", err) + + require.Error(t, err, "malformed JSON must be rejected") + require.Contains(t, strings.ToLower(err.Error()), "invalid", + "error message should mention 'invalid'") + }) + + t.Run("meta_validation_failure_rejected", func(t *testing.T) { + // Contract: error message must mention "type". The santhosh-tekuri + // library reports meta-validation failures against the draft + // 2020-12 meta-schema, and "type: nonsense" fails the enum check + // on the "type" keyword -- the word "type" is the most + // discriminating substring the golang engineer can reliably + // surface from the library's error output. + schema := json.RawMessage(`{"type":"nonsense"}`) + + err := v.ValidateSchemaDefinition(schema) + t.Logf("stub err = %v", err) + + require.Error(t, err, "schema failing JSON Schema meta-validation must be rejected") + require.Contains(t, strings.ToLower(err.Error()), "type", + "error message should mention 'type'") + }) + + t.Run("root_type_not_object_rejected", func(t *testing.T) { + // Contract: error message must mention "object" (Plan 6.2 rule 3 + // is explicit that root type must be "object"). + schema := json.RawMessage(`{"type":"array","items":{"type":"string"}}`) + + err := v.ValidateSchemaDefinition(schema) + t.Logf("stub err = %v", err) + + require.Error(t, err, "non-object root type must be rejected") + require.Contains(t, strings.ToLower(err.Error()), "object", + "error message should mention 'object'") + }) + + t.Run("root_type_missing_rejected", func(t *testing.T) { + // Contract: error message must mention "type". Omitted root + // `type` fails Plan 6.2 rule 3 (root type must equal "object") + // regardless of JSON Schema's own permissiveness. + schema := json.RawMessage(`{"additionalProperties":false}`) + + err := v.ValidateSchemaDefinition(schema) + t.Logf("stub err = %v", err) + + require.Error(t, err, "missing root type must be rejected") + require.Contains(t, strings.ToLower(err.Error()), "type", + "error message should mention 'type'") + }) + + t.Run("additional_properties_sub_schema_rejected", func(t *testing.T) { + // Contract: only boolean true/false is accepted. A sub-schema object + // like {"type":"string"} must be rejected with an error mentioning + // "additionalProperties". Plan Section 6.2 rule 4 is explicitly binary. + schema := json.RawMessage(`{"type":"object","additionalProperties":{"type":"string"}}`) + + err := v.ValidateSchemaDefinition(schema) + t.Logf("stub err = %v", err) + + require.Error(t, err, "sub-schema additionalProperties must be rejected") + require.Contains(t, err.Error(), "additionalProperties", + "error message should mention 'additionalProperties'") + }) + + t.Run("additional_properties_missing_rejected", func(t *testing.T) { + // Contract: error message must mention "additionalProperties" + // (Plan 6.2 rule 4 is explicit that this key must be present). + schema := json.RawMessage(`{"type":"object","properties":{"foo":{"type":"string"}}}`) + + err := v.ValidateSchemaDefinition(schema) + t.Logf("stub err = %v", err) + + require.Error(t, err, "missing root additionalProperties must be rejected") + require.Contains(t, err.Error(), "additionalProperties", + "error message should mention 'additionalProperties'") + }) + + t.Run("oversize_rejected", func(t *testing.T) { + // Contract: error message must mention "size" (Plan 6.2 rule 5: + // Size must be <= MaxSchemaDefinitionBytes). + const scaffoldLen = 63 + // One byte past the limit. + padLen := customschema.MaxSchemaDefinitionBytes - scaffoldLen + 1 + pad := strings.Repeat("a", padLen) + raw := `{"type":"object","additionalProperties":false,"description":"` + pad + `"}` + require.Equal(t, customschema.MaxSchemaDefinitionBytes+1, len(raw), + "test setup: payload must be exactly MaxSchemaDefinitionBytes+1 bytes") + + schema := json.RawMessage(raw) + err := v.ValidateSchemaDefinition(schema) + t.Logf("stub err = %v", err) + + require.Error(t, err, "schema larger than MaxSchemaDefinitionBytes must be rejected") + require.Contains(t, strings.ToLower(err.Error()), "size", + "error message should mention 'size'") + }) +} diff --git a/internal/database/migrations/00000000000127_create_client_metadata_schemas.down.sql b/internal/database/migrations/00000000000127_create_client_metadata_schemas.down.sql new file mode 100644 index 00000000..b2f06702 --- /dev/null +++ b/internal/database/migrations/00000000000127_create_client_metadata_schemas.down.sql @@ -0,0 +1,11 @@ +-- Reverse of 00000000000127_create_client_metadata_schemas.up.sql. +-- DROP TABLE automatically drops its indexes and constraints; the enum +-- type must be dropped explicitly after the table that uses it. + +DROP INDEX IF EXISTS idx_cms_client_status; +DROP INDEX IF EXISTS idx_cms_client_name; +DROP INDEX IF EXISTS idx_cms_client_id; + +DROP TABLE IF EXISTS client_metadata_schemas; + +DROP TYPE IF EXISTS schema_status_type; diff --git a/internal/database/migrations/00000000000127_create_client_metadata_schemas.up.sql b/internal/database/migrations/00000000000127_create_client_metadata_schemas.up.sql new file mode 100644 index 00000000..10919357 --- /dev/null +++ b/internal/database/migrations/00000000000127_create_client_metadata_schemas.up.sql @@ -0,0 +1,26 @@ +-- Mutable metadata feature, milestone 1.1: schema_status_type enum and +-- client_metadata_schemas table. See plan Section 4.1 and 4.2 of +-- plans/mutable.metadata.plan.combo.v4.md for the canonical definition. + +CREATE TYPE schema_status_type AS ENUM ('active', 'superseded', 'retired'); + +CREATE TABLE client_metadata_schemas ( + id uuid NOT NULL DEFAULT uuid_generate_v7(), + client_id varchar(255) NOT NULL REFERENCES clients(clientId) ON DELETE CASCADE, + name varchar(255) NOT NULL, + description text, + schema_def jsonb NOT NULL, + version int NOT NULL DEFAULT 1, + status schema_status_type NOT NULL DEFAULT 'active', + created_at timestamptz NOT NULL DEFAULT NOW(), + created_by varchar(255) NOT NULL, + + CONSTRAINT pk_client_metadata_schemas PRIMARY KEY (id), + CONSTRAINT uq_client_schema_name_version UNIQUE (client_id, name, version), + CONSTRAINT uq_cms_id_client UNIQUE (id, client_id), + CONSTRAINT chk_schema_def_size CHECK (pg_column_size(schema_def) <= 70000) +); + +CREATE INDEX idx_cms_client_id ON client_metadata_schemas(client_id); +CREATE INDEX idx_cms_client_name ON client_metadata_schemas(client_id, name); +CREATE INDEX idx_cms_client_status ON client_metadata_schemas(client_id, status); diff --git a/internal/database/migrations/00000000000128_add_custom_schema_id_to_documents.down.sql b/internal/database/migrations/00000000000128_add_custom_schema_id_to_documents.down.sql new file mode 100644 index 00000000..0e241fa9 --- /dev/null +++ b/internal/database/migrations/00000000000128_add_custom_schema_id_to_documents.down.sql @@ -0,0 +1,9 @@ +-- Reverse of 00000000000128_add_custom_schema_id_to_documents.up.sql. +-- Drop order is index -> constraint -> column so nothing references the +-- column when it is removed. + +DROP INDEX IF EXISTS idx_documents_custom_schema_id; + +ALTER TABLE documents DROP CONSTRAINT IF EXISTS fk_documents_custom_schema_same_client; + +ALTER TABLE documents DROP COLUMN IF EXISTS custom_schema_id; diff --git a/internal/database/migrations/00000000000128_add_custom_schema_id_to_documents.up.sql b/internal/database/migrations/00000000000128_add_custom_schema_id_to_documents.up.sql new file mode 100644 index 00000000..80addb3f --- /dev/null +++ b/internal/database/migrations/00000000000128_add_custom_schema_id_to_documents.up.sql @@ -0,0 +1,18 @@ +-- Mutable metadata feature, milestone 1.3: bind documents to a +-- client_metadata_schemas row via a composite (schema_id, clientId) FK so +-- cross-client assignment is impossible. See plan Section 4.4 of +-- plans/mutable.metadata.plan.combo.v4.md for the canonical definition. +-- The composite FK is the only referential integrity tie to +-- client_metadata_schemas; no inline single-column REFERENCES is declared. + +ALTER TABLE documents ADD COLUMN custom_schema_id uuid NULL; + +-- The plan writes "clientId" quoted, but migration 5 created the column +-- unquoted (so Postgres stored it as lowercase "clientid"). A quoted +-- "clientId" in the FK declaration is a literal case-sensitive lookup +-- that does not match. Use the unquoted form so it folds to lowercase. +ALTER TABLE documents ADD CONSTRAINT fk_documents_custom_schema_same_client + FOREIGN KEY (custom_schema_id, clientId) + REFERENCES client_metadata_schemas(id, client_id); + +CREATE INDEX idx_documents_custom_schema_id ON documents(custom_schema_id); diff --git a/internal/database/migrations/00000000000129_create_document_custom_metadata.down.sql b/internal/database/migrations/00000000000129_create_document_custom_metadata.down.sql new file mode 100644 index 00000000..cf65f7a4 --- /dev/null +++ b/internal/database/migrations/00000000000129_create_document_custom_metadata.down.sql @@ -0,0 +1,6 @@ +-- Rollback migration 129: drop document_custom_metadata and its index. +-- The FK cascade from documents takes care of any in-flight rows when the +-- table itself is dropped, so no explicit DELETE is required. + +DROP INDEX IF EXISTS idx_dcm_doc_version_desc; +DROP TABLE IF EXISTS document_custom_metadata; diff --git a/internal/database/migrations/00000000000129_create_document_custom_metadata.up.sql b/internal/database/migrations/00000000000129_create_document_custom_metadata.up.sql new file mode 100644 index 00000000..1453162e --- /dev/null +++ b/internal/database/migrations/00000000000129_create_document_custom_metadata.up.sql @@ -0,0 +1,39 @@ +-- Mutable metadata feature, milestone 2.1: document_custom_metadata table. +-- Stores every historical version of a document's custom metadata payload +-- against its bound schema (schema is looked up via documents.custom_schema_id +-- at read time, not stored here). +-- +-- See plan Section 4.3 of plans/mutable.metadata.plan.combo.v4.md for the +-- canonical definition. +-- +-- v4 simplifications compared to v3: +-- 1. No `schema_id` column. The binding lives on the document row +-- (documents.custom_schema_id); storing it again here would allow the +-- two copies to drift. Reads join through documents to +-- client_metadata_schemas to decorate the response. +-- 2. No `current_document_custom_metadata` view. The "current" row is +-- fetched with a simple ORDER BY version DESC LIMIT 1 query backed by +-- idx_dcm_doc_version_desc. +-- 3. ON DELETE CASCADE on the document_id FK replaces v3's explicit +-- delete-cascade workstream: dropping a document also removes its +-- metadata history automatically. + +CREATE TABLE document_custom_metadata ( + id uuid NOT NULL DEFAULT uuid_generate_v7(), + document_id uuid NOT NULL, + metadata jsonb NOT NULL, + version int NOT NULL, + created_at timestamptz NOT NULL DEFAULT NOW(), + created_by varchar(255) NOT NULL, + + CONSTRAINT pk_document_custom_metadata PRIMARY KEY (id), + CONSTRAINT uq_doc_custom_metadata_version UNIQUE (document_id, version), + CONSTRAINT fk_dcm_document_id FOREIGN KEY (document_id) + REFERENCES documents(id) ON DELETE CASCADE +); + +-- The single index v4 needs on this table: backs the "current metadata" +-- lookup (ORDER BY version DESC LIMIT 1). v3 also defined idx_dcm_schema_id; +-- v4 intentionally omits it because the schema_id column does not exist. +CREATE INDEX idx_dcm_doc_version_desc + ON document_custom_metadata(document_id, version DESC); diff --git a/internal/database/migrations/00000000000130_add_schema_invariant_triggers.down.sql b/internal/database/migrations/00000000000130_add_schema_invariant_triggers.down.sql new file mode 100644 index 00000000..14338fbf --- /dev/null +++ b/internal/database/migrations/00000000000130_add_schema_invariant_triggers.down.sql @@ -0,0 +1,11 @@ +-- Rollback migration 130: drop both triggers, then both functions. +-- Triggers must go before their functions or DROP FUNCTION complains +-- about dependent objects. + +DROP TRIGGER IF EXISTS trg_prevent_legacy_extraction_on_custom_document + ON documentFieldExtractions; +DROP TRIGGER IF EXISTS trg_prevent_schema_reassignment + ON documents; + +DROP FUNCTION IF EXISTS trg_prevent_legacy_extraction_on_custom_document(); +DROP FUNCTION IF EXISTS trg_prevent_schema_reassignment(); diff --git a/internal/database/migrations/00000000000130_add_schema_invariant_triggers.up.sql b/internal/database/migrations/00000000000130_add_schema_invariant_triggers.up.sql new file mode 100644 index 00000000..38480d7d --- /dev/null +++ b/internal/database/migrations/00000000000130_add_schema_invariant_triggers.up.sql @@ -0,0 +1,116 @@ +-- Mutable metadata feature, milestone 2.2: schema invariant triggers. +-- See plan Section 4.7 of plans/mutable.metadata.plan.combo.v4.md for the +-- canonical definition. +-- +-- v4 ships exactly two triggers on this path: +-- +-- 1. trg_prevent_schema_reassignment — BEFORE UPDATE OF custom_schema_id +-- on documents. Blocks changing a schema binding once the document has +-- either custom metadata or legacy field extractions. The pre-binding +-- case (document has no metadata and no extractions) is allowed so the +-- admin can correct a mistake before real data lands. +-- +-- 2. trg_prevent_legacy_extraction_on_custom_document — BEFORE INSERT on +-- documentFieldExtractions. Reads documents.custom_schema_id with +-- FOR SHARE so a concurrent AssignSchema tx holding FOR UPDATE blocks +-- this INSERT until it commits; after the commit the trigger observes +-- the new non-null value and raises check_violation. This is the +-- defense-in-depth backstop for the race described in plan Section 7.3. +-- +-- v4 intentionally does NOT define: +-- - trg_validate_schema_client_match (v3 "Trigger 2"): replaced by the +-- composite FK fk_documents_custom_schema_same_client introduced in +-- migration 128, which enforces the same (schema_id, client_id) tuple +-- invariant at the referential-integrity layer. +-- - trg_enforce_consistent_schema_id (v3 "Trigger 3"): structurally +-- impossible to need because document_custom_metadata has no schema_id +-- column in v4. +-- +-- Postgres folds unquoted identifiers to lowercase. The `documents`, +-- `documentFieldExtractions`, and `documentFieldExtractionVersions` tables +-- were all created with unquoted DDL, so their stored relnames are +-- `documents`, `documentfieldextractions`, `documentfieldextractionversions`. +-- The same rule applies to the `custom_schema_id` and `documentId` columns. + +-- ---------- Trigger 1: prevent schema reassignment ---------- + +CREATE OR REPLACE FUNCTION trg_prevent_schema_reassignment() +RETURNS trigger +LANGUAGE plpgsql +AS $$ +BEGIN + -- No-op when the binding did not change. Triggers fire on every + -- UPDATE OF, even when the new value equals the old. Skip the checks + -- unless the value truly changed (IS DISTINCT FROM handles NULL + -- transitions both ways). + IF NEW.custom_schema_id IS NOT DISTINCT FROM OLD.custom_schema_id THEN + RETURN NEW; + END IF; + + -- Block reassignment when metadata already exists for this document. + IF EXISTS ( + SELECT 1 FROM document_custom_metadata + WHERE document_id = NEW.id + ) THEN + RAISE EXCEPTION + 'cannot reassign schema: custom metadata already exists for document %', + NEW.id; + END IF; + + -- Block reassignment when legacy field extraction versions already + -- exist for this document. The existence check uses + -- documentFieldExtractionVersions because migration 117 normalized the + -- version table to carry documentId directly. + IF EXISTS ( + SELECT 1 FROM documentFieldExtractionVersions + WHERE documentId = NEW.id + ) THEN + RAISE EXCEPTION + 'cannot reassign schema: legacy field extractions already exist for document %', + NEW.id; + END IF; + + RETURN NEW; +END; +$$; + +CREATE TRIGGER trg_prevent_schema_reassignment + BEFORE UPDATE OF custom_schema_id ON documents + FOR EACH ROW + EXECUTE FUNCTION trg_prevent_schema_reassignment(); + +-- ---------- Trigger 2: prevent legacy extraction on custom document ---------- + +CREATE OR REPLACE FUNCTION trg_prevent_legacy_extraction_on_custom_document() +RETURNS trigger +LANGUAGE plpgsql +AS $$ +DECLARE + bound_schema uuid; +BEGIN + -- FOR SHARE acquires a row-level share lock that blocks on any + -- concurrent FOR UPDATE on the same documents row. That serializes + -- this INSERT with an in-flight AssignSchema transaction so that if + -- the assign commits first, this INSERT observes the non-null + -- custom_schema_id and rejects. + SELECT custom_schema_id + INTO bound_schema + FROM documents + WHERE id = NEW.documentId + FOR SHARE; + + IF bound_schema IS NOT NULL THEN + RAISE EXCEPTION + 'document % is bound to custom schema %, legacy field extractions are not allowed', + NEW.documentId, bound_schema + USING ERRCODE = '23514'; + END IF; + + RETURN NEW; +END; +$$; + +CREATE TRIGGER trg_prevent_legacy_extraction_on_custom_document + BEFORE INSERT ON documentFieldExtractions + FOR EACH ROW + EXECUTE FUNCTION trg_prevent_legacy_extraction_on_custom_document(); diff --git a/internal/database/queries/custommetadata.sql b/internal/database/queries/custommetadata.sql new file mode 100644 index 00000000..c5b06acb --- /dev/null +++ b/internal/database/queries/custommetadata.sql @@ -0,0 +1,186 @@ +-- Mutable metadata feature, milestone 2.3: document_custom_metadata CRUD +-- queries plus document custom_schema_id accessors. +-- +-- See plan Section 8.2 / tracking §2.3 of +-- plans/mutable.metadata.plan.combo.v4.md for the canonical list. +-- +-- v4 notes: +-- - No schema_id parameter on the metadata insert. The schema binding +-- lives on the document row; metadata rows don't duplicate it. +-- - GetCurrentDocumentCustomMetadata and GetDocumentCustomMetadataByVersion +-- join documents + client_metadata_schemas so the handler can populate +-- schemaId/schemaName/schemaVersion in one round-trip. +-- - LockDocumentForMetadataWrite takes a parent-row lock on documents. +-- Replaces v3's LockDocumentCustomMetadataForVersion. +-- - DeleteDocumentCustomMetadata / NullifyDocumentCustomSchemaId are +-- intentionally absent: FK cascades handle document deletion and the +-- reset-metadata workstream writes a direct UPDATE instead. + +-- name: CreateDocumentCustomMetadata :one +-- Insert a new metadata version for a document. Caller supplies an +-- explicit version computed from GetMaxDocumentMetadataVersion + 1 under +-- the parent-row lock from LockDocumentForMetadataWrite. No schema_id +-- column exists on this table in v4. +INSERT INTO document_custom_metadata ( + document_id, + metadata, + version, + created_by +) VALUES ( + @document_id, + @metadata, + @version, + @created_by +) +RETURNING *; + +-- name: GetCurrentDocumentCustomMetadata :one +-- Return the most-recent metadata row for a document, decorated with the +-- current schema name and version pulled from the bound schema row. +-- Joins documents (for the binding) and client_metadata_schemas (for +-- name/version). idx_dcm_doc_version_desc backs the ORDER BY ... LIMIT 1. +SELECT + dcm.id, + dcm.document_id, + dcm.metadata, + dcm.version, + dcm.created_at, + dcm.created_by, + cms.id AS schema_id, + cms.name AS schema_name, + cms.version AS schema_version +FROM document_custom_metadata dcm +JOIN documents d ON d.id = dcm.document_id +LEFT JOIN client_metadata_schemas cms ON cms.id = d.custom_schema_id +WHERE dcm.document_id = @document_id +ORDER BY dcm.version DESC +LIMIT 1; + +-- name: GetDocumentCustomMetadataByVersion :one +-- Return a specific historical version with the same schema decoration +-- as the current-version query above. +SELECT + dcm.id, + dcm.document_id, + dcm.metadata, + dcm.version, + dcm.created_at, + dcm.created_by, + cms.id AS schema_id, + cms.name AS schema_name, + cms.version AS schema_version +FROM document_custom_metadata dcm +JOIN documents d ON d.id = dcm.document_id +LEFT JOIN client_metadata_schemas cms ON cms.id = d.custom_schema_id +WHERE dcm.document_id = @document_id + AND dcm.version = @version; + +-- name: GetDocumentCustomMetadataHistory :many +-- Return a paged list of version summaries for a document, newest first. +-- No schema join: the history response is a terse list of +-- (version, created_at, created_by) tuples. +SELECT + version, + created_at, + created_by +FROM document_custom_metadata +WHERE document_id = @document_id +ORDER BY version DESC +LIMIT @limit_val OFFSET @offset_val; + +-- name: LockDocumentForMetadataWrite :one +-- Parent-row lock for metadata write ordering. Must be called inside a +-- transaction as the FIRST DML statement. Serializes with AssignSchema +-- (which also takes FOR UPDATE on documents) so the mutual-exclusivity +-- invariant in plan Section 7.3 holds. +SELECT id FROM documents +WHERE id = @document_id +FOR UPDATE; + +-- name: GetMaxDocumentMetadataVersion :one +-- Return MAX(version) for a document's metadata rows, or 0 when none +-- exist. Caller adds 1 to compute the next version to insert. Must run +-- inside the transaction that holds the LockDocumentForMetadataWrite +-- lock; otherwise two concurrent writers can both see the same max. +SELECT COALESCE(MAX(version), 0)::int AS max_version +FROM document_custom_metadata +WHERE document_id = @document_id; + +-- name: SetDocumentCustomSchemaId :exec +-- Bind (or clear) a document's schema. Nullable parameter: passing NULL +-- clears the binding. The composite FK in migration 128 prevents binding +-- a schema owned by a different client, so this query does not re-check. +UPDATE documents +SET custom_schema_id = @custom_schema_id +WHERE id = @id; + +-- name: GetDocumentCustomSchemaId :one +-- Read a document's current schema binding. Returns NULL when unbound. +SELECT custom_schema_id +FROM documents +WHERE id = @id; + +-- name: GetDocumentClientID :one +-- Read a document's clientId for the AssignSchema cross-client check. +-- documents.clientId is the unquoted (lowercased) varchar column. +SELECT clientId FROM documents WHERE id = @id; + +-- name: HasDocumentCustomMetadata :one +-- EXISTS probe for any metadata row belonging to this document. Used by +-- AssignSchema to reject re-binding once metadata has been written. +SELECT EXISTS( + SELECT 1 FROM document_custom_metadata WHERE document_id = @document_id +) AS has_metadata; + +-- name: LockDocumentForReset :one +-- Milestone 3 parent-row lock acquired by ResetDocumentMetadata as the +-- FIRST DML statement inside its transaction. Same FOR UPDATE shape as +-- LockDocumentForMetadataWrite / LockDocumentForLegacyExtractionWrite so +-- the reset path serializes correctly against concurrent assign and write +-- paths for the mutual-exclusivity invariant (plan Section 7.3). +-- Returns id, clientid, and current custom_schema_id so the caller can +-- short-circuit and/or decorate the result without a second round-trip. +SELECT id, clientId, custom_schema_id +FROM documents +WHERE id = @document_id +FOR UPDATE; + +-- name: GetDocumentSchemaBindingForReset :one +-- Read the document's current schema binding along with the joined +-- schema row's name and version so ResetDocumentMetadata can populate the +-- previousSchema* fields on its response. The LEFT JOIN returns NULL for +-- name/version when the document has no schema bound — the reset endpoint +-- treats that as the idempotent no-op case and still succeeds. +SELECT + d.custom_schema_id, + cms.name AS schema_name, + cms.version AS schema_version +FROM documents d +LEFT JOIN client_metadata_schemas cms ON cms.id = d.custom_schema_id +WHERE d.id = @document_id; + +-- name: CountDocumentCustomMetadataVersions :one +-- Return the number of metadata version rows for a document. Used by +-- ResetDocumentMetadata to populate metadataVersionsDeleted on its +-- response. Run under the parent-row lock from LockDocumentForReset so +-- the count is authoritative at commit time. +SELECT COUNT(*)::int AS version_count +FROM document_custom_metadata +WHERE document_id = @document_id; + +-- name: DeleteDocumentCustomMetadataForReset :exec +-- @sqlc-vet-disable +-- Delete every metadata version row for a document. Owned by the +-- reset-metadata path — the Delete cascade for document deletion is +-- handled by ON DELETE CASCADE on document_custom_metadata.document_id, +-- not by this query. Name disambiguates from any future cascade helper. +DELETE FROM document_custom_metadata WHERE document_id = @document_id; + +-- name: NullifyDocumentCustomSchemaIdForReset :exec +-- Clear the document's schema binding as the final DML of the reset +-- transaction. Trigger 1 (trg_prevent_schema_reassignment) fires on this +-- UPDATE; the preceding DeleteDocumentCustomMetadataForReset has already +-- removed every metadata row and the transaction has already rejected any +-- document carrying legacy field extractions, so the trigger's EXISTS +-- checks both return false and the UPDATE succeeds. +UPDATE documents SET custom_schema_id = NULL WHERE id = @document_id; diff --git a/internal/database/queries/customschemas.sql b/internal/database/queries/customschemas.sql new file mode 100644 index 00000000..82c311dd --- /dev/null +++ b/internal/database/queries/customschemas.sql @@ -0,0 +1,109 @@ +-- Mutable metadata feature, milestone 1.4: schema CRUD queries for +-- client_metadata_schemas. See plan Section 8.2 of +-- plans/mutable.metadata.plan.combo.v4.md for the canonical list. +-- +-- Intentionally omitted in v4: +-- - GetSchemaMetadataRecordCount (removed; canDelete derives from +-- GetSchemaDocumentCount alone because document_custom_metadata has +-- no schema_id column in v4). +-- - Metadata, document-modification, and reset-metadata queries +-- (Milestones 2 and 3, plan sections 8.2 continued). + +-- name: CreateClientMetadataSchema :one +-- Insert a new schema row. Caller supplies an explicit version computed +-- from GetMaxSchemaVersion + 1 and an explicit status (always 'active' +-- in Milestone 1; supersede/retire go through SetSchemaStatus). The +-- database defaults id and created_at. +INSERT INTO client_metadata_schemas ( + client_id, + name, + description, + schema_def, + version, + status, + created_by +) VALUES ( + @client_id, + @name, + @description, + @schema_def, + @version, + @status, + @created_by +) +RETURNING *; + +-- name: GetClientMetadataSchema :one +-- Fetch a single schema by primary key. +SELECT * FROM client_metadata_schemas +WHERE id = $1; + +-- name: ListClientMetadataSchemas :many +-- List schemas for a client with optional filters. Status filtering uses a +-- two-parameter shape: when include_all_statuses is true, status is ignored; +-- otherwise status_filter selects exactly one of active/superseded/retired. +-- When include_all_versions is false, only the latest version per +-- (client_id, name) is returned (via a max-version subquery). When true, +-- every version is returned. Name filter uses sqlc.narg so passing NULL +-- skips the filter; a non-NULL value is matched exactly. The document_count +-- column is a correlated subquery so the handler can derive canDelete +-- without a second round-trip. +SELECT + cms.*, + (SELECT COUNT(*) FROM documents WHERE documents.custom_schema_id = cms.id)::bigint AS document_count +FROM client_metadata_schemas cms +WHERE cms.client_id = @client_id + AND (sqlc.narg(name_filter)::varchar IS NULL OR cms.name = sqlc.narg(name_filter)::varchar) + AND (@include_all_statuses::bool OR cms.status = @status_filter::schema_status_type) + AND ( + @include_all_versions::bool + OR (cms.client_id, cms.name, cms.version) IN ( + SELECT client_id, name, MAX(version) + FROM client_metadata_schemas + WHERE client_id = @client_id + GROUP BY client_id, name + ) + ) +ORDER BY cms.name, cms.version DESC +LIMIT @limit_val OFFSET @offset_val; + +-- name: GetLatestSchemaByName :one +-- Return the highest-version row for a (client_id, name) pair regardless +-- of status. Callers that need an active-only view should filter after. +SELECT * FROM client_metadata_schemas +WHERE client_id = @client_id AND name = @name +ORDER BY version DESC +LIMIT 1; + +-- name: GetSchemaDocumentCount :one +-- Count documents currently bound to a schema via documents.custom_schema_id. +-- Used to derive canDelete and to drive retire-vs-delete decisions. +SELECT COUNT(*) AS document_count +FROM documents +WHERE custom_schema_id = @schema_id; + +-- name: SetSchemaStatus :exec +-- Update a schema's status. Used by the service layer to mark a row +-- superseded when a new version is created, and to mark a row retired +-- when the schema still has documents bound at delete time. +UPDATE client_metadata_schemas +SET status = @status +WHERE id = @id; + +-- name: GetMaxSchemaVersion :one +-- Get the current max version for a (client_id, name) pair. Returns 0 +-- for a brand-new pair so the service layer can safely write version 1. +-- Must be called inside the same transaction that holds the lock from +-- LockSchemaVersionsForName. +SELECT COALESCE(MAX(version), 0) AS max_version +FROM client_metadata_schemas +WHERE client_id = @client_id AND name = @name; + +-- name: LockSchemaVersionsForName :many +-- Lock every version row of a (client_id, name) pair with FOR UPDATE to +-- serialize concurrent version creation. Returns the locked rows so the +-- service can read the current active row id and max version in one +-- statement. Must be called inside a transaction. +SELECT * FROM client_metadata_schemas +WHERE client_id = @client_id AND name = @name +FOR UPDATE; diff --git a/internal/database/queries/document.sql b/internal/database/queries/document.sql index cca69ed0..efbd3238 100644 --- a/internal/database/queries/document.sql +++ b/internal/database/queries/document.sql @@ -61,7 +61,10 @@ RIGHT JOIN max_part mp ON p.part = mp.max_part_num GROUP BY p.part; -- name: GetDocumentEnriched :one --- Retrieves a document with extended metadata including folder and filename +-- Retrieves a document with extended metadata including folder and filename. +-- Milestone 2.3a adds custom_schema_id and a correlated EXISTS probe into +-- document_custom_metadata so GET /document/{id} can surface both fields +-- without a follow-up query. SELECT d.id, d.clientId, @@ -69,10 +72,27 @@ SELECT d.folderId, d.filename, d.originalPath, - d.file_size_bytes + d.file_size_bytes, + d.custom_schema_id, + EXISTS( + SELECT 1 FROM document_custom_metadata + WHERE document_id = d.id + ) AS has_custom_metadata FROM documents d WHERE d.id = $1; +-- name: LockDocumentForLegacyExtractionWrite :one +-- Milestone 2.5 parent-row lock acquired by CreateFieldExtraction as the +-- FIRST DML statement inside its transaction. Serializes with AssignSchema +-- (which takes FOR UPDATE on the same row) so the mutual-exclusivity +-- invariant from plan Section 7.3 holds even if Trigger 2 is ever removed. +-- Returns the current custom_schema_id so the caller can short-circuit to +-- ErrMutualExclusivityViolation when the document is already bound. +SELECT id, custom_schema_id +FROM documents +WHERE id = @document_id +FOR UPDATE; + -- name: CollectDocumentS3Paths :many -- @sqlc-vet-disable -- Collect S3 bucket+key from documentEntries before deleting, for logging orphaned S3 objects diff --git a/internal/database/repository/client_metadata_schemas_migration_test.go b/internal/database/repository/client_metadata_schemas_migration_test.go new file mode 100644 index 00000000..220a905f --- /dev/null +++ b/internal/database/repository/client_metadata_schemas_migration_test.go @@ -0,0 +1,899 @@ +package repository_test + +import ( + "context" + "errors" + "fmt" + "os" + "strings" + "testing" + + "queryorchestration/internal/database/repository" + "queryorchestration/internal/serviceconfig" + "queryorchestration/internal/test" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgconn" + "github.com/stretchr/testify/require" +) + +// TestMigration127_ClientMetadataSchemasShape asserts the structural shape +// defined by plan Section 4.1 (schema_status_type enum) and Section 4.2 +// (client_metadata_schemas table) of plans/mutable.metadata.plan.combo.v4.md. +// +// This test intentionally does NOT exercise constraint-violation behavior +// (duplicate insert, CHECK trip, cascade delete). Those belong to a later +// dispatch once the migration SQL has landed. Its purpose right now is to +// fail loudly until migration 127 is in place. +// +// It introspects information_schema and pg_catalog against the real Postgres +// testcontainer that test.CreateDB stands up. No mocks. +func TestMigration127_ClientMetadataSchemasShape(t *testing.T) { + t.Parallel() + if testing.Short() { + t.SkipNow() + } + ctx := t.Context() + + cfg := &serviceconfig.BaseConfig{} + test.CreateDB(t, cfg) + pool := cfg.GetDBPool() + require.NotNil(t, pool) + + t.Run("enum_exists", func(t *testing.T) { + rows, err := pool.Query(ctx, `SELECT unnest(enum_range(NULL::schema_status_type))::text`) + require.NoError(t, err, "schema_status_type enum must exist") + defer rows.Close() + + var values []string + for rows.Next() { + var v string + require.NoError(t, rows.Scan(&v)) + values = append(values, v) + } + require.NoError(t, rows.Err()) + require.Equal(t, []string{"active", "superseded", "retired"}, values, + "schema_status_type must contain exactly {active, superseded, retired} in that order") + }) + + t.Run("table_exists", func(t *testing.T) { + var exists bool + err := pool.QueryRow(ctx, ` + SELECT EXISTS ( + SELECT 1 FROM information_schema.tables + WHERE table_schema = 'public' + AND table_name = 'client_metadata_schemas' + ) + `).Scan(&exists) + require.NoError(t, err) + require.True(t, exists, "client_metadata_schemas table must exist") + }) + + t.Run("columns", func(t *testing.T) { + // expected column -> (data_type, is_nullable) + type colSpec struct { + dataType string + isNullable string // "YES" or "NO" + } + expected := map[string]colSpec{ + "id": {"uuid", "NO"}, + "client_id": {"character varying", "NO"}, + "name": {"character varying", "NO"}, + "description": {"text", "YES"}, + "schema_def": {"jsonb", "NO"}, + "version": {"integer", "NO"}, + "status": {"USER-DEFINED", "NO"}, // enum types surface as USER-DEFINED + "created_at": {"timestamp with time zone", "NO"}, + "created_by": {"character varying", "NO"}, + } + + rows, err := pool.Query(ctx, ` + SELECT column_name, data_type, is_nullable, udt_name + FROM information_schema.columns + WHERE table_schema = 'public' + AND table_name = 'client_metadata_schemas' + `) + require.NoError(t, err) + defer rows.Close() + + found := map[string]colSpec{} + udtNames := map[string]string{} + for rows.Next() { + var name, dataType, nullable, udt string + require.NoError(t, rows.Scan(&name, &dataType, &nullable, &udt)) + found[name] = colSpec{dataType: dataType, isNullable: nullable} + udtNames[name] = udt + } + require.NoError(t, rows.Err()) + + require.Equal(t, len(expected), len(found), + "column count mismatch; expected %d, got %v", len(expected), found) + + for name, want := range expected { + got, ok := found[name] + require.Truef(t, ok, "missing column %q", name) + require.Equalf(t, want.dataType, got.dataType, + "column %q data_type mismatch", name) + require.Equalf(t, want.isNullable, got.isNullable, + "column %q is_nullable mismatch", name) + } + // schema_status_type enum backs the status column. + require.Equal(t, "schema_status_type", udtNames["status"], + "status column must be backed by schema_status_type enum") + }) + + t.Run("primary_key", func(t *testing.T) { + cols, err := fetchConstraintColumns(ctx, pool, "pk_client_metadata_schemas", "p") + require.NoError(t, err) + require.Equal(t, []string{"id"}, cols, + "pk_client_metadata_schemas must be PRIMARY KEY on (id)") + }) + + t.Run("unique_name_version", func(t *testing.T) { + cols, err := fetchConstraintColumns(ctx, pool, "uq_client_schema_name_version", "u") + require.NoError(t, err) + require.Equal(t, []string{"client_id", "name", "version"}, cols, + "uq_client_schema_name_version must be UNIQUE on (client_id, name, version)") + }) + + t.Run("unique_id_client", func(t *testing.T) { + cols, err := fetchConstraintColumns(ctx, pool, "uq_cms_id_client", "u") + require.NoError(t, err) + require.Equal(t, []string{"id", "client_id"}, cols, + "uq_cms_id_client must be UNIQUE on (id, client_id)") + }) + + t.Run("check_size", func(t *testing.T) { + var contype string + err := pool.QueryRow(ctx, ` + SELECT c.contype::text + FROM pg_constraint c + JOIN pg_class t ON t.oid = c.conrelid + WHERE c.conname = 'chk_schema_def_size' + AND t.relname = 'client_metadata_schemas' + `).Scan(&contype) + require.NoError(t, err, "chk_schema_def_size must exist on client_metadata_schemas") + require.Equal(t, "c", contype, "chk_schema_def_size must be a CHECK constraint") + }) + + t.Run("fk_client_cascade", func(t *testing.T) { + // Verify a FK exists from client_metadata_schemas.client_id to clients(clientid). + // The plan writes REFERENCES clients(clientId) but Postgres folds unquoted + // identifiers to lowercase, and migration 00000000000003 created the column + // without quotes, so pg_catalog stores the foreign column as "clientid". + // with confdeltype 'c' (CASCADE). + var ( + confdeltype string + refTable string + localColumn string + foreignCol string + ) + err := pool.QueryRow(ctx, ` + SELECT + c.confdeltype::text, + rt.relname, + la.attname, + fa.attname + FROM pg_constraint c + JOIN pg_class t ON t.oid = c.conrelid + JOIN pg_class rt ON rt.oid = c.confrelid + JOIN pg_attribute la ON la.attrelid = c.conrelid AND la.attnum = c.conkey[1] + JOIN pg_attribute fa ON fa.attrelid = c.confrelid AND fa.attnum = c.confkey[1] + WHERE c.contype = 'f' + AND t.relname = 'client_metadata_schemas' + AND la.attname = 'client_id' + `).Scan(&confdeltype, &refTable, &localColumn, &foreignCol) + require.NoError(t, err, "FK on client_metadata_schemas.client_id must exist") + require.Equal(t, "clients", refTable, "FK must reference clients table") + require.Equal(t, "client_id", localColumn) + require.Equal(t, "clientid", foreignCol, "FK must reference clients(clientid) — Postgres lowercased the unquoted column in migration 3") + require.Equal(t, "c", confdeltype, "FK must be ON DELETE CASCADE") + }) + + t.Run("indexes", func(t *testing.T) { + expected := []string{ + "idx_cms_client_id", + "idx_cms_client_name", + "idx_cms_client_status", + } + rows, err := pool.Query(ctx, ` + SELECT indexname + FROM pg_indexes + WHERE schemaname = 'public' + AND tablename = 'client_metadata_schemas' + `) + require.NoError(t, err) + defer rows.Close() + + found := map[string]bool{} + for rows.Next() { + var name string + require.NoError(t, rows.Scan(&name)) + found[name] = true + } + require.NoError(t, rows.Err()) + for _, name := range expected { + require.Truef(t, found[name], "expected index %q on client_metadata_schemas", name) + } + }) +} + +// queryable is the minimal subset of the pool we need for introspection. +// It matches both *pgxpool.Pool and the serviceconfig Pool wrapper. +type queryable interface { + Query(ctx context.Context, sql string, args ...any) (pgx.Rows, error) +} + +// TestClientMetadataSchemas_UniqueNameVersionRejected proves that the +// uq_client_schema_name_version UNIQUE constraint rejects a second row with +// the same (client_id, name, version) tuple. Behavioral counterpart to the +// shape-level check in TestMigration127_ClientMetadataSchemasShape. +func TestClientMetadataSchemas_UniqueNameVersionRejected(t *testing.T) { + t.Parallel() + if testing.Short() { + t.SkipNow() + } + ctx := t.Context() + + cfg := &serviceconfig.BaseConfig{} + test.CreateDB(t, cfg) + pool := cfg.GetDBPool() + require.NotNil(t, pool) + + // Parent row — FK is ON DELETE CASCADE to clients(clientid). + // The Postgres container is reused across test runs and the per-test + // database is NOT dropped between runs, so a leftover client row from + // a previous failure would collide with a fresh CreateClient. Delete + // first (cascade wipes any orphaned metadata rows) to make the test + // idempotent against rerun. + clientID := "UNIQ_TEST" + _, err := pool.Exec(ctx, `DELETE FROM clients WHERE clientId = $1`, clientID) + require.NoError(t, err) + err = cfg.GetDBQueries().CreateClient(ctx, &repository.CreateClientParams{ + Name: "unique-version-test", + Clientid: clientID, + }) + require.NoError(t, err) + + const insertSQL = ` + INSERT INTO client_metadata_schemas + (client_id, name, schema_def, version, created_by) + VALUES ($1, $2, $3::jsonb, $4, $5) + ` + trivialSchema := `{"type":"object","additionalProperties":false}` + + _, err = pool.Exec(ctx, insertSQL, clientID, "invoice", trivialSchema, 1, "tester") + require.NoError(t, err, "first insert must succeed") + + _, err = pool.Exec(ctx, insertSQL, clientID, "invoice", trivialSchema, 1, "tester") + require.Error(t, err, "second insert with same (client_id,name,version) must fail") + + var pgErr *pgconn.PgError + require.True(t, errors.As(err, &pgErr), + "expected *pgconn.PgError, got %T: %v", err, err) + require.Equal(t, "23505", pgErr.Code, + "expected unique_violation SQLSTATE 23505") + require.Equal(t, "uq_client_schema_name_version", pgErr.ConstraintName, + "expected the duplicate to trip uq_client_schema_name_version") +} + +// TestClientMetadataSchemas_CheckSchemaDefSizeRejected proves that the +// chk_schema_def_size CHECK constraint rejects a JSONB schema_def whose +// pg_column_size exceeds the 70 000-byte budget, and conversely accepts +// a comfortably small one. +func TestClientMetadataSchemas_CheckSchemaDefSizeRejected(t *testing.T) { + t.Parallel() + if testing.Short() { + t.SkipNow() + } + ctx := t.Context() + + cfg := &serviceconfig.BaseConfig{} + test.CreateDB(t, cfg) + pool := cfg.GetDBPool() + require.NotNil(t, pool) + + // See the note in TestClientMetadataSchemas_UniqueNameVersionRejected: + // the test database persists across reruns of the same test name, so + // any leftover parent row must be removed before CreateClient. FK + // ON DELETE CASCADE takes care of any orphaned client_metadata_schemas. + clientID := "SIZE_TEST" + _, err := pool.Exec(ctx, `DELETE FROM clients WHERE clientId = $1`, clientID) + require.NoError(t, err) + err = cfg.GetDBQueries().CreateClient(ctx, &repository.CreateClientParams{ + Name: "check-size-test", + Clientid: clientID, + }) + require.NoError(t, err) + + const insertSQL = ` + INSERT INTO client_metadata_schemas + (client_id, name, schema_def, version, created_by) + VALUES ($1, $2, $3::jsonb, $4, $5) + ` + + // Build an oversize schema_def. JSONB stores a binary representation + // that can be smaller than the raw JSON text (and is further subject + // to PGLZ compression once the datum crosses the TOAST threshold), so + // we need enough high-entropy content that the on-row pg_column_size + // comfortably exceeds 70 000. Each property produces a unique key name + // and a unique description string to defeat compression; 2500 such + // properties yields well over 200 KB of raw JSON. + const propCount = 2500 + var b strings.Builder + b.WriteString(`{"type":"object","additionalProperties":false,"properties":{`) + for i := 0; i < propCount; i++ { + if i > 0 { + b.WriteByte(',') + } + fmt.Fprintf(&b, + `"field_with_reasonably_long_name_%06d":{"type":"string","description":"unique-description-for-property-index-%06d-padding-%06d"}`, + i, i, i) + } + b.WriteString("}}") + oversizeSchema := b.String() + + // Sanity-check that what we're sending is actually large. This is a + // safety net for the test itself — if this ever drops below the CHECK + // budget the INSERT-should-fail assertion would become a false pass. + require.Greater(t, len(oversizeSchema), 200000, + "constructed schema JSON text is smaller than expected; test is no longer exercising the CHECK") + + _, err = pool.Exec(ctx, insertSQL, clientID, "big", oversizeSchema, 1, "tester") + require.Error(t, err, "oversize schema_def must be rejected by chk_schema_def_size") + + var pgErr *pgconn.PgError + require.True(t, errors.As(err, &pgErr), + "expected *pgconn.PgError, got %T: %v", err, err) + require.Equal(t, "23514", pgErr.Code, + "expected check_violation SQLSTATE 23514") + require.Equal(t, "chk_schema_def_size", pgErr.ConstraintName, + "expected chk_schema_def_size to be the tripped constraint") + + t.Run("small_schema_accepted", func(t *testing.T) { + // Control: a trivially small schema must not be over-rejected. Uses + // a distinct (name, version) to avoid tripping the uniqueness test. + trivialSchema := `{"type":"object","additionalProperties":false}` + _, err := pool.Exec(ctx, insertSQL, clientID, "small", trivialSchema, 1, "tester") + require.NoError(t, err, "small schema must be accepted by chk_schema_def_size") + }) +} + +// TestMigration127_UpDownRoundtrip proves the migration 127 down.sql is +// complete: running it against a fully-migrated DB tears the table and the +// enum out cleanly, and re-running the up.sql re-creates them. This guards +// against incomplete down migrations (forgetting to drop the enum, an +// index, etc.) that would otherwise only bite on a real rollback. +// +// Safe to run in parallel with the other tests because test.CreateDB +// provisions a per-test database (via GetAlias) even though the Postgres +// container itself is reused across the suite. +func TestMigration127_UpDownRoundtrip(t *testing.T) { + t.Parallel() + if testing.Short() { + t.SkipNow() + } + ctx := t.Context() + + cfg := &serviceconfig.BaseConfig{} + test.CreateDB(t, cfg) + pool := cfg.GetDBPool() + require.NotNil(t, pool) + + // Read the real migration files from disk. Tests run with cwd set to + // the package directory, so this is the canonical relative path. + // + // Migration 128 adds `documents.custom_schema_id` and a composite FK + // that REFERENCES client_metadata_schemas(id, client_id). That FK + // blocks a naked DROP of client_metadata_schemas — so this roundtrip + // test must tear down in reverse migration order (130 down, 129 down, + // 128 down, then 127 down) and re-apply in forward order (127 up, + // 128 up, 129 up, 130 up) to prove the round trip works in the real + // migration ordering the production golang-migrate tool uses. + // + // Migrations 129 and 130 must be reversed before 128 because 130's + // trigger (trg_prevent_schema_reassignment) references custom_schema_id, + // which is defined in 128. Postgres refuses to drop the column while the + // trigger still exists. + const ( + up127Path = "../migrations/00000000000127_create_client_metadata_schemas.up.sql" + down127Path = "../migrations/00000000000127_create_client_metadata_schemas.down.sql" + up128Path = "../migrations/00000000000128_add_custom_schema_id_to_documents.up.sql" + down128Path = "../migrations/00000000000128_add_custom_schema_id_to_documents.down.sql" + up129Path = "../migrations/00000000000129_create_document_custom_metadata.up.sql" + down129Path = "../migrations/00000000000129_create_document_custom_metadata.down.sql" + up130Path = "../migrations/00000000000130_add_schema_invariant_triggers.up.sql" + down130Path = "../migrations/00000000000130_add_schema_invariant_triggers.down.sql" + ) + upSQL, err := os.ReadFile(up127Path) + require.NoError(t, err, "must be able to read migration 127 up.sql") + downSQL, err := os.ReadFile(down127Path) + require.NoError(t, err, "must be able to read migration 127 down.sql") + up128SQL, err := os.ReadFile(up128Path) + require.NoError(t, err, "must be able to read migration 128 up.sql") + down128SQL, err := os.ReadFile(down128Path) + require.NoError(t, err, "must be able to read migration 128 down.sql") + up129SQL, err := os.ReadFile(up129Path) + require.NoError(t, err, "must be able to read migration 129 up.sql") + down129SQL, err := os.ReadFile(down129Path) + require.NoError(t, err, "must be able to read migration 129 down.sql") + up130SQL, err := os.ReadFile(up130Path) + require.NoError(t, err, "must be able to read migration 130 up.sql") + down130SQL, err := os.ReadFile(down130Path) + require.NoError(t, err, "must be able to read migration 130 down.sql") + + // If any assertion below fails, make a best-effort attempt to leave the + // DB in the migrated state so the shared container stays healthy. We + // re-apply in forward order (127 up, 128 up, 129 up, 130 up). + // context.Background() is intentional — in Go 1.24 t.Context() is + // canceled before t.Cleanup callbacks run, which would turn this + // best-effort restore into a no-op. + t.Cleanup(func() { + _, _ = pool.Exec(context.Background(), string(upSQL)) //nolint:usetesting // see comment above + _, _ = pool.Exec(context.Background(), string(up128SQL)) //nolint:usetesting // see comment above + _, _ = pool.Exec(context.Background(), string(up129SQL)) //nolint:usetesting // see comment above + _, _ = pool.Exec(context.Background(), string(up130SQL)) //nolint:usetesting // see comment above + }) + + // 1. Down in reverse migration order: 130, 129, 128, 127. + _, err = pool.Exec(ctx, string(down130SQL)) + require.NoError(t, err, "down migration 130 must apply cleanly before 128 can drop custom_schema_id") + _, err = pool.Exec(ctx, string(down129SQL)) + require.NoError(t, err, "down migration 129 must apply cleanly before 128 can drop custom_schema_id") + _, err = pool.Exec(ctx, string(down128SQL)) + require.NoError(t, err, "down migration 128 must apply cleanly before 127 can drop its table") + _, err = pool.Exec(ctx, string(downSQL)) + require.NoError(t, err, "down migration 127 must apply cleanly") + + var enumExists bool + err = pool.QueryRow(ctx, ` + SELECT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'schema_status_type') + `).Scan(&enumExists) + require.NoError(t, err) + require.False(t, enumExists, "schema_status_type enum must be gone after down") + + var tableOID *string + err = pool.QueryRow(ctx, `SELECT to_regclass('client_metadata_schemas')::text`).Scan(&tableOID) + require.NoError(t, err) + require.Nil(t, tableOID, "client_metadata_schemas table must be gone after down") + + // 2. Back up — forward migration order: 127, 128, 129, 130. + _, err = pool.Exec(ctx, string(upSQL)) + require.NoError(t, err, "up migration 127 must re-apply cleanly") + _, err = pool.Exec(ctx, string(up128SQL)) + require.NoError(t, err, "up migration 128 must re-apply cleanly after 127 is back") + _, err = pool.Exec(ctx, string(up129SQL)) + require.NoError(t, err, "up migration 129 must re-apply cleanly after 128 is back") + _, err = pool.Exec(ctx, string(up130SQL)) + require.NoError(t, err, "up migration 130 must re-apply cleanly after 129 is back") + + err = pool.QueryRow(ctx, ` + SELECT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'schema_status_type') + `).Scan(&enumExists) + require.NoError(t, err) + require.True(t, enumExists, "schema_status_type enum must exist again after up") + + err = pool.QueryRow(ctx, `SELECT to_regclass('client_metadata_schemas')::text`).Scan(&tableOID) + require.NoError(t, err) + require.NotNil(t, tableOID, "client_metadata_schemas must exist again after up") + require.Equal(t, "client_metadata_schemas", *tableOID) + + // Compact index check — the three indexes declared in up.sql. + rows, err := pool.Query(ctx, ` + SELECT indexname + FROM pg_indexes + WHERE schemaname = 'public' + AND tablename = 'client_metadata_schemas' + `) + require.NoError(t, err) + defer rows.Close() + found := map[string]bool{} + for rows.Next() { + var name string + require.NoError(t, rows.Scan(&name)) + found[name] = true + } + require.NoError(t, rows.Err()) + for _, name := range []string{ + "idx_cms_client_id", + "idx_cms_client_name", + "idx_cms_client_status", + } { + require.Truef(t, found[name], "expected index %q after re-running up", name) + } +} + +// fetchConstraintColumns returns the ordered list of column names that back +// the named constraint on client_metadata_schemas, verifying contype matches +// (e.g. "p" for PRIMARY KEY, "u" for UNIQUE). +func fetchConstraintColumns(ctx context.Context, q queryable, conname, wantContype string) ([]string, error) { + rows, err := q.Query(ctx, ` + SELECT a.attname + FROM pg_constraint c + JOIN pg_class t ON t.oid = c.conrelid + JOIN unnest(c.conkey) WITH ORDINALITY AS k(attnum, ord) ON TRUE + JOIN pg_attribute a ON a.attrelid = c.conrelid AND a.attnum = k.attnum + WHERE c.conname = $1 + AND t.relname = 'client_metadata_schemas' + AND c.contype = $2 + ORDER BY k.ord + `, conname, wantContype) + if err != nil { + return nil, err + } + defer rows.Close() + + var cols []string + for rows.Next() { + var name string + if err := rows.Scan(&name); err != nil { + return nil, err + } + cols = append(cols, name) + } + return cols, rows.Err() +} + +// TestMigration128_DocumentsCustomSchemaIdShape asserts the structural shape +// defined by plan Section 4.4 (documents.custom_schema_id column + composite +// FK fk_documents_custom_schema_same_client + idx_documents_custom_schema_id) +// of plans/mutable.metadata.plan.combo.v4.md. +// +// This test is intentionally pure introspection — no behavioral writes, just +// information_schema / pg_catalog / pg_indexes checks. Its purpose is to fail +// loudly until migration 128 is in place. +func TestMigration128_DocumentsCustomSchemaIdShape(t *testing.T) { + t.Parallel() + if testing.Short() { + t.SkipNow() + } + ctx := t.Context() + + cfg := &serviceconfig.BaseConfig{} + test.CreateDB(t, cfg) + pool := cfg.GetDBPool() + require.NotNil(t, pool) + + t.Run("column_exists_nullable", func(t *testing.T) { + var dataType, isNullable string + err := pool.QueryRow(ctx, ` + SELECT data_type, is_nullable + FROM information_schema.columns + WHERE table_schema = 'public' + AND table_name = 'documents' + AND column_name = 'custom_schema_id' + `).Scan(&dataType, &isNullable) + require.NoError(t, err, "documents.custom_schema_id column must exist") + require.Equal(t, "uuid", dataType, "custom_schema_id must be uuid") + require.Equal(t, "YES", isNullable, "custom_schema_id must be nullable") + }) + + t.Run("composite_fk_exists", func(t *testing.T) { + // Pull the composite FK in one shot: local columns (in conkey order) + // and referenced columns (in confkey order), plus the referenced + // relation name. Any introspection of documents.clientId must expect + // lowercase "clientid" because migration 5 declared the column + // unquoted, and Postgres folds unquoted identifiers to lowercase. + type fkShape struct { + relName string + refRelName string + localCols []string + foreignCols []string + } + var shape fkShape + + // Local table and referenced table. + err := pool.QueryRow(ctx, ` + SELECT t.relname, rt.relname + FROM pg_constraint c + JOIN pg_class t ON t.oid = c.conrelid + JOIN pg_class rt ON rt.oid = c.confrelid + WHERE c.conname = 'fk_documents_custom_schema_same_client' + AND c.contype = 'f' + `).Scan(&shape.relName, &shape.refRelName) + require.NoError(t, err, "fk_documents_custom_schema_same_client must exist as a FOREIGN KEY") + require.Equal(t, "documents", shape.relName, "FK must live on documents") + require.Equal(t, "client_metadata_schemas", shape.refRelName, "FK must reference client_metadata_schemas") + + // Local columns in conkey order. + localRows, err := pool.Query(ctx, ` + SELECT a.attname + FROM pg_constraint c + JOIN unnest(c.conkey) WITH ORDINALITY AS k(attnum, ord) ON TRUE + JOIN pg_attribute a ON a.attrelid = c.conrelid AND a.attnum = k.attnum + WHERE c.conname = 'fk_documents_custom_schema_same_client' + ORDER BY k.ord + `) + require.NoError(t, err) + defer localRows.Close() + for localRows.Next() { + var name string + require.NoError(t, localRows.Scan(&name)) + shape.localCols = append(shape.localCols, name) + } + require.NoError(t, localRows.Err()) + // clientId in migration 5 is unquoted, so pg_attribute stores "clientid". + require.Equal(t, []string{"custom_schema_id", "clientid"}, shape.localCols, + "FK local columns must be (custom_schema_id, clientid) in that order") + + // Referenced columns in confkey order. + foreignRows, err := pool.Query(ctx, ` + SELECT a.attname + FROM pg_constraint c + JOIN unnest(c.confkey) WITH ORDINALITY AS k(attnum, ord) ON TRUE + JOIN pg_attribute a ON a.attrelid = c.confrelid AND a.attnum = k.attnum + WHERE c.conname = 'fk_documents_custom_schema_same_client' + ORDER BY k.ord + `) + require.NoError(t, err) + defer foreignRows.Close() + for foreignRows.Next() { + var name string + require.NoError(t, foreignRows.Scan(&name)) + shape.foreignCols = append(shape.foreignCols, name) + } + require.NoError(t, foreignRows.Err()) + require.Equal(t, []string{"id", "client_id"}, shape.foreignCols, + "FK must reference client_metadata_schemas(id, client_id) in that order") + }) + + t.Run("index_exists", func(t *testing.T) { + var indexName string + err := pool.QueryRow(ctx, ` + SELECT indexname + FROM pg_indexes + WHERE schemaname = 'public' + AND tablename = 'documents' + AND indexname = 'idx_documents_custom_schema_id' + `).Scan(&indexName) + require.NoError(t, err, "idx_documents_custom_schema_id must exist on documents") + require.Equal(t, "idx_documents_custom_schema_id", indexName) + }) + + t.Run("existing_documents_null", func(t *testing.T) { + // On a freshly migrated DB the migration must leave existing rows + // with custom_schema_id = NULL. With no fixture data this is + // trivially zero, but asserting it guards against a migration that + // accidentally populates the column with a default. + var count int64 + err := pool.QueryRow(ctx, ` + SELECT COUNT(*) FROM documents WHERE custom_schema_id IS NOT NULL + `).Scan(&count) + require.NoError(t, err) + require.Equal(t, int64(0), count, + "freshly migrated documents table must have zero non-NULL custom_schema_id") + }) +} + +// TestMigration128_CrossClientRejected is the headline behavioral test for +// the composite FK fk_documents_custom_schema_same_client. It inserts two +// clients, a schema owned by client A, and a document owned by client B, +// then attempts to bind the document to the schema. The composite FK must +// reject the UPDATE with SQLSTATE 23503 (foreign_key_violation) naming +// fk_documents_custom_schema_same_client. A same-client subtest acts as the +// positive control. +func TestMigration128_CrossClientRejected(t *testing.T) { + t.Parallel() + if testing.Short() { + t.SkipNow() + } + ctx := t.Context() + + cfg := &serviceconfig.BaseConfig{} + test.CreateDB(t, cfg) + pool := cfg.GetDBPool() + require.NotNil(t, pool) + queries := cfg.GetDBQueries() + require.NotNil(t, queries) + + // The Postgres container and per-test database are reused across runs, + // so leftover rows from a previous failure would collide with a fresh + // CreateClient. Delete first — FK ON DELETE CASCADE wipes any orphaned + // client_metadata_schemas and documents owned by these clients. + clientIDA := "MIG128_A" + clientIDB := "MIG128_B" + _, err := pool.Exec(ctx, `DELETE FROM clients WHERE clientId IN ($1, $2)`, clientIDA, clientIDB) + require.NoError(t, err) + + err = queries.CreateClient(ctx, &repository.CreateClientParams{ + Name: "mig128-client-a", + Clientid: clientIDA, + }) + require.NoError(t, err, "CreateClient(A) must succeed") + err = queries.CreateClient(ctx, &repository.CreateClientParams{ + Name: "mig128-client-b", + Clientid: clientIDB, + }) + require.NoError(t, err, "CreateClient(B) must succeed") + + // Insert a schema owned by client A via raw SQL. The sqlc query file + // for client_metadata_schemas does not exist yet (§1.4), so raw INSERT + // RETURNING is the only path right now. + const insertSchemaSQL = ` + INSERT INTO client_metadata_schemas + (client_id, name, schema_def, version, created_by) + VALUES ($1, $2, $3::jsonb, $4, $5) + RETURNING id + ` + trivialSchema := `{"type":"object","additionalProperties":false}` + + var schemaIDA string + err = pool.QueryRow(ctx, insertSchemaSQL, clientIDA, "invoice", trivialSchema, 1, "tester").Scan(&schemaIDA) + require.NoError(t, err, "inserting schema owned by client A must succeed") + require.NotEmpty(t, schemaIDA) + + // Insert a document owned by client B via the sqlc-generated + // CreateDocument. Minimum required fields mirror document_test.go: + // Clientid, Hash, BatchID=nil. All other columns (batch_id, filename, + // folderId, originalPath, file_size_bytes) are nullable. + docID, err := queries.CreateDocument(ctx, &repository.CreateDocumentParams{ + Clientid: clientIDB, + Hash: "mig128_cross_client_hash", + BatchID: nil, + }) + require.NoError(t, err, "CreateDocument for client B must succeed") + require.NotEmpty(t, docID) + + // Cross-client attempt: bind client B's document to client A's schema. + // The composite FK must trip with SQLSTATE 23503 naming the constraint. + _, err = pool.Exec(ctx, + `UPDATE documents SET custom_schema_id = $1 WHERE id = $2`, + schemaIDA, docID) + require.Error(t, err, "cross-client UPDATE must be rejected by fk_documents_custom_schema_same_client") + + var pgErr *pgconn.PgError + require.True(t, errors.As(err, &pgErr), + "expected *pgconn.PgError, got %T: %v", err, err) + require.Equal(t, "23503", pgErr.Code, + "expected foreign_key_violation SQLSTATE 23503, got %q: %v", pgErr.Code, pgErr.Message) + require.Equal(t, "fk_documents_custom_schema_same_client", pgErr.ConstraintName, + "expected fk_documents_custom_schema_same_client to be the tripped constraint") + + t.Run("same_client_accepted", func(t *testing.T) { + // Positive control: insert a second schema owned by client B and + // bind client B's document to it. Same-client binding must succeed. + var schemaIDB string + err := pool.QueryRow(ctx, insertSchemaSQL, clientIDB, "invoice", trivialSchema, 1, "tester").Scan(&schemaIDB) + require.NoError(t, err, "inserting schema owned by client B must succeed") + require.NotEmpty(t, schemaIDB) + + _, err = pool.Exec(ctx, + `UPDATE documents SET custom_schema_id = $1 WHERE id = $2`, + schemaIDB, docID) + require.NoError(t, err, "same-client UPDATE must be accepted by fk_documents_custom_schema_same_client") + }) +} + +// TestMigration128_UpDownRoundtrip proves the migration 128 down.sql is +// complete: running it against a fully-migrated DB drops the column, the +// composite FK, and the index cleanly, and re-running the up.sql re-creates +// them. Mirrors TestMigration127_UpDownRoundtrip. +func TestMigration128_UpDownRoundtrip(t *testing.T) { + t.Parallel() + if testing.Short() { + t.SkipNow() + } + ctx := t.Context() + + cfg := &serviceconfig.BaseConfig{} + test.CreateDB(t, cfg) + pool := cfg.GetDBPool() + require.NotNil(t, pool) + + // Canonical filenames per tracking doc §1.3. + // Migrations 129 and 130 must be reversed before 128 because 130's + // trigger (trg_prevent_schema_reassignment) references custom_schema_id, + // which is defined in 128. Postgres refuses to drop the column while the + // trigger still exists. + const ( + upPath = "../migrations/00000000000128_add_custom_schema_id_to_documents.up.sql" + downPath = "../migrations/00000000000128_add_custom_schema_id_to_documents.down.sql" + up129Path = "../migrations/00000000000129_create_document_custom_metadata.up.sql" + dn129Path = "../migrations/00000000000129_create_document_custom_metadata.down.sql" + up130Path = "../migrations/00000000000130_add_schema_invariant_triggers.up.sql" + dn130Path = "../migrations/00000000000130_add_schema_invariant_triggers.down.sql" + ) + upSQL, err := os.ReadFile(upPath) + require.NoError(t, err, "must be able to read migration 128 up.sql") + downSQL, err := os.ReadFile(downPath) + require.NoError(t, err, "must be able to read migration 128 down.sql") + up129SQL, err := os.ReadFile(up129Path) + require.NoError(t, err, "must be able to read migration 129 up.sql") + dn129SQL, err := os.ReadFile(dn129Path) + require.NoError(t, err, "must be able to read migration 129 down.sql") + up130SQL, err := os.ReadFile(up130Path) + require.NoError(t, err, "must be able to read migration 130 up.sql") + dn130SQL, err := os.ReadFile(dn130Path) + require.NoError(t, err, "must be able to read migration 130 down.sql") + + // Best-effort: if any assertion below fails mid-test, reapply the ups + // so the shared container is left in a migrated state. context.Background() + // is intentional — in Go 1.24 t.Context() is canceled before t.Cleanup + // callbacks run, which would turn this best-effort restore into a no-op. + t.Cleanup(func() { + _, _ = pool.Exec(context.Background(), string(upSQL)) //nolint:usetesting // see comment above + _, _ = pool.Exec(context.Background(), string(up129SQL)) //nolint:usetesting // see comment above + _, _ = pool.Exec(context.Background(), string(up130SQL)) //nolint:usetesting // see comment above + }) + + // 1. Down in reverse migration order: 130, 129, 128. + _, err = pool.Exec(ctx, string(dn130SQL)) + require.NoError(t, err, "down migration 130 must apply cleanly before 128 can drop custom_schema_id") + _, err = pool.Exec(ctx, string(dn129SQL)) + require.NoError(t, err, "down migration 129 must apply cleanly before 128 can drop custom_schema_id") + _, err = pool.Exec(ctx, string(downSQL)) + require.NoError(t, err, "down migration 128 must apply cleanly") + + var colExists bool + err = pool.QueryRow(ctx, ` + SELECT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_schema = 'public' + AND table_name = 'documents' + AND column_name = 'custom_schema_id' + ) + `).Scan(&colExists) + require.NoError(t, err) + require.False(t, colExists, "custom_schema_id column must be gone after down") + + var fkExists bool + err = pool.QueryRow(ctx, ` + SELECT EXISTS ( + SELECT 1 FROM pg_constraint + WHERE conname = 'fk_documents_custom_schema_same_client' + ) + `).Scan(&fkExists) + require.NoError(t, err) + require.False(t, fkExists, "fk_documents_custom_schema_same_client must be gone after down") + + var indexExists bool + err = pool.QueryRow(ctx, ` + SELECT EXISTS ( + SELECT 1 FROM pg_indexes + WHERE schemaname = 'public' + AND tablename = 'documents' + AND indexname = 'idx_documents_custom_schema_id' + ) + `).Scan(&indexExists) + require.NoError(t, err) + require.False(t, indexExists, "idx_documents_custom_schema_id must be gone after down") + + // 2. Back up in forward migration order: 128, 129, 130. + _, err = pool.Exec(ctx, string(upSQL)) + require.NoError(t, err, "up migration 128 must re-apply cleanly") + _, err = pool.Exec(ctx, string(up129SQL)) + require.NoError(t, err, "up migration 129 must re-apply cleanly after 128 is back") + _, err = pool.Exec(ctx, string(up130SQL)) + require.NoError(t, err, "up migration 130 must re-apply cleanly after 129 is back") + + err = pool.QueryRow(ctx, ` + SELECT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_schema = 'public' + AND table_name = 'documents' + AND column_name = 'custom_schema_id' + ) + `).Scan(&colExists) + require.NoError(t, err) + require.True(t, colExists, "custom_schema_id column must exist again after up") + + err = pool.QueryRow(ctx, ` + SELECT EXISTS ( + SELECT 1 FROM pg_constraint + WHERE conname = 'fk_documents_custom_schema_same_client' + ) + `).Scan(&fkExists) + require.NoError(t, err) + require.True(t, fkExists, "fk_documents_custom_schema_same_client must exist again after up") + + err = pool.QueryRow(ctx, ` + SELECT EXISTS ( + SELECT 1 FROM pg_indexes + WHERE schemaname = 'public' + AND tablename = 'documents' + AND indexname = 'idx_documents_custom_schema_id' + ) + `).Scan(&indexExists) + require.NoError(t, err) + require.True(t, indexExists, "idx_documents_custom_schema_id must exist again after up") +} diff --git a/internal/database/repository/custommetadata.sql.go b/internal/database/repository/custommetadata.sql.go new file mode 100644 index 00000000..b8f2290b --- /dev/null +++ b/internal/database/repository/custommetadata.sql.go @@ -0,0 +1,523 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.27.0 +// source: custommetadata.sql + +package repository + +import ( + "context" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5/pgtype" +) + +const countDocumentCustomMetadataVersions = `-- name: CountDocumentCustomMetadataVersions :one +SELECT COUNT(*)::int AS version_count +FROM document_custom_metadata +WHERE document_id = $1 +` + +// Return the number of metadata version rows for a document. Used by +// ResetDocumentMetadata to populate metadataVersionsDeleted on its +// response. Run under the parent-row lock from LockDocumentForReset so +// the count is authoritative at commit time. +// +// SELECT COUNT(*)::int AS version_count +// FROM document_custom_metadata +// WHERE document_id = $1 +func (q *Queries) CountDocumentCustomMetadataVersions(ctx context.Context, documentID uuid.UUID) (int32, error) { + row := q.db.QueryRow(ctx, countDocumentCustomMetadataVersions, documentID) + var version_count int32 + err := row.Scan(&version_count) + return version_count, err +} + +const createDocumentCustomMetadata = `-- name: CreateDocumentCustomMetadata :one + +INSERT INTO document_custom_metadata ( + document_id, + metadata, + version, + created_by +) VALUES ( + $1, + $2, + $3, + $4 +) +RETURNING id, document_id, metadata, version, created_at, created_by +` + +type CreateDocumentCustomMetadataParams struct { + DocumentID uuid.UUID `db:"document_id"` + Metadata []byte `db:"metadata"` + Version int32 `db:"version"` + CreatedBy string `db:"created_by"` +} + +// Mutable metadata feature, milestone 2.3: document_custom_metadata CRUD +// queries plus document custom_schema_id accessors. +// +// See plan Section 8.2 / tracking §2.3 of +// plans/mutable.metadata.plan.combo.v4.md for the canonical list. +// +// v4 notes: +// - No schema_id parameter on the metadata insert. The schema binding +// lives on the document row; metadata rows don't duplicate it. +// - GetCurrentDocumentCustomMetadata and GetDocumentCustomMetadataByVersion +// join documents + client_metadata_schemas so the handler can populate +// schemaId/schemaName/schemaVersion in one round-trip. +// - LockDocumentForMetadataWrite takes a parent-row lock on documents. +// Replaces v3's LockDocumentCustomMetadataForVersion. +// - DeleteDocumentCustomMetadata / NullifyDocumentCustomSchemaId are +// intentionally absent: FK cascades handle document deletion and the +// reset-metadata workstream writes a direct UPDATE instead. +// +// Insert a new metadata version for a document. Caller supplies an +// explicit version computed from GetMaxDocumentMetadataVersion + 1 under +// the parent-row lock from LockDocumentForMetadataWrite. No schema_id +// column exists on this table in v4. +// +// INSERT INTO document_custom_metadata ( +// document_id, +// metadata, +// version, +// created_by +// ) VALUES ( +// $1, +// $2, +// $3, +// $4 +// ) +// RETURNING id, document_id, metadata, version, created_at, created_by +func (q *Queries) CreateDocumentCustomMetadata(ctx context.Context, arg *CreateDocumentCustomMetadataParams) (*DocumentCustomMetadatum, error) { + row := q.db.QueryRow(ctx, createDocumentCustomMetadata, + arg.DocumentID, + arg.Metadata, + arg.Version, + arg.CreatedBy, + ) + var i DocumentCustomMetadatum + err := row.Scan( + &i.ID, + &i.DocumentID, + &i.Metadata, + &i.Version, + &i.CreatedAt, + &i.CreatedBy, + ) + return &i, err +} + +const deleteDocumentCustomMetadataForReset = `-- name: DeleteDocumentCustomMetadataForReset :exec +DELETE FROM document_custom_metadata WHERE document_id = $1 +` + +// @sqlc-vet-disable +// Delete every metadata version row for a document. Owned by the +// reset-metadata path — the Delete cascade for document deletion is +// handled by ON DELETE CASCADE on document_custom_metadata.document_id, +// not by this query. Name disambiguates from any future cascade helper. +// +// DELETE FROM document_custom_metadata WHERE document_id = $1 +func (q *Queries) DeleteDocumentCustomMetadataForReset(ctx context.Context, documentID uuid.UUID) error { + _, err := q.db.Exec(ctx, deleteDocumentCustomMetadataForReset, documentID) + return err +} + +const getCurrentDocumentCustomMetadata = `-- name: GetCurrentDocumentCustomMetadata :one +SELECT + dcm.id, + dcm.document_id, + dcm.metadata, + dcm.version, + dcm.created_at, + dcm.created_by, + cms.id AS schema_id, + cms.name AS schema_name, + cms.version AS schema_version +FROM document_custom_metadata dcm +JOIN documents d ON d.id = dcm.document_id +LEFT JOIN client_metadata_schemas cms ON cms.id = d.custom_schema_id +WHERE dcm.document_id = $1 +ORDER BY dcm.version DESC +LIMIT 1 +` + +type GetCurrentDocumentCustomMetadataRow struct { + ID uuid.UUID `db:"id"` + DocumentID uuid.UUID `db:"document_id"` + Metadata []byte `db:"metadata"` + Version int32 `db:"version"` + CreatedAt pgtype.Timestamptz `db:"created_at"` + CreatedBy string `db:"created_by"` + SchemaID *uuid.UUID `db:"schema_id"` + SchemaName *string `db:"schema_name"` + SchemaVersion *int32 `db:"schema_version"` +} + +// Return the most-recent metadata row for a document, decorated with the +// current schema name and version pulled from the bound schema row. +// Joins documents (for the binding) and client_metadata_schemas (for +// name/version). idx_dcm_doc_version_desc backs the ORDER BY ... LIMIT 1. +// +// SELECT +// dcm.id, +// dcm.document_id, +// dcm.metadata, +// dcm.version, +// dcm.created_at, +// dcm.created_by, +// cms.id AS schema_id, +// cms.name AS schema_name, +// cms.version AS schema_version +// FROM document_custom_metadata dcm +// JOIN documents d ON d.id = dcm.document_id +// LEFT JOIN client_metadata_schemas cms ON cms.id = d.custom_schema_id +// WHERE dcm.document_id = $1 +// ORDER BY dcm.version DESC +// LIMIT 1 +func (q *Queries) GetCurrentDocumentCustomMetadata(ctx context.Context, documentID uuid.UUID) (*GetCurrentDocumentCustomMetadataRow, error) { + row := q.db.QueryRow(ctx, getCurrentDocumentCustomMetadata, documentID) + var i GetCurrentDocumentCustomMetadataRow + err := row.Scan( + &i.ID, + &i.DocumentID, + &i.Metadata, + &i.Version, + &i.CreatedAt, + &i.CreatedBy, + &i.SchemaID, + &i.SchemaName, + &i.SchemaVersion, + ) + return &i, err +} + +const getDocumentClientID = `-- name: GetDocumentClientID :one +SELECT clientId FROM documents WHERE id = $1 +` + +// Read a document's clientId for the AssignSchema cross-client check. +// documents.clientId is the unquoted (lowercased) varchar column. +// +// SELECT clientId FROM documents WHERE id = $1 +func (q *Queries) GetDocumentClientID(ctx context.Context, id uuid.UUID) (string, error) { + row := q.db.QueryRow(ctx, getDocumentClientID, id) + var clientid string + err := row.Scan(&clientid) + return clientid, err +} + +const getDocumentCustomMetadataByVersion = `-- name: GetDocumentCustomMetadataByVersion :one +SELECT + dcm.id, + dcm.document_id, + dcm.metadata, + dcm.version, + dcm.created_at, + dcm.created_by, + cms.id AS schema_id, + cms.name AS schema_name, + cms.version AS schema_version +FROM document_custom_metadata dcm +JOIN documents d ON d.id = dcm.document_id +LEFT JOIN client_metadata_schemas cms ON cms.id = d.custom_schema_id +WHERE dcm.document_id = $1 + AND dcm.version = $2 +` + +type GetDocumentCustomMetadataByVersionParams struct { + DocumentID uuid.UUID `db:"document_id"` + Version int32 `db:"version"` +} + +type GetDocumentCustomMetadataByVersionRow struct { + ID uuid.UUID `db:"id"` + DocumentID uuid.UUID `db:"document_id"` + Metadata []byte `db:"metadata"` + Version int32 `db:"version"` + CreatedAt pgtype.Timestamptz `db:"created_at"` + CreatedBy string `db:"created_by"` + SchemaID *uuid.UUID `db:"schema_id"` + SchemaName *string `db:"schema_name"` + SchemaVersion *int32 `db:"schema_version"` +} + +// Return a specific historical version with the same schema decoration +// as the current-version query above. +// +// SELECT +// dcm.id, +// dcm.document_id, +// dcm.metadata, +// dcm.version, +// dcm.created_at, +// dcm.created_by, +// cms.id AS schema_id, +// cms.name AS schema_name, +// cms.version AS schema_version +// FROM document_custom_metadata dcm +// JOIN documents d ON d.id = dcm.document_id +// LEFT JOIN client_metadata_schemas cms ON cms.id = d.custom_schema_id +// WHERE dcm.document_id = $1 +// AND dcm.version = $2 +func (q *Queries) GetDocumentCustomMetadataByVersion(ctx context.Context, arg *GetDocumentCustomMetadataByVersionParams) (*GetDocumentCustomMetadataByVersionRow, error) { + row := q.db.QueryRow(ctx, getDocumentCustomMetadataByVersion, arg.DocumentID, arg.Version) + var i GetDocumentCustomMetadataByVersionRow + err := row.Scan( + &i.ID, + &i.DocumentID, + &i.Metadata, + &i.Version, + &i.CreatedAt, + &i.CreatedBy, + &i.SchemaID, + &i.SchemaName, + &i.SchemaVersion, + ) + return &i, err +} + +const getDocumentCustomMetadataHistory = `-- name: GetDocumentCustomMetadataHistory :many +SELECT + version, + created_at, + created_by +FROM document_custom_metadata +WHERE document_id = $1 +ORDER BY version DESC +LIMIT $3 OFFSET $2 +` + +type GetDocumentCustomMetadataHistoryParams struct { + DocumentID uuid.UUID `db:"document_id"` + OffsetVal int64 `db:"offset_val"` + LimitVal int64 `db:"limit_val"` +} + +type GetDocumentCustomMetadataHistoryRow struct { + Version int32 `db:"version"` + CreatedAt pgtype.Timestamptz `db:"created_at"` + CreatedBy string `db:"created_by"` +} + +// Return a paged list of version summaries for a document, newest first. +// No schema join: the history response is a terse list of +// (version, created_at, created_by) tuples. +// +// SELECT +// version, +// created_at, +// created_by +// FROM document_custom_metadata +// WHERE document_id = $1 +// ORDER BY version DESC +// LIMIT $3 OFFSET $2 +func (q *Queries) GetDocumentCustomMetadataHistory(ctx context.Context, arg *GetDocumentCustomMetadataHistoryParams) ([]*GetDocumentCustomMetadataHistoryRow, error) { + rows, err := q.db.Query(ctx, getDocumentCustomMetadataHistory, arg.DocumentID, arg.OffsetVal, arg.LimitVal) + if err != nil { + return nil, err + } + defer rows.Close() + items := []*GetDocumentCustomMetadataHistoryRow{} + for rows.Next() { + var i GetDocumentCustomMetadataHistoryRow + if err := rows.Scan(&i.Version, &i.CreatedAt, &i.CreatedBy); err != nil { + return nil, err + } + items = append(items, &i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const getDocumentCustomSchemaId = `-- name: GetDocumentCustomSchemaId :one +SELECT custom_schema_id +FROM documents +WHERE id = $1 +` + +// Read a document's current schema binding. Returns NULL when unbound. +// +// SELECT custom_schema_id +// FROM documents +// WHERE id = $1 +func (q *Queries) GetDocumentCustomSchemaId(ctx context.Context, id uuid.UUID) (*uuid.UUID, error) { + row := q.db.QueryRow(ctx, getDocumentCustomSchemaId, id) + var custom_schema_id *uuid.UUID + err := row.Scan(&custom_schema_id) + return custom_schema_id, err +} + +const getDocumentSchemaBindingForReset = `-- name: GetDocumentSchemaBindingForReset :one +SELECT + d.custom_schema_id, + cms.name AS schema_name, + cms.version AS schema_version +FROM documents d +LEFT JOIN client_metadata_schemas cms ON cms.id = d.custom_schema_id +WHERE d.id = $1 +` + +type GetDocumentSchemaBindingForResetRow struct { + CustomSchemaID *uuid.UUID `db:"custom_schema_id"` + SchemaName *string `db:"schema_name"` + SchemaVersion *int32 `db:"schema_version"` +} + +// Read the document's current schema binding along with the joined +// schema row's name and version so ResetDocumentMetadata can populate the +// previousSchema* fields on its response. The LEFT JOIN returns NULL for +// name/version when the document has no schema bound — the reset endpoint +// treats that as the idempotent no-op case and still succeeds. +// +// SELECT +// d.custom_schema_id, +// cms.name AS schema_name, +// cms.version AS schema_version +// FROM documents d +// LEFT JOIN client_metadata_schemas cms ON cms.id = d.custom_schema_id +// WHERE d.id = $1 +func (q *Queries) GetDocumentSchemaBindingForReset(ctx context.Context, documentID uuid.UUID) (*GetDocumentSchemaBindingForResetRow, error) { + row := q.db.QueryRow(ctx, getDocumentSchemaBindingForReset, documentID) + var i GetDocumentSchemaBindingForResetRow + err := row.Scan(&i.CustomSchemaID, &i.SchemaName, &i.SchemaVersion) + return &i, err +} + +const getMaxDocumentMetadataVersion = `-- name: GetMaxDocumentMetadataVersion :one +SELECT COALESCE(MAX(version), 0)::int AS max_version +FROM document_custom_metadata +WHERE document_id = $1 +` + +// Return MAX(version) for a document's metadata rows, or 0 when none +// exist. Caller adds 1 to compute the next version to insert. Must run +// inside the transaction that holds the LockDocumentForMetadataWrite +// lock; otherwise two concurrent writers can both see the same max. +// +// SELECT COALESCE(MAX(version), 0)::int AS max_version +// FROM document_custom_metadata +// WHERE document_id = $1 +func (q *Queries) GetMaxDocumentMetadataVersion(ctx context.Context, documentID uuid.UUID) (int32, error) { + row := q.db.QueryRow(ctx, getMaxDocumentMetadataVersion, documentID) + var max_version int32 + err := row.Scan(&max_version) + return max_version, err +} + +const hasDocumentCustomMetadata = `-- name: HasDocumentCustomMetadata :one +SELECT EXISTS( + SELECT 1 FROM document_custom_metadata WHERE document_id = $1 +) AS has_metadata +` + +// EXISTS probe for any metadata row belonging to this document. Used by +// AssignSchema to reject re-binding once metadata has been written. +// +// SELECT EXISTS( +// SELECT 1 FROM document_custom_metadata WHERE document_id = $1 +// ) AS has_metadata +func (q *Queries) HasDocumentCustomMetadata(ctx context.Context, documentID uuid.UUID) (bool, error) { + row := q.db.QueryRow(ctx, hasDocumentCustomMetadata, documentID) + var has_metadata bool + err := row.Scan(&has_metadata) + return has_metadata, err +} + +const lockDocumentForMetadataWrite = `-- name: LockDocumentForMetadataWrite :one +SELECT id FROM documents +WHERE id = $1 +FOR UPDATE +` + +// Parent-row lock for metadata write ordering. Must be called inside a +// transaction as the FIRST DML statement. Serializes with AssignSchema +// (which also takes FOR UPDATE on documents) so the mutual-exclusivity +// invariant in plan Section 7.3 holds. +// +// SELECT id FROM documents +// WHERE id = $1 +// FOR UPDATE +func (q *Queries) LockDocumentForMetadataWrite(ctx context.Context, documentID uuid.UUID) (uuid.UUID, error) { + row := q.db.QueryRow(ctx, lockDocumentForMetadataWrite, documentID) + var id uuid.UUID + err := row.Scan(&id) + return id, err +} + +const lockDocumentForReset = `-- name: LockDocumentForReset :one +SELECT id, clientId, custom_schema_id +FROM documents +WHERE id = $1 +FOR UPDATE +` + +type LockDocumentForResetRow struct { + ID uuid.UUID `db:"id"` + Clientid string `db:"clientid"` + CustomSchemaID *uuid.UUID `db:"custom_schema_id"` +} + +// Milestone 3 parent-row lock acquired by ResetDocumentMetadata as the +// FIRST DML statement inside its transaction. Same FOR UPDATE shape as +// LockDocumentForMetadataWrite / LockDocumentForLegacyExtractionWrite so +// the reset path serializes correctly against concurrent assign and write +// paths for the mutual-exclusivity invariant (plan Section 7.3). +// Returns id, clientid, and current custom_schema_id so the caller can +// short-circuit and/or decorate the result without a second round-trip. +// +// SELECT id, clientId, custom_schema_id +// FROM documents +// WHERE id = $1 +// FOR UPDATE +func (q *Queries) LockDocumentForReset(ctx context.Context, documentID uuid.UUID) (*LockDocumentForResetRow, error) { + row := q.db.QueryRow(ctx, lockDocumentForReset, documentID) + var i LockDocumentForResetRow + err := row.Scan(&i.ID, &i.Clientid, &i.CustomSchemaID) + return &i, err +} + +const nullifyDocumentCustomSchemaIdForReset = `-- name: NullifyDocumentCustomSchemaIdForReset :exec +UPDATE documents SET custom_schema_id = NULL WHERE id = $1 +` + +// Clear the document's schema binding as the final DML of the reset +// transaction. Trigger 1 (trg_prevent_schema_reassignment) fires on this +// UPDATE; the preceding DeleteDocumentCustomMetadataForReset has already +// removed every metadata row and the transaction has already rejected any +// document carrying legacy field extractions, so the trigger's EXISTS +// checks both return false and the UPDATE succeeds. +// +// UPDATE documents SET custom_schema_id = NULL WHERE id = $1 +func (q *Queries) NullifyDocumentCustomSchemaIdForReset(ctx context.Context, documentID uuid.UUID) error { + _, err := q.db.Exec(ctx, nullifyDocumentCustomSchemaIdForReset, documentID) + return err +} + +const setDocumentCustomSchemaId = `-- name: SetDocumentCustomSchemaId :exec +UPDATE documents +SET custom_schema_id = $1 +WHERE id = $2 +` + +type SetDocumentCustomSchemaIdParams struct { + CustomSchemaID *uuid.UUID `db:"custom_schema_id"` + ID uuid.UUID `db:"id"` +} + +// Bind (or clear) a document's schema. Nullable parameter: passing NULL +// clears the binding. The composite FK in migration 128 prevents binding +// a schema owned by a different client, so this query does not re-check. +// +// UPDATE documents +// SET custom_schema_id = $1 +// WHERE id = $2 +func (q *Queries) SetDocumentCustomSchemaId(ctx context.Context, arg *SetDocumentCustomSchemaIdParams) error { + _, err := q.db.Exec(ctx, setDocumentCustomSchemaId, arg.CustomSchemaID, arg.ID) + return err +} diff --git a/internal/database/repository/customschemas.sql.go b/internal/database/repository/customschemas.sql.go new file mode 100644 index 00000000..8cbf1449 --- /dev/null +++ b/internal/database/repository/customschemas.sql.go @@ -0,0 +1,394 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.27.0 +// source: customschemas.sql + +package repository + +import ( + "context" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5/pgtype" +) + +const createClientMetadataSchema = `-- name: CreateClientMetadataSchema :one + +INSERT INTO client_metadata_schemas ( + client_id, + name, + description, + schema_def, + version, + status, + created_by +) VALUES ( + $1, + $2, + $3, + $4, + $5, + $6, + $7 +) +RETURNING id, client_id, name, description, schema_def, version, status, created_at, created_by +` + +type CreateClientMetadataSchemaParams struct { + ClientID string `db:"client_id"` + Name string `db:"name"` + Description *string `db:"description"` + SchemaDef []byte `db:"schema_def"` + Version int32 `db:"version"` + Status SchemaStatusType `db:"status"` + CreatedBy string `db:"created_by"` +} + +// Mutable metadata feature, milestone 1.4: schema CRUD queries for +// client_metadata_schemas. See plan Section 8.2 of +// plans/mutable.metadata.plan.combo.v4.md for the canonical list. +// +// Intentionally omitted in v4: +// - GetSchemaMetadataRecordCount (removed; canDelete derives from +// GetSchemaDocumentCount alone because document_custom_metadata has +// no schema_id column in v4). +// - Metadata, document-modification, and reset-metadata queries +// (Milestones 2 and 3, plan sections 8.2 continued). +// +// Insert a new schema row. Caller supplies an explicit version computed +// from GetMaxSchemaVersion + 1 and an explicit status (always 'active' +// in Milestone 1; supersede/retire go through SetSchemaStatus). The +// database defaults id and created_at. +// +// INSERT INTO client_metadata_schemas ( +// client_id, +// name, +// description, +// schema_def, +// version, +// status, +// created_by +// ) VALUES ( +// $1, +// $2, +// $3, +// $4, +// $5, +// $6, +// $7 +// ) +// RETURNING id, client_id, name, description, schema_def, version, status, created_at, created_by +func (q *Queries) CreateClientMetadataSchema(ctx context.Context, arg *CreateClientMetadataSchemaParams) (*ClientMetadataSchema, error) { + row := q.db.QueryRow(ctx, createClientMetadataSchema, + arg.ClientID, + arg.Name, + arg.Description, + arg.SchemaDef, + arg.Version, + arg.Status, + arg.CreatedBy, + ) + var i ClientMetadataSchema + err := row.Scan( + &i.ID, + &i.ClientID, + &i.Name, + &i.Description, + &i.SchemaDef, + &i.Version, + &i.Status, + &i.CreatedAt, + &i.CreatedBy, + ) + return &i, err +} + +const getClientMetadataSchema = `-- name: GetClientMetadataSchema :one +SELECT id, client_id, name, description, schema_def, version, status, created_at, created_by FROM client_metadata_schemas +WHERE id = $1 +` + +// Fetch a single schema by primary key. +// +// SELECT id, client_id, name, description, schema_def, version, status, created_at, created_by FROM client_metadata_schemas +// WHERE id = $1 +func (q *Queries) GetClientMetadataSchema(ctx context.Context, id uuid.UUID) (*ClientMetadataSchema, error) { + row := q.db.QueryRow(ctx, getClientMetadataSchema, id) + var i ClientMetadataSchema + err := row.Scan( + &i.ID, + &i.ClientID, + &i.Name, + &i.Description, + &i.SchemaDef, + &i.Version, + &i.Status, + &i.CreatedAt, + &i.CreatedBy, + ) + return &i, err +} + +const getLatestSchemaByName = `-- name: GetLatestSchemaByName :one +SELECT id, client_id, name, description, schema_def, version, status, created_at, created_by FROM client_metadata_schemas +WHERE client_id = $1 AND name = $2 +ORDER BY version DESC +LIMIT 1 +` + +type GetLatestSchemaByNameParams struct { + ClientID string `db:"client_id"` + Name string `db:"name"` +} + +// Return the highest-version row for a (client_id, name) pair regardless +// of status. Callers that need an active-only view should filter after. +// +// SELECT id, client_id, name, description, schema_def, version, status, created_at, created_by FROM client_metadata_schemas +// WHERE client_id = $1 AND name = $2 +// ORDER BY version DESC +// LIMIT 1 +func (q *Queries) GetLatestSchemaByName(ctx context.Context, arg *GetLatestSchemaByNameParams) (*ClientMetadataSchema, error) { + row := q.db.QueryRow(ctx, getLatestSchemaByName, arg.ClientID, arg.Name) + var i ClientMetadataSchema + err := row.Scan( + &i.ID, + &i.ClientID, + &i.Name, + &i.Description, + &i.SchemaDef, + &i.Version, + &i.Status, + &i.CreatedAt, + &i.CreatedBy, + ) + return &i, err +} + +const getMaxSchemaVersion = `-- name: GetMaxSchemaVersion :one +SELECT COALESCE(MAX(version), 0) AS max_version +FROM client_metadata_schemas +WHERE client_id = $1 AND name = $2 +` + +type GetMaxSchemaVersionParams struct { + ClientID string `db:"client_id"` + Name string `db:"name"` +} + +// Get the current max version for a (client_id, name) pair. Returns 0 +// for a brand-new pair so the service layer can safely write version 1. +// Must be called inside the same transaction that holds the lock from +// LockSchemaVersionsForName. +// +// SELECT COALESCE(MAX(version), 0) AS max_version +// FROM client_metadata_schemas +// WHERE client_id = $1 AND name = $2 +func (q *Queries) GetMaxSchemaVersion(ctx context.Context, arg *GetMaxSchemaVersionParams) (*int32, error) { + row := q.db.QueryRow(ctx, getMaxSchemaVersion, arg.ClientID, arg.Name) + var max_version *int32 + err := row.Scan(&max_version) + return max_version, err +} + +const getSchemaDocumentCount = `-- name: GetSchemaDocumentCount :one +SELECT COUNT(*) AS document_count +FROM documents +WHERE custom_schema_id = $1 +` + +// Count documents currently bound to a schema via documents.custom_schema_id. +// Used to derive canDelete and to drive retire-vs-delete decisions. +// +// SELECT COUNT(*) AS document_count +// FROM documents +// WHERE custom_schema_id = $1 +func (q *Queries) GetSchemaDocumentCount(ctx context.Context, schemaID *uuid.UUID) (int64, error) { + row := q.db.QueryRow(ctx, getSchemaDocumentCount, schemaID) + var document_count int64 + err := row.Scan(&document_count) + return document_count, err +} + +const listClientMetadataSchemas = `-- name: ListClientMetadataSchemas :many +SELECT + cms.id, cms.client_id, cms.name, cms.description, cms.schema_def, cms.version, cms.status, cms.created_at, cms.created_by, + (SELECT COUNT(*) FROM documents WHERE documents.custom_schema_id = cms.id)::bigint AS document_count +FROM client_metadata_schemas cms +WHERE cms.client_id = $1 + AND ($2::varchar IS NULL OR cms.name = $2::varchar) + AND ($3::bool OR cms.status = $4::schema_status_type) + AND ( + $5::bool + OR (cms.client_id, cms.name, cms.version) IN ( + SELECT client_id, name, MAX(version) + FROM client_metadata_schemas + WHERE client_id = $1 + GROUP BY client_id, name + ) + ) +ORDER BY cms.name, cms.version DESC +LIMIT $7 OFFSET $6 +` + +type ListClientMetadataSchemasParams struct { + ClientID string `db:"client_id"` + NameFilter *string `db:"name_filter"` + IncludeAllStatuses bool `db:"include_all_statuses"` + StatusFilter SchemaStatusType `db:"status_filter"` + IncludeAllVersions bool `db:"include_all_versions"` + OffsetVal int64 `db:"offset_val"` + LimitVal int64 `db:"limit_val"` +} + +type ListClientMetadataSchemasRow struct { + ID uuid.UUID `db:"id"` + ClientID string `db:"client_id"` + Name string `db:"name"` + Description *string `db:"description"` + SchemaDef []byte `db:"schema_def"` + Version int32 `db:"version"` + Status SchemaStatusType `db:"status"` + CreatedAt pgtype.Timestamptz `db:"created_at"` + CreatedBy string `db:"created_by"` + DocumentCount int64 `db:"document_count"` +} + +// List schemas for a client with optional filters. Status filtering uses a +// two-parameter shape: when include_all_statuses is true, status is ignored; +// otherwise status_filter selects exactly one of active/superseded/retired. +// When include_all_versions is false, only the latest version per +// (client_id, name) is returned (via a max-version subquery). When true, +// every version is returned. Name filter uses sqlc.narg so passing NULL +// skips the filter; a non-NULL value is matched exactly. The document_count +// column is a correlated subquery so the handler can derive canDelete +// without a second round-trip. +// +// SELECT +// cms.id, cms.client_id, cms.name, cms.description, cms.schema_def, cms.version, cms.status, cms.created_at, cms.created_by, +// (SELECT COUNT(*) FROM documents WHERE documents.custom_schema_id = cms.id)::bigint AS document_count +// FROM client_metadata_schemas cms +// WHERE cms.client_id = $1 +// AND ($2::varchar IS NULL OR cms.name = $2::varchar) +// AND ($3::bool OR cms.status = $4::schema_status_type) +// AND ( +// $5::bool +// OR (cms.client_id, cms.name, cms.version) IN ( +// SELECT client_id, name, MAX(version) +// FROM client_metadata_schemas +// WHERE client_id = $1 +// GROUP BY client_id, name +// ) +// ) +// ORDER BY cms.name, cms.version DESC +// LIMIT $7 OFFSET $6 +func (q *Queries) ListClientMetadataSchemas(ctx context.Context, arg *ListClientMetadataSchemasParams) ([]*ListClientMetadataSchemasRow, error) { + rows, err := q.db.Query(ctx, listClientMetadataSchemas, + arg.ClientID, + arg.NameFilter, + arg.IncludeAllStatuses, + arg.StatusFilter, + arg.IncludeAllVersions, + arg.OffsetVal, + arg.LimitVal, + ) + if err != nil { + return nil, err + } + defer rows.Close() + items := []*ListClientMetadataSchemasRow{} + for rows.Next() { + var i ListClientMetadataSchemasRow + if err := rows.Scan( + &i.ID, + &i.ClientID, + &i.Name, + &i.Description, + &i.SchemaDef, + &i.Version, + &i.Status, + &i.CreatedAt, + &i.CreatedBy, + &i.DocumentCount, + ); err != nil { + return nil, err + } + items = append(items, &i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const lockSchemaVersionsForName = `-- name: LockSchemaVersionsForName :many +SELECT id, client_id, name, description, schema_def, version, status, created_at, created_by FROM client_metadata_schemas +WHERE client_id = $1 AND name = $2 +FOR UPDATE +` + +type LockSchemaVersionsForNameParams struct { + ClientID string `db:"client_id"` + Name string `db:"name"` +} + +// Lock every version row of a (client_id, name) pair with FOR UPDATE to +// serialize concurrent version creation. Returns the locked rows so the +// service can read the current active row id and max version in one +// statement. Must be called inside a transaction. +// +// SELECT id, client_id, name, description, schema_def, version, status, created_at, created_by FROM client_metadata_schemas +// WHERE client_id = $1 AND name = $2 +// FOR UPDATE +func (q *Queries) LockSchemaVersionsForName(ctx context.Context, arg *LockSchemaVersionsForNameParams) ([]*ClientMetadataSchema, error) { + rows, err := q.db.Query(ctx, lockSchemaVersionsForName, arg.ClientID, arg.Name) + if err != nil { + return nil, err + } + defer rows.Close() + items := []*ClientMetadataSchema{} + for rows.Next() { + var i ClientMetadataSchema + if err := rows.Scan( + &i.ID, + &i.ClientID, + &i.Name, + &i.Description, + &i.SchemaDef, + &i.Version, + &i.Status, + &i.CreatedAt, + &i.CreatedBy, + ); err != nil { + return nil, err + } + items = append(items, &i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const setSchemaStatus = `-- name: SetSchemaStatus :exec +UPDATE client_metadata_schemas +SET status = $1 +WHERE id = $2 +` + +type SetSchemaStatusParams struct { + Status SchemaStatusType `db:"status"` + ID uuid.UUID `db:"id"` +} + +// Update a schema's status. Used by the service layer to mark a row +// superseded when a new version is created, and to mark a row retired +// when the schema still has documents bound at delete time. +// +// UPDATE client_metadata_schemas +// SET status = $1 +// WHERE id = $2 +func (q *Queries) SetSchemaStatus(ctx context.Context, arg *SetSchemaStatusParams) error { + _, err := q.db.Exec(ctx, setSchemaStatus, arg.Status, arg.ID) + return err +} diff --git a/internal/database/repository/customschemas_queries_test.go b/internal/database/repository/customschemas_queries_test.go new file mode 100644 index 00000000..12be542a --- /dev/null +++ b/internal/database/repository/customschemas_queries_test.go @@ -0,0 +1,601 @@ +package repository_test + +import ( + "context" + "errors" + "testing" + + "queryorchestration/internal/database/repository" + "queryorchestration/internal/serviceconfig" + "queryorchestration/internal/test" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5" + "github.com/stretchr/testify/require" +) + +// Mutable metadata feature, milestone 1.4: integration tests for the eight +// sqlc-generated schema CRUD queries against a real Postgres testcontainer. +// +// The tests exercise every generated method at least once: +// - CreateClientMetadataSchema +// - GetClientMetadataSchema +// - GetLatestSchemaByName +// - GetMaxSchemaVersion +// - SetSchemaStatus +// - GetSchemaDocumentCount +// - LockSchemaVersionsForName +// - ListClientMetadataSchemas (covered by TestListClientMetadataSchemas_Filters) +// +// No mocks. All queries run against the shared testcontainer that +// test.CreateDB stands up. The Postgres container is reused across test +// runs so each top-level test re-seeds its client rows with a +// DELETE-then-CreateClient idempotency dance (see the §1.1/§1.3 tests in +// client_metadata_schemas_migration_test.go for the same pattern). + +// trivialSchemaJSON is a minimal but valid schema_def. Used throughout +// these tests when the exact content does not matter. +const trivialSchemaJSON = `{"type":"object","additionalProperties":false}` + +// resetClient makes a client row idempotent across reruns of the same test. +// The Postgres container and per-test database are reused, so leftover rows +// from a previous run must be cleaned out before CreateClient. The FK on +// documents.clientid is NOT ON DELETE CASCADE, so we have to delete +// documents explicitly before the client delete can succeed. Deleting +// documents also implicitly clears documents.custom_schema_id references, +// which lets the subsequent cascade through clients -> client_metadata_schemas +// proceed cleanly. +func resetClient(t *testing.T, ctx context.Context, cfg *serviceconfig.BaseConfig, clientID, name string) { + t.Helper() + pool := cfg.GetDBPool() + // Order matters: documents first (no cascade from clients), then + // clients (cascade wipes client_metadata_schemas via the FK from §1.1). + _, err := pool.Exec(ctx, `DELETE FROM documents WHERE clientId = $1`, clientID) + require.NoError(t, err, "pre-test DELETE documents for clientID=%q must succeed", clientID) + _, err = pool.Exec(ctx, `DELETE FROM clients WHERE clientId = $1`, clientID) + require.NoError(t, err, "pre-test DELETE clients for clientID=%q must succeed", clientID) + + err = cfg.GetDBQueries().CreateClient(ctx, &repository.CreateClientParams{ + Name: name, + Clientid: clientID, + }) + require.NoError(t, err, "CreateClient for clientID=%q must succeed", clientID) +} + +// seedSchema inserts a schema row via the generated CreateClientMetadataSchema +// query and returns the resulting row. Keeps the test bodies uncluttered. +func seedSchema( + t *testing.T, + ctx context.Context, + queries *repository.Queries, + clientID, name, description string, + version int32, + status repository.SchemaStatusType, +) *repository.ClientMetadataSchema { + t.Helper() + desc := description + row, err := queries.CreateClientMetadataSchema(ctx, &repository.CreateClientMetadataSchemaParams{ + ClientID: clientID, + Name: name, + Description: &desc, + SchemaDef: []byte(trivialSchemaJSON), + Version: version, + Status: status, + CreatedBy: "tester", + }) + require.NoError(t, err, "seed CreateClientMetadataSchema(%s,%s,v%d) must succeed", clientID, name, version) + require.NotNil(t, row) + return row +} + +// TestClientMetadataSchemas_CRUD covers the seven non-list queries with one +// subtest per query. Each subtest is responsible for its own fixture data +// (no shared schema rows across subtests) so that reordering or running a +// single subtest in isolation still works. +func TestClientMetadataSchemas_CRUD(t *testing.T) { + t.Parallel() + if testing.Short() { + t.SkipNow() + } + ctx := t.Context() + + cfg := &serviceconfig.BaseConfig{} + test.CreateDB(t, cfg) + pool := cfg.GetDBPool() + require.NotNil(t, pool) + queries := cfg.GetDBQueries() + require.NotNil(t, queries) + + // Two distinct clients so cross-client isolation can be exercised if + // needed. Each test body picks whichever it needs. + const ( + clientA = "SQLC_Q_A" + clientB = "SQLC_Q_B" + ) + resetClient(t, ctx, cfg, clientA, "sqlc-q-a") + resetClient(t, ctx, cfg, clientB, "sqlc-q-b") + + t.Run("create_and_get", func(t *testing.T) { + // Use a name unique to this subtest so parallel / rerun interactions + // can't collide with other subtests that seed the same client. + const schemaName = "create_and_get" + desc := "unit-test schema" + params := &repository.CreateClientMetadataSchemaParams{ + ClientID: clientA, + Name: schemaName, + Description: &desc, + SchemaDef: []byte(trivialSchemaJSON), + Version: 1, + Status: repository.SchemaStatusTypeActive, + CreatedBy: "tester", + } + created, err := queries.CreateClientMetadataSchema(ctx, params) + require.NoError(t, err) + require.NotNil(t, created) + require.NotEqual(t, uuid.Nil, created.ID, "id must be populated by DEFAULT") + require.Equal(t, clientA, created.ClientID) + require.Equal(t, schemaName, created.Name) + require.NotNil(t, created.Description) + require.Equal(t, desc, *created.Description) + require.JSONEq(t, trivialSchemaJSON, string(created.SchemaDef)) + require.Equal(t, int32(1), created.Version) + require.Equal(t, repository.SchemaStatusTypeActive, created.Status) + require.True(t, created.CreatedAt.Valid, "created_at must be set by DEFAULT now()") + require.False(t, created.CreatedAt.Time.IsZero()) + require.Equal(t, "tester", created.CreatedBy) + + // GetClientMetadataSchema must round-trip the row exactly. + fetched, err := queries.GetClientMetadataSchema(ctx, created.ID) + require.NoError(t, err) + require.NotNil(t, fetched) + require.Equal(t, created.ID, fetched.ID) + require.Equal(t, created.ClientID, fetched.ClientID) + require.Equal(t, created.Name, fetched.Name) + require.NotNil(t, fetched.Description) + require.Equal(t, *created.Description, *fetched.Description) + require.JSONEq(t, string(created.SchemaDef), string(fetched.SchemaDef)) + require.Equal(t, created.Version, fetched.Version) + require.Equal(t, created.Status, fetched.Status) + require.Equal(t, created.CreatedAt.Time.UnixMicro(), fetched.CreatedAt.Time.UnixMicro()) + require.Equal(t, created.CreatedBy, fetched.CreatedBy) + }) + + t.Run("get_not_found_returns_err_no_rows", func(t *testing.T) { + // A random UUID will not collide with any seeded row. + randomID := uuid.New() + _, err := queries.GetClientMetadataSchema(ctx, randomID) + require.Error(t, err) + require.True(t, errors.Is(err, pgx.ErrNoRows), + "expected pgx.ErrNoRows, got %T: %v", err, err) + }) + + t.Run("get_latest_by_name", func(t *testing.T) { + const schemaName = "get_latest_by_name" + seedSchema(t, ctx, queries, clientA, schemaName, "v1", 1, repository.SchemaStatusTypeSuperseded) + seedSchema(t, ctx, queries, clientA, schemaName, "v2", 2, repository.SchemaStatusTypeSuperseded) + v3 := seedSchema(t, ctx, queries, clientA, schemaName, "v3", 3, repository.SchemaStatusTypeActive) + + latest, err := queries.GetLatestSchemaByName(ctx, &repository.GetLatestSchemaByNameParams{ + ClientID: clientA, + Name: schemaName, + }) + require.NoError(t, err) + require.NotNil(t, latest) + require.Equal(t, v3.ID, latest.ID, "GetLatestSchemaByName must return the highest-version row") + require.Equal(t, int32(3), latest.Version) + }) + + t.Run("get_max_schema_version", func(t *testing.T) { + const schemaName = "get_max_schema_version" + // Invariant the service layer relies on: brand-new (client, name) + // pair returns 0, not an error. This is what lets the service safely + // assign v1 to a new lineage on the first write. + maxVer, err := queries.GetMaxSchemaVersion(ctx, &repository.GetMaxSchemaVersionParams{ + ClientID: clientA, + Name: schemaName, + }) + require.NoError(t, err, "GetMaxSchemaVersion on empty lineage must NOT error (COALESCE)") + require.NotNil(t, maxVer) + require.Equal(t, int32(0), *maxVer, "empty lineage must return 0") + + // Non-contiguous versions on purpose — the query must return MAX, + // not COUNT or the last inserted version. + seedSchema(t, ctx, queries, clientA, schemaName, "v1", 1, repository.SchemaStatusTypeSuperseded) + seedSchema(t, ctx, queries, clientA, schemaName, "v2", 2, repository.SchemaStatusTypeSuperseded) + seedSchema(t, ctx, queries, clientA, schemaName, "v5", 5, repository.SchemaStatusTypeActive) + + maxVer, err = queries.GetMaxSchemaVersion(ctx, &repository.GetMaxSchemaVersionParams{ + ClientID: clientA, + Name: schemaName, + }) + require.NoError(t, err) + require.NotNil(t, maxVer) + require.Equal(t, int32(5), *maxVer, "GetMaxSchemaVersion must return the largest existing version") + }) + + t.Run("set_schema_status", func(t *testing.T) { + const schemaName = "set_schema_status" + row := seedSchema(t, ctx, queries, clientA, schemaName, "v1", 1, repository.SchemaStatusTypeActive) + + // active -> superseded + err := queries.SetSchemaStatus(ctx, &repository.SetSchemaStatusParams{ + ID: row.ID, + Status: repository.SchemaStatusTypeSuperseded, + }) + require.NoError(t, err) + reloaded, err := queries.GetClientMetadataSchema(ctx, row.ID) + require.NoError(t, err) + require.Equal(t, repository.SchemaStatusTypeSuperseded, reloaded.Status) + + // superseded -> retired + err = queries.SetSchemaStatus(ctx, &repository.SetSchemaStatusParams{ + ID: row.ID, + Status: repository.SchemaStatusTypeRetired, + }) + require.NoError(t, err) + reloaded, err = queries.GetClientMetadataSchema(ctx, row.ID) + require.NoError(t, err) + require.Equal(t, repository.SchemaStatusTypeRetired, reloaded.Status) + }) + + t.Run("get_schema_document_count", func(t *testing.T) { + const schemaName = "get_schema_document_count" + schema := seedSchema(t, ctx, queries, clientA, schemaName, "v1", 1, repository.SchemaStatusTypeActive) + + // No documents bound yet. + count, err := queries.GetSchemaDocumentCount(ctx, &schema.ID) + require.NoError(t, err) + require.Equal(t, int64(0), count, "unbound schema must report 0 documents") + + // Insert a first document owned by the SAME client as the schema + // and bind it. Same-client is required by the composite FK on + // documents.custom_schema_id (see migration 128 / §1.3 test). + doc1, err := queries.CreateDocument(ctx, &repository.CreateDocumentParams{ + Clientid: clientA, + Hash: "sqlc_q_doc_hash_1", + }) + require.NoError(t, err) + _, err = pool.Exec(ctx, + `UPDATE documents SET custom_schema_id = $1 WHERE id = $2`, + schema.ID, doc1) + require.NoError(t, err) + + count, err = queries.GetSchemaDocumentCount(ctx, &schema.ID) + require.NoError(t, err) + require.Equal(t, int64(1), count, "one bound document must report 1") + + // Bind a second document. + doc2, err := queries.CreateDocument(ctx, &repository.CreateDocumentParams{ + Clientid: clientA, + Hash: "sqlc_q_doc_hash_2", + }) + require.NoError(t, err) + _, err = pool.Exec(ctx, + `UPDATE documents SET custom_schema_id = $1 WHERE id = $2`, + schema.ID, doc2) + require.NoError(t, err) + + count, err = queries.GetSchemaDocumentCount(ctx, &schema.ID) + require.NoError(t, err) + require.Equal(t, int64(2), count, "two bound documents must report 2") + }) + + t.Run("lock_schema_versions_for_name", func(t *testing.T) { + // This test only proves that LockSchemaVersionsForName returns the + // expected rows when run inside a transaction. It does NOT exercise + // the FOR UPDATE serialization semantics — concurrent version + // creation is proven by the Milestone 1.6 service-layer tests. The + // value of LockSchemaVersionsForName at this layer is just "give me + // back every version row of this (client, name) pair, under a + // transactional lock"; that is what we assert. + const schemaName = "lock_schema_versions_for_name" + v1 := seedSchema(t, ctx, queries, clientA, schemaName, "v1", 1, repository.SchemaStatusTypeSuperseded) + v2 := seedSchema(t, ctx, queries, clientA, schemaName, "v2", 2, repository.SchemaStatusTypeActive) + + tx, err := pool.Begin(ctx) + require.NoError(t, err, "pool.Begin must succeed") + // Always roll back — the seeded rows already exist outside the tx + // and nothing inside the tx needs to persist. + defer func() { _ = tx.Rollback(ctx) }() + + txQueries := queries.WithTx(tx) + locked, err := txQueries.LockSchemaVersionsForName(ctx, &repository.LockSchemaVersionsForNameParams{ + ClientID: clientA, + Name: schemaName, + }) + require.NoError(t, err) + require.Len(t, locked, 2, "must lock every version row for the (client, name) pair") + + gotIDs := map[uuid.UUID]bool{} + for _, row := range locked { + gotIDs[row.ID] = true + } + require.True(t, gotIDs[v1.ID], "locked set must include v1 row") + require.True(t, gotIDs[v2.ID], "locked set must include v2 row") + }) +} + +// TestListClientMetadataSchemas_Filters exercises the filter matrix of +// ListClientMetadataSchemas. Uses a client pair distinct from the CRUD test +// (SQLC_LIST_A / SQLC_LIST_B) so the two top-level tests can run in parallel +// without stepping on each other. +// +// Filter semantics asserted: +// - include_all_versions=false returns only the MAX(version) row per +// (client_id, name). +// - include_all_versions=true returns every row. +// - name_filter uses EXACT match (the generated SQL is +// `cms.name = $2::varchar`, not ILIKE). See the name_filter_narrows +// subtest for the assertion. +// - include_all_statuses=true bypasses the status filter; otherwise +// status_filter selects exactly one status. +// - document_count is a correlated subquery on documents.custom_schema_id. +// - client_id scoping is strict (cross-client isolation). +// - LIMIT/OFFSET paginate across an ORDER BY (name, version DESC) window. +func TestListClientMetadataSchemas_Filters(t *testing.T) { + t.Parallel() + if testing.Short() { + t.SkipNow() + } + ctx := t.Context() + + cfg := &serviceconfig.BaseConfig{} + test.CreateDB(t, cfg) + pool := cfg.GetDBPool() + require.NotNil(t, pool) + queries := cfg.GetDBQueries() + require.NotNil(t, queries) + + const ( + clientA = "SQLC_LIST_A" + clientB = "SQLC_LIST_B" + ) + resetClient(t, ctx, cfg, clientA, "sqlc-list-a") + resetClient(t, ctx, cfg, clientB, "sqlc-list-b") + + // Shared fixture on clientA: two alpha versions (v1 superseded, v2 active) + // and one beta v1 active. Every later subtest that seeds additional rows + // does so on a DIFFERENT name so it doesn't disturb these. + alphaV1 := seedSchema(t, ctx, queries, clientA, "alpha", "", 1, repository.SchemaStatusTypeSuperseded) + alphaV2 := seedSchema(t, ctx, queries, clientA, "alpha", "", 2, repository.SchemaStatusTypeActive) + betaV1 := seedSchema(t, ctx, queries, clientA, "beta", "", 1, repository.SchemaStatusTypeActive) + + t.Run("default_filters_returns_latest_active", func(t *testing.T) { + // include_all_versions=false collapses to MAX(version) per name, so + // the superseded alpha v1 drops out. Combined with status_filter=active, + // the result is exactly {alpha v2, beta v1}. + rows, err := queries.ListClientMetadataSchemas(ctx, &repository.ListClientMetadataSchemasParams{ + ClientID: clientA, + NameFilter: nil, + IncludeAllStatuses: false, + StatusFilter: repository.SchemaStatusTypeActive, + IncludeAllVersions: false, + OffsetVal: 0, + LimitVal: 100, + }) + require.NoError(t, err) + + // Build a set of (name, version) tuples for assertion. + type tuple struct { + name string + version int32 + } + got := map[tuple]bool{} + for _, r := range rows { + got[tuple{r.Name, r.Version}] = true + } + require.Truef(t, got[tuple{"alpha", 2}], "result must contain (alpha, v2); got=%v", got) + require.Truef(t, got[tuple{"beta", 1}], "result must contain (beta, v1); got=%v", got) + require.Falsef(t, got[tuple{"alpha", 1}], "result must NOT contain (alpha, v1) when include_all_versions=false; got=%v", got) + }) + + t.Run("include_all_versions_returns_every_row", func(t *testing.T) { + rows, err := queries.ListClientMetadataSchemas(ctx, &repository.ListClientMetadataSchemasParams{ + ClientID: clientA, + NameFilter: nil, + IncludeAllStatuses: true, // don't let status filter confuse this assertion + StatusFilter: repository.SchemaStatusTypeActive, + IncludeAllVersions: true, + OffsetVal: 0, + LimitVal: 100, + }) + require.NoError(t, err) + + // Shared fixture yields 3 rows (alpha v1, alpha v2, beta v1). + // Other subtests may have added MORE rows to clientA by the time + // this subtest runs (t.Run children run sequentially inside the + // top-level test so ordering is deterministic — this subtest runs + // second, before any seed-adding subtest). Assert the three + // fixture rows are present rather than an exact length. + ids := map[uuid.UUID]bool{} + for _, r := range rows { + ids[r.ID] = true + } + require.True(t, ids[alphaV1.ID], "include_all_versions must include alpha v1") + require.True(t, ids[alphaV2.ID], "include_all_versions must include alpha v2") + require.True(t, ids[betaV1.ID], "include_all_versions must include beta v1") + }) + + t.Run("name_filter_narrows", func(t *testing.T) { + // The generated SQL uses EXACT match (`cms.name = $2::varchar`), + // NOT ILIKE. Passing "alpha" must return only alpha rows. + nameFilter := "alpha" + rows, err := queries.ListClientMetadataSchemas(ctx, &repository.ListClientMetadataSchemasParams{ + ClientID: clientA, + NameFilter: &nameFilter, + IncludeAllStatuses: true, + StatusFilter: repository.SchemaStatusTypeActive, + IncludeAllVersions: true, + OffsetVal: 0, + LimitVal: 100, + }) + require.NoError(t, err) + require.NotEmpty(t, rows, "name_filter=alpha must return at least one row") + for _, r := range rows { + require.Equal(t, "alpha", r.Name, + "name_filter is exact match; every row must have name=alpha, got %q", r.Name) + } + }) + + t.Run("status_filter_superseded", func(t *testing.T) { + // Seed one additional row with status=superseded on a name that no + // other subtest touches, so we can assert status filtering cleanly. + gammaV1 := seedSchema(t, ctx, queries, clientA, "gamma", "", 1, repository.SchemaStatusTypeSuperseded) + + rows, err := queries.ListClientMetadataSchemas(ctx, &repository.ListClientMetadataSchemasParams{ + ClientID: clientA, + NameFilter: nil, + IncludeAllStatuses: false, + StatusFilter: repository.SchemaStatusTypeSuperseded, + IncludeAllVersions: true, // look at every row, filter by status only + OffsetVal: 0, + LimitVal: 100, + }) + require.NoError(t, err) + + sawGamma := false + for _, r := range rows { + require.Equal(t, repository.SchemaStatusTypeSuperseded, r.Status, + "status_filter=superseded must not return %s rows", r.Status) + if r.ID == gammaV1.ID { + sawGamma = true + } + } + require.True(t, sawGamma, "result must include the seeded gamma superseded row") + }) + + t.Run("include_all_statuses_returns_all", func(t *testing.T) { + rows, err := queries.ListClientMetadataSchemas(ctx, &repository.ListClientMetadataSchemasParams{ + ClientID: clientA, + NameFilter: nil, + IncludeAllStatuses: true, + StatusFilter: repository.SchemaStatusTypeActive, // ignored + IncludeAllVersions: true, + OffsetVal: 0, + LimitVal: 100, + }) + require.NoError(t, err) + + // The shared-fixture rows plus the gamma row from the previous + // subtest must all come back regardless of status. + seenStatuses := map[repository.SchemaStatusType]bool{} + for _, r := range rows { + seenStatuses[r.Status] = true + } + require.True(t, seenStatuses[repository.SchemaStatusTypeActive], "result must include active rows") + require.True(t, seenStatuses[repository.SchemaStatusTypeSuperseded], "result must include superseded rows") + }) + + t.Run("document_count_is_populated", func(t *testing.T) { + // Fresh schema on clientB with two bound documents. The correlated + // subquery must report DocumentCount=2. + schemaB := seedSchema(t, ctx, queries, clientB, "docbound", "", 1, repository.SchemaStatusTypeActive) + + doc1, err := queries.CreateDocument(ctx, &repository.CreateDocumentParams{ + Clientid: clientB, + Hash: "sqlc_list_doc_hash_1", + }) + require.NoError(t, err) + _, err = pool.Exec(ctx, + `UPDATE documents SET custom_schema_id = $1 WHERE id = $2`, + schemaB.ID, doc1) + require.NoError(t, err) + + doc2, err := queries.CreateDocument(ctx, &repository.CreateDocumentParams{ + Clientid: clientB, + Hash: "sqlc_list_doc_hash_2", + }) + require.NoError(t, err) + _, err = pool.Exec(ctx, + `UPDATE documents SET custom_schema_id = $1 WHERE id = $2`, + schemaB.ID, doc2) + require.NoError(t, err) + + rows, err := queries.ListClientMetadataSchemas(ctx, &repository.ListClientMetadataSchemasParams{ + ClientID: clientB, + NameFilter: nil, + IncludeAllStatuses: true, + StatusFilter: repository.SchemaStatusTypeActive, + IncludeAllVersions: true, + OffsetVal: 0, + LimitVal: 100, + }) + require.NoError(t, err) + require.Len(t, rows, 1, "clientB fixture has exactly one schema") + require.Equal(t, schemaB.ID, rows[0].ID) + require.Equal(t, int64(2), rows[0].DocumentCount, + "DocumentCount must reflect bound documents via correlated subquery") + }) + + t.Run("cross_client_isolation", func(t *testing.T) { + // Listing for clientA must not leak any clientB rows — and we know + // clientB now has a schema from the previous subtest. + rows, err := queries.ListClientMetadataSchemas(ctx, &repository.ListClientMetadataSchemasParams{ + ClientID: clientA, + NameFilter: nil, + IncludeAllStatuses: true, + StatusFilter: repository.SchemaStatusTypeActive, + IncludeAllVersions: true, + OffsetVal: 0, + LimitVal: 100, + }) + require.NoError(t, err) + require.NotEmpty(t, rows) + for _, r := range rows { + require.Equal(t, clientA, r.ClientID, + "cross-client isolation: clientA query must not leak %s rows", r.ClientID) + } + }) + + t.Run("pagination_limit_offset", func(t *testing.T) { + // Seed 5 schemas with distinct names on a NEW client so the + // pagination assertions don't have to account for shared-fixture + // rows accumulated by the earlier subtests. Uses a third client id + // to stay isolated. + const clientC = "SQLC_LIST_C" + resetClient(t, ctx, cfg, clientC, "sqlc-list-c") + + names := []string{"p1_apple", "p2_banana", "p3_cherry", "p4_date", "p5_elderberry"} + for _, n := range names { + seedSchema(t, ctx, queries, clientC, n, "", 1, repository.SchemaStatusTypeActive) + } + + fetch := func(offset, limit int64) []*repository.ListClientMetadataSchemasRow { + rows, err := queries.ListClientMetadataSchemas(ctx, &repository.ListClientMetadataSchemasParams{ + ClientID: clientC, + NameFilter: nil, + IncludeAllStatuses: true, + StatusFilter: repository.SchemaStatusTypeActive, + IncludeAllVersions: true, + OffsetVal: offset, + LimitVal: limit, + }) + require.NoError(t, err) + return rows + } + + page1 := fetch(0, 2) + require.Len(t, page1, 2) + page2 := fetch(2, 2) + require.Len(t, page2, 2) + page3 := fetch(4, 2) + require.Len(t, page3, 1) + + // Combined across the three calls we must see every seeded name + // exactly once — no duplicates, no gaps. + seen := map[string]int{} + for _, r := range page1 { + seen[r.Name]++ + } + for _, r := range page2 { + seen[r.Name]++ + } + for _, r := range page3 { + seen[r.Name]++ + } + require.Len(t, seen, 5, "pagination must cover all 5 distinct schemas") + for _, n := range names { + require.Equal(t, 1, seen[n], "name %q must appear exactly once across pages", n) + } + }) +} diff --git a/internal/database/repository/document.sql.go b/internal/database/repository/document.sql.go index 6f57a3ed..fd2cb2c9 100644 --- a/internal/database/repository/document.sql.go +++ b/internal/database/repository/document.sql.go @@ -229,22 +229,32 @@ SELECT d.folderId, d.filename, d.originalPath, - d.file_size_bytes + d.file_size_bytes, + d.custom_schema_id, + EXISTS( + SELECT 1 FROM document_custom_metadata + WHERE document_id = d.id + ) AS has_custom_metadata FROM documents d WHERE d.id = $1 ` type GetDocumentEnrichedRow struct { - ID uuid.UUID `db:"id"` - Clientid string `db:"clientid"` - Hash string `db:"hash"` - Folderid *uuid.UUID `db:"folderid"` - Filename *string `db:"filename"` - Originalpath *string `db:"originalpath"` - FileSizeBytes *int64 `db:"file_size_bytes"` + ID uuid.UUID `db:"id"` + Clientid string `db:"clientid"` + Hash string `db:"hash"` + Folderid *uuid.UUID `db:"folderid"` + Filename *string `db:"filename"` + Originalpath *string `db:"originalpath"` + FileSizeBytes *int64 `db:"file_size_bytes"` + CustomSchemaID *uuid.UUID `db:"custom_schema_id"` + HasCustomMetadata bool `db:"has_custom_metadata"` } -// Retrieves a document with extended metadata including folder and filename +// Retrieves a document with extended metadata including folder and filename. +// Milestone 2.3a adds custom_schema_id and a correlated EXISTS probe into +// document_custom_metadata so GET /document/{id} can surface both fields +// without a follow-up query. // // SELECT // d.id, @@ -253,7 +263,12 @@ type GetDocumentEnrichedRow struct { // d.folderId, // d.filename, // d.originalPath, -// d.file_size_bytes +// d.file_size_bytes, +// d.custom_schema_id, +// EXISTS( +// SELECT 1 FROM document_custom_metadata +// WHERE document_id = d.id +// ) AS has_custom_metadata // FROM documents d // WHERE d.id = $1 func (q *Queries) GetDocumentEnriched(ctx context.Context, id uuid.UUID) (*GetDocumentEnrichedRow, error) { @@ -267,6 +282,8 @@ func (q *Queries) GetDocumentEnriched(ctx context.Context, id uuid.UUID) (*GetDo &i.Filename, &i.Originalpath, &i.FileSizeBytes, + &i.CustomSchemaID, + &i.HasCustomMetadata, ) return &i, err } @@ -627,3 +644,33 @@ func (q *Queries) ListDocumentsByClient(ctx context.Context, clientid string) ([ } return items, nil } + +const lockDocumentForLegacyExtractionWrite = `-- name: LockDocumentForLegacyExtractionWrite :one +SELECT id, custom_schema_id +FROM documents +WHERE id = $1 +FOR UPDATE +` + +type LockDocumentForLegacyExtractionWriteRow struct { + ID uuid.UUID `db:"id"` + CustomSchemaID *uuid.UUID `db:"custom_schema_id"` +} + +// Milestone 2.5 parent-row lock acquired by CreateFieldExtraction as the +// FIRST DML statement inside its transaction. Serializes with AssignSchema +// (which takes FOR UPDATE on the same row) so the mutual-exclusivity +// invariant from plan Section 7.3 holds even if Trigger 2 is ever removed. +// Returns the current custom_schema_id so the caller can short-circuit to +// ErrMutualExclusivityViolation when the document is already bound. +// +// SELECT id, custom_schema_id +// FROM documents +// WHERE id = $1 +// FOR UPDATE +func (q *Queries) LockDocumentForLegacyExtractionWrite(ctx context.Context, documentID uuid.UUID) (*LockDocumentForLegacyExtractionWriteRow, error) { + row := q.db.QueryRow(ctx, lockDocumentForLegacyExtractionWrite, documentID) + var i LockDocumentForLegacyExtractionWriteRow + err := row.Scan(&i.ID, &i.CustomSchemaID) + return &i, err +} diff --git a/internal/database/repository/folders.sql.go b/internal/database/repository/folders.sql.go index 54cb4c20..fbb51aca 100644 --- a/internal/database/repository/folders.sql.go +++ b/internal/database/repository/folders.sql.go @@ -179,14 +179,14 @@ func (q *Queries) GetDocumentIDsInFolderTree(ctx context.Context, folderID *uuid } const getDocumentsByFolder = `-- name: GetDocumentsByFolder :many -SELECT id, clientid, hash, batch_id, filename, folderid, originalpath, file_size_bytes FROM documents +SELECT id, clientid, hash, batch_id, filename, folderid, originalpath, file_size_bytes, custom_schema_id FROM documents WHERE folderId = $1 ORDER BY id ` // GetDocumentsByFolder // -// SELECT id, clientid, hash, batch_id, filename, folderid, originalpath, file_size_bytes FROM documents +// SELECT id, clientid, hash, batch_id, filename, folderid, originalpath, file_size_bytes, custom_schema_id FROM documents // WHERE folderId = $1 // ORDER BY id func (q *Queries) GetDocumentsByFolder(ctx context.Context, folderid *uuid.UUID) ([]*Document, error) { @@ -207,6 +207,7 @@ func (q *Queries) GetDocumentsByFolder(ctx context.Context, folderid *uuid.UUID) &i.Folderid, &i.Originalpath, &i.FileSizeBytes, + &i.CustomSchemaID, ); err != nil { return nil, err } diff --git a/internal/database/repository/labels.sql.go b/internal/database/repository/labels.sql.go index 7c5c42d2..08cf2c49 100644 --- a/internal/database/repository/labels.sql.go +++ b/internal/database/repository/labels.sql.go @@ -191,7 +191,7 @@ func (q *Queries) GetDocumentLabels(ctx context.Context, documentid uuid.UUID) ( } const getDocumentsByLabel = `-- name: GetDocumentsByLabel :many -SELECT DISTINCT d.id, d.clientid, d.hash, d.batch_id, d.filename, d.folderid, d.originalpath, d.file_size_bytes +SELECT DISTINCT d.id, d.clientid, d.hash, d.batch_id, d.filename, d.folderid, d.originalpath, d.file_size_bytes, d.custom_schema_id FROM documents d INNER JOIN documentLabels dl ON d.id = dl.documentId WHERE d.clientId = $1 AND dl.label = $2 @@ -205,7 +205,7 @@ type GetDocumentsByLabelParams struct { // GetDocumentsByLabel // -// SELECT DISTINCT d.id, d.clientid, d.hash, d.batch_id, d.filename, d.folderid, d.originalpath, d.file_size_bytes +// SELECT DISTINCT d.id, d.clientid, d.hash, d.batch_id, d.filename, d.folderid, d.originalpath, d.file_size_bytes, d.custom_schema_id // FROM documents d // INNER JOIN documentLabels dl ON d.id = dl.documentId // WHERE d.clientId = $1 AND dl.label = $2 @@ -228,6 +228,7 @@ func (q *Queries) GetDocumentsByLabel(ctx context.Context, arg *GetDocumentsByLa &i.Folderid, &i.Originalpath, &i.FileSizeBytes, + &i.CustomSchemaID, ); err != nil { return nil, err } @@ -240,7 +241,7 @@ func (q *Queries) GetDocumentsByLabel(ctx context.Context, arg *GetDocumentsByLa } const getDocumentsByLabelAndFolder = `-- name: GetDocumentsByLabelAndFolder :many -SELECT DISTINCT d.id, d.clientid, d.hash, d.batch_id, d.filename, d.folderid, d.originalpath, d.file_size_bytes +SELECT DISTINCT d.id, d.clientid, d.hash, d.batch_id, d.filename, d.folderid, d.originalpath, d.file_size_bytes, d.custom_schema_id FROM documents d INNER JOIN documentLabels dl ON d.id = dl.documentId WHERE d.folderId = $1 AND dl.label = $2 @@ -254,7 +255,7 @@ type GetDocumentsByLabelAndFolderParams struct { // GetDocumentsByLabelAndFolder // -// SELECT DISTINCT d.id, d.clientid, d.hash, d.batch_id, d.filename, d.folderid, d.originalpath, d.file_size_bytes +// SELECT DISTINCT d.id, d.clientid, d.hash, d.batch_id, d.filename, d.folderid, d.originalpath, d.file_size_bytes, d.custom_schema_id // FROM documents d // INNER JOIN documentLabels dl ON d.id = dl.documentId // WHERE d.folderId = $1 AND dl.label = $2 @@ -277,6 +278,7 @@ func (q *Queries) GetDocumentsByLabelAndFolder(ctx context.Context, arg *GetDocu &i.Folderid, &i.Originalpath, &i.FileSizeBytes, + &i.CustomSchemaID, ); err != nil { return nil, err } diff --git a/internal/database/repository/milestone2_migrations_test.go b/internal/database/repository/milestone2_migrations_test.go new file mode 100644 index 00000000..42842875 --- /dev/null +++ b/internal/database/repository/milestone2_migrations_test.go @@ -0,0 +1,1073 @@ +//go:build milestone2 + +// Package repository_test — Milestone 2 §2.1 and §2.2 failing migration +// shape tests. +// +// Build tag: this file is gated behind `milestone2` so the default +// `task fullsuite:ci` run (which does not pass the tag) skips every +// test in here. That keeps Milestone 1's green bar intact while the +// RED tests sit on disk as the TDD target for the golang engineer. +// Once migrations 129 and 130 land, the engineer runs +// +// go test -tags=milestone2 ./internal/database/repository/... +// +// to flip them to green before running fullsuite:ci without the tag +// and confirming the whole suite still passes. +// +// These tests fail by design until the golang engineer lands migrations +// 129 (document_custom_metadata table) and 130 (schema invariant triggers). +// Every test in this file asserts DB-level structure or behaviour defined +// in plan Section 4.3 and Section 4.7 of plans/mutable.metadata.plan.combo.v4.md +// against a real Postgres 17 testcontainer stood up by internal/test.CreateDB. +// +// No mocks. Every assertion is an introspection or behavioural write +// against the real DB. See client_metadata_schemas_migration_test.go for +// the same pattern applied to migration 127. +package repository_test + +import ( + "context" + "errors" + "os" + "strings" + "sync" + "testing" + "time" + + "queryorchestration/internal/database/repository" + "queryorchestration/internal/serviceconfig" + "queryorchestration/internal/test" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgconn" + "github.com/stretchr/testify/require" +) + +// ---------- §2.1 migration 129 — document_custom_metadata table ---------- + +// TestMigration129_DocumentCustomMetadataShape asserts the structural shape +// defined by plan Section 4.3. Fails until migration 129 exists. +// +// v4 explicitly removes v3's `schema_id` column from this table (see plan +// Section 4.3 "No schema_id column (v4 simplification)"), so the test +// asserts the column is NOT present. +func TestMigration129_DocumentCustomMetadataShape(t *testing.T) { + t.Parallel() + if testing.Short() { + t.SkipNow() + } + ctx := t.Context() + + cfg := &serviceconfig.BaseConfig{} + test.CreateDB(t, cfg) + pool := cfg.GetDBPool() + require.NotNil(t, pool) + + t.Run("table_exists", func(t *testing.T) { + var exists bool + err := pool.QueryRow(ctx, ` + SELECT EXISTS ( + SELECT 1 FROM information_schema.tables + WHERE table_schema = 'public' + AND table_name = 'document_custom_metadata' + ) + `).Scan(&exists) + require.NoError(t, err) + require.True(t, exists, "document_custom_metadata table must exist") + }) + + t.Run("columns", func(t *testing.T) { + type colSpec struct { + dataType string + isNullable string // "YES" or "NO" + } + expected := map[string]colSpec{ + "id": {"uuid", "NO"}, + "document_id": {"uuid", "NO"}, + "metadata": {"jsonb", "NO"}, + "version": {"integer", "NO"}, + "created_at": {"timestamp with time zone", "NO"}, + "created_by": {"character varying", "NO"}, + } + + rows, err := pool.Query(ctx, ` + SELECT column_name, data_type, is_nullable + FROM information_schema.columns + WHERE table_schema = 'public' + AND table_name = 'document_custom_metadata' + `) + require.NoError(t, err) + defer rows.Close() + + found := map[string]colSpec{} + for rows.Next() { + var name, dataType, nullable string + require.NoError(t, rows.Scan(&name, &dataType, &nullable)) + found[name] = colSpec{dataType: dataType, isNullable: nullable} + } + require.NoError(t, rows.Err()) + + require.Equal(t, len(expected), len(found), + "document_custom_metadata column count mismatch; expected %d, got %v", + len(expected), found) + for name, want := range expected { + got, ok := found[name] + require.Truef(t, ok, "missing column %q", name) + require.Equalf(t, want.dataType, got.dataType, + "column %q data_type mismatch", name) + require.Equalf(t, want.isNullable, got.isNullable, + "column %q is_nullable mismatch", name) + } + }) + + t.Run("no_schema_id_column_v4_simplification", func(t *testing.T) { + // v4 deliberately removes the v3 schema_id column. If a future + // contributor accidentally reintroduces it via migration 129 or + // later, this assertion fails loudly with a pointer back to the + // plan's Section 4.3 design note. + var exists bool + err := pool.QueryRow(ctx, ` + SELECT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_schema = 'public' + AND table_name = 'document_custom_metadata' + AND column_name = 'schema_id' + ) + `).Scan(&exists) + require.NoError(t, err) + require.False(t, exists, + "document_custom_metadata must NOT have a schema_id column in v4 — see plan Section 4.3 'No schema_id column (v4 simplification)'") + }) + + t.Run("primary_key_on_id", func(t *testing.T) { + cols, err := fetchDCMConstraintColumns(ctx, pool, "p", "") + require.NoError(t, err) + require.Equal(t, []string{"id"}, cols, + "document_custom_metadata primary key must be on (id)") + }) + + t.Run("unique_document_id_version", func(t *testing.T) { + cols, err := fetchDCMConstraintColumns(ctx, pool, "u", "uq_doc_custom_metadata_version") + require.NoError(t, err) + require.Equal(t, []string{"document_id", "version"}, cols, + "uq_doc_custom_metadata_version must be UNIQUE on (document_id, version)") + }) + + t.Run("fk_document_cascade", func(t *testing.T) { + var ( + confdeltype string + refTable string + localColumn string + foreignCol string + ) + err := pool.QueryRow(ctx, ` + SELECT + c.confdeltype::text, + rt.relname, + la.attname, + fa.attname + FROM pg_constraint c + JOIN pg_class t ON t.oid = c.conrelid + JOIN pg_class rt ON rt.oid = c.confrelid + JOIN pg_attribute la ON la.attrelid = c.conrelid AND la.attnum = c.conkey[1] + JOIN pg_attribute fa ON fa.attrelid = c.confrelid AND fa.attnum = c.confkey[1] + WHERE c.contype = 'f' + AND t.relname = 'document_custom_metadata' + AND la.attname = 'document_id' + `).Scan(&confdeltype, &refTable, &localColumn, &foreignCol) + require.NoError(t, err, + "FK on document_custom_metadata.document_id must exist") + require.Equal(t, "documents", refTable, "FK must reference documents table") + require.Equal(t, "document_id", localColumn) + require.Equal(t, "id", foreignCol, "FK must reference documents(id)") + require.Equal(t, "c", confdeltype, + "FK must be ON DELETE CASCADE (confdeltype='c')") + }) + + t.Run("index_doc_version_desc", func(t *testing.T) { + // idx_dcm_doc_version_desc is the only index v4 needs on this table. + // It backs GetCurrentDocumentCustomMetadata's `ORDER BY version DESC + // LIMIT 1` lookup (plan Section 4.5). + var exists bool + err := pool.QueryRow(ctx, ` + SELECT EXISTS ( + SELECT 1 FROM pg_indexes + WHERE schemaname = 'public' + AND tablename = 'document_custom_metadata' + AND indexname = 'idx_dcm_doc_version_desc' + ) + `).Scan(&exists) + require.NoError(t, err) + require.True(t, exists, + "idx_dcm_doc_version_desc must exist on document_custom_metadata") + }) +} + +// TestMigration129_FKCascadeDeletesMetadata proves that deleting a document +// row cascades through to the document_custom_metadata table, removing any +// metadata rows for that document. This is the §2.1 "delete of a document +// removes its metadata rows" behavioural assertion. +func TestMigration129_FKCascadeDeletesMetadata(t *testing.T) { + t.Parallel() + if testing.Short() { + t.SkipNow() + } + ctx := t.Context() + + cfg := &serviceconfig.BaseConfig{} + test.CreateDB(t, cfg) + pool := cfg.GetDBPool() + require.NotNil(t, pool) + + const clientID = "DCM_FK_CASCADE" + resetClientForMilestone2Test(t, ctx, pool, clientID) + err := cfg.GetDBQueries().CreateClient(ctx, &repository.CreateClientParams{ + Name: "dcm-fk-cascade", + Clientid: clientID, + }) + require.NoError(t, err) + + // Create a document. + docID, err := cfg.GetDBQueries().CreateDocument(ctx, &repository.CreateDocumentParams{ + Clientid: clientID, + Hash: "dcm-fk-cascade-doc", + }) + require.NoError(t, err) + + // Insert two metadata rows directly. + _, err = pool.Exec(ctx, ` + INSERT INTO document_custom_metadata (document_id, metadata, version, created_by) + VALUES ($1, $2::jsonb, 1, 'tester'), + ($1, $3::jsonb, 2, 'tester') + `, docID, `{"k":"v1"}`, `{"k":"v2"}`) + require.NoError(t, err) + + var before int + require.NoError(t, pool.QueryRow(ctx, + `SELECT COUNT(*) FROM document_custom_metadata WHERE document_id = $1`, + docID).Scan(&before)) + require.Equal(t, 2, before) + + // Delete the document — cascade should remove metadata rows. + _, err = pool.Exec(ctx, `DELETE FROM documents WHERE id = $1`, docID) + require.NoError(t, err) + + var after int + require.NoError(t, pool.QueryRow(ctx, + `SELECT COUNT(*) FROM document_custom_metadata WHERE document_id = $1`, + docID).Scan(&after)) + require.Equal(t, 0, after, + "ON DELETE CASCADE must remove every metadata row for the deleted document") +} + +// TestMigration129_UniqueDocumentVersionRejected proves the +// uq_doc_custom_metadata_version UNIQUE constraint rejects a second row with +// the same (document_id, version) tuple. Behavioural counterpart to the +// shape-level assertion in TestMigration129_DocumentCustomMetadataShape. +func TestMigration129_UniqueDocumentVersionRejected(t *testing.T) { + t.Parallel() + if testing.Short() { + t.SkipNow() + } + ctx := t.Context() + + cfg := &serviceconfig.BaseConfig{} + test.CreateDB(t, cfg) + pool := cfg.GetDBPool() + require.NotNil(t, pool) + + const clientID = "DCM_UNIQ" + resetClientForMilestone2Test(t, ctx, pool, clientID) + err := cfg.GetDBQueries().CreateClient(ctx, &repository.CreateClientParams{ + Name: "dcm-unique-test", + Clientid: clientID, + }) + require.NoError(t, err) + + docID, err := cfg.GetDBQueries().CreateDocument(ctx, &repository.CreateDocumentParams{ + Clientid: clientID, + Hash: "dcm-unique-doc", + }) + require.NoError(t, err) + + const insertSQL = ` + INSERT INTO document_custom_metadata (document_id, metadata, version, created_by) + VALUES ($1, $2::jsonb, $3, $4) + ` + _, err = pool.Exec(ctx, insertSQL, docID, `{"k":"v1"}`, 1, "tester") + require.NoError(t, err, "first insert must succeed") + + _, err = pool.Exec(ctx, insertSQL, docID, `{"k":"v2"}`, 1, "tester") + require.Error(t, err, "second insert with same (document_id,version) must fail") + + var pgErr *pgconn.PgError + require.True(t, errors.As(err, &pgErr), + "expected *pgconn.PgError, got %T: %v", err, err) + require.Equal(t, "23505", pgErr.Code, + "expected unique_violation SQLSTATE 23505") + require.Equal(t, "uq_doc_custom_metadata_version", pgErr.ConstraintName, + "expected the duplicate to trip uq_doc_custom_metadata_version") +} + +// TestMigration129_UpDownRoundtrip proves the migration 129 down.sql is +// complete: running it tears the table out cleanly, and re-running the +// up.sql re-creates it. +func TestMigration129_UpDownRoundtrip(t *testing.T) { + t.Parallel() + if testing.Short() { + t.SkipNow() + } + ctx := t.Context() + + cfg := &serviceconfig.BaseConfig{} + test.CreateDB(t, cfg) + pool := cfg.GetDBPool() + require.NotNil(t, pool) + + const ( + upPath = "../migrations/00000000000129_create_document_custom_metadata.up.sql" + downPath = "../migrations/00000000000129_create_document_custom_metadata.down.sql" + ) + upSQL, err := os.ReadFile(upPath) + require.NoError(t, err, "must be able to read migration 129 up.sql") + downSQL, err := os.ReadFile(downPath) + require.NoError(t, err, "must be able to read migration 129 down.sql") + + // Leave DB in migrated state on test exit. + t.Cleanup(func() { + _, _ = pool.Exec(context.Background(), string(upSQL)) + }) + + // Migration 130 may create triggers that reference document_custom_metadata + // (Trigger 1 reads from it via EXISTS). If 130 has already landed, bringing + // 129 down while 130 is still attached would fail; the test engineer keeps + // this file ordered so 130 is torn down first. For now, 130 does not exist, + // so a plain down is fine. + _, err = pool.Exec(ctx, string(downSQL)) + require.NoError(t, err, "down migration 129 must apply cleanly") + + var tableOID *string + err = pool.QueryRow(ctx, + `SELECT to_regclass('document_custom_metadata')::text`).Scan(&tableOID) + require.NoError(t, err) + require.Nil(t, tableOID, + "document_custom_metadata table must be gone after down") + + _, err = pool.Exec(ctx, string(upSQL)) + require.NoError(t, err, "up migration 129 must re-apply cleanly") + + err = pool.QueryRow(ctx, + `SELECT to_regclass('document_custom_metadata')::text`).Scan(&tableOID) + require.NoError(t, err) + require.NotNil(t, tableOID, + "document_custom_metadata must exist again after re-applying up") +} + +// ---------- §2.2 migration 130 — schema invariant triggers ---------- + +// TestMigration130_TriggersExist asserts the presence of both trigger +// functions and their attachments per plan Section 4.7. Fails until +// migration 130 exists. +func TestMigration130_TriggersExist(t *testing.T) { + t.Parallel() + if testing.Short() { + t.SkipNow() + } + ctx := t.Context() + + cfg := &serviceconfig.BaseConfig{} + test.CreateDB(t, cfg) + pool := cfg.GetDBPool() + require.NotNil(t, pool) + + t.Run("trigger1_function_exists", func(t *testing.T) { + var exists bool + err := pool.QueryRow(ctx, ` + SELECT EXISTS ( + SELECT 1 FROM pg_proc p + JOIN pg_namespace n ON n.oid = p.pronamespace + WHERE n.nspname = 'public' + AND p.proname = 'trg_prevent_schema_reassignment' + ) + `).Scan(&exists) + require.NoError(t, err) + require.True(t, exists, + "trg_prevent_schema_reassignment() function must exist in pg_proc") + }) + + t.Run("trigger1_attached_to_documents_before_update", func(t *testing.T) { + // pg_trigger.tgtype is a bitmask. For BEFORE UPDATE the relevant + // bits are (TRIGGER_TYPE_BEFORE=2) | (TRIGGER_TYPE_UPDATE=16) = 18. + // We also check the row-level bit (TRIGGER_TYPE_ROW=1) is set. + var ( + exists bool + tgtype int16 + tgname string + ) + err := pool.QueryRow(ctx, ` + SELECT TRUE, t.tgtype, t.tgname + FROM pg_trigger t + JOIN pg_class c ON c.oid = t.tgrelid + JOIN pg_proc p ON p.oid = t.tgfoid + WHERE c.relname = 'documents' + AND p.proname = 'trg_prevent_schema_reassignment' + AND t.tgisinternal = FALSE + `).Scan(&exists, &tgtype, &tgname) + require.NoError(t, err, + "trigger bound to documents and calling trg_prevent_schema_reassignment must exist") + require.True(t, exists) + // BEFORE bit = 2, ROW bit = 1, UPDATE bit = 16. + require.NotZero(t, tgtype&int16(2), + "trigger must fire BEFORE the row write (tgtype BEFORE bit)") + require.NotZero(t, tgtype&int16(1), + "trigger must be ROW-level (tgtype ROW bit)") + require.NotZero(t, tgtype&int16(16), + "trigger must fire on UPDATE (tgtype UPDATE bit)") + }) + + t.Run("trigger2_function_exists", func(t *testing.T) { + var exists bool + err := pool.QueryRow(ctx, ` + SELECT EXISTS ( + SELECT 1 FROM pg_proc p + JOIN pg_namespace n ON n.oid = p.pronamespace + WHERE n.nspname = 'public' + AND p.proname = 'trg_prevent_legacy_extraction_on_custom_document' + ) + `).Scan(&exists) + require.NoError(t, err) + require.True(t, exists, + "trg_prevent_legacy_extraction_on_custom_document() function must exist") + }) + + t.Run("trigger2_attached_to_documentfieldextractions_before_insert", func(t *testing.T) { + // Postgres folds unquoted identifiers to lowercase, so the real + // stored relname is 'documentfieldextractions' (see plan migration + // 112 — the CREATE TABLE is unquoted). Any new DDL that references + // documents."documentFieldExtractions" would fail the same way the + // §1.3 composite FK did with the "clientId" case. + var ( + exists bool + tgtype int16 + ) + err := pool.QueryRow(ctx, ` + SELECT TRUE, t.tgtype + FROM pg_trigger t + JOIN pg_class c ON c.oid = t.tgrelid + JOIN pg_proc p ON p.oid = t.tgfoid + WHERE c.relname = 'documentfieldextractions' + AND p.proname = 'trg_prevent_legacy_extraction_on_custom_document' + AND t.tgisinternal = FALSE + `).Scan(&exists, &tgtype) + require.NoError(t, err, + "trigger bound to documentfieldextractions must exist") + require.True(t, exists) + // BEFORE bit = 2, ROW bit = 1, INSERT bit = 4. + require.NotZero(t, tgtype&int16(2), + "trigger 2 must fire BEFORE the insert") + require.NotZero(t, tgtype&int16(1), + "trigger 2 must be ROW-level") + require.NotZero(t, tgtype&int16(4), + "trigger 2 must fire on INSERT") + }) + + t.Run("v3_removed_triggers_absent", func(t *testing.T) { + // v4 intentionally does NOT have these two v3 triggers: + // - trg_validate_schema_client_match (replaced by composite FK) + // - trg_enforce_consistent_schema_id (column removed entirely) + // If a migration accidentally re-introduces either, the test fails + // with a pointer back to the plan decision. + var count int + err := pool.QueryRow(ctx, ` + SELECT COUNT(*) FROM pg_proc p + WHERE p.proname IN ( + 'trg_validate_schema_client_match', + 'trg_enforce_consistent_schema_id' + ) + `).Scan(&count) + require.NoError(t, err) + require.Zero(t, count, + "v4 intentionally omits trg_validate_schema_client_match and trg_enforce_consistent_schema_id — see plan Section 4.7 'Removed in v4' subsections") + }) +} + +// TestMigration130_Trigger1_RejectsReassignWhenMetadataExists covers the +// §2.2 Trigger 1 behavioural case #1: document has custom metadata, attempt +// to change custom_schema_id → RAISE EXCEPTION "custom metadata already +// exists". +func TestMig130_Trg1_RejectsReassignWhenMetadata(t *testing.T) { + t.Parallel() + if testing.Short() { + t.SkipNow() + } + ctx := t.Context() + + cfg := &serviceconfig.BaseConfig{} + test.CreateDB(t, cfg) + pool := cfg.GetDBPool() + require.NotNil(t, pool) + + const clientID = "TRG1_META" + resetClientForMilestone2Test(t, ctx, pool, clientID) + err := cfg.GetDBQueries().CreateClient(ctx, &repository.CreateClientParams{ + Name: "trg1-meta", + Clientid: clientID, + }) + require.NoError(t, err) + + // Seed two schemas so we can attempt a real reassignment. + schemaA := seedRawSchemaForMilestone2(t, ctx, pool, clientID, "trg1-meta-A") + schemaB := seedRawSchemaForMilestone2(t, ctx, pool, clientID, "trg1-meta-B") + + docID, err := cfg.GetDBQueries().CreateDocument(ctx, &repository.CreateDocumentParams{ + Clientid: clientID, + Hash: "trg1-meta-doc", + }) + require.NoError(t, err) + + // Bind document to schemaA. + _, err = pool.Exec(ctx, + `UPDATE documents SET custom_schema_id = $1 WHERE id = $2`, schemaA, docID) + require.NoError(t, err) + + // Write a metadata row. + _, err = pool.Exec(ctx, ` + INSERT INTO document_custom_metadata (document_id, metadata, version, created_by) + VALUES ($1, '{"k":"v"}'::jsonb, 1, 'tester') + `, docID) + require.NoError(t, err) + + // Attempt to reassign to schemaB — trigger must block. + _, err = pool.Exec(ctx, + `UPDATE documents SET custom_schema_id = $1 WHERE id = $2`, schemaB, docID) + require.Error(t, err, "reassignment must be rejected while metadata exists") + require.Contains(t, err.Error(), "custom metadata already exists", + "trigger 1 must raise with 'custom metadata already exists' message") +} + +// TestMigration130_Trigger1_RejectsReassignWhenLegacyExtractionsExist covers +// §2.2 Trigger 1 behavioural case #2. +func TestMig130_Trg1_RejectsReassignWhenLegacy(t *testing.T) { + t.Parallel() + if testing.Short() { + t.SkipNow() + } + ctx := t.Context() + + cfg := &serviceconfig.BaseConfig{} + test.CreateDB(t, cfg) + pool := cfg.GetDBPool() + require.NotNil(t, pool) + + const clientID = "TRG1_LEGACY" + resetClientForMilestone2Test(t, ctx, pool, clientID) + err := cfg.GetDBQueries().CreateClient(ctx, &repository.CreateClientParams{ + Name: "trg1-legacy", + Clientid: clientID, + }) + require.NoError(t, err) + + schemaA := seedRawSchemaForMilestone2(t, ctx, pool, clientID, "trg1-legacy-A") + + docID, err := cfg.GetDBQueries().CreateDocument(ctx, &repository.CreateDocumentParams{ + Clientid: clientID, + Hash: "trg1-legacy-doc", + }) + require.NoError(t, err) + + // Insert a legacy extraction version row. The table is + // documentFieldExtractionVersions per plan Section 4.7 Trigger 1. + // Postgres stored the unquoted identifier as 'documentfieldextractionversions'. + insertExtractionVersionForMilestone2(t, ctx, pool, docID) + + // Attempt to assign a schema — trigger must block because legacy + // extractions already exist. Note: this is a NULL -> NOT-NULL change, + // which Trigger 1 also blocks (OLD.custom_schema_id IS DISTINCT FROM + // NEW.custom_schema_id evaluates true when OLD is NULL). + _, err = pool.Exec(ctx, + `UPDATE documents SET custom_schema_id = $1 WHERE id = $2`, schemaA, docID) + require.Error(t, err, "assignment must be rejected while legacy extractions exist") + require.Contains(t, err.Error(), "legacy field extractions already exist", + "trigger 1 must raise with 'legacy field extractions already exist' message") +} + +// TestMigration130_Trigger1_AllowsPreBindingChange covers §2.2 Trigger 1 +// behavioural case #3: document with no metadata and no extractions can +// freely change custom_schema_id from one schema to another. +func TestMigration130_Trigger1_AllowsPreBindingChange(t *testing.T) { + t.Parallel() + if testing.Short() { + t.SkipNow() + } + ctx := t.Context() + + cfg := &serviceconfig.BaseConfig{} + test.CreateDB(t, cfg) + pool := cfg.GetDBPool() + require.NotNil(t, pool) + + const clientID = "TRG1_PRE" + resetClientForMilestone2Test(t, ctx, pool, clientID) + err := cfg.GetDBQueries().CreateClient(ctx, &repository.CreateClientParams{ + Name: "trg1-pre", + Clientid: clientID, + }) + require.NoError(t, err) + + schemaA := seedRawSchemaForMilestone2(t, ctx, pool, clientID, "trg1-pre-A") + schemaB := seedRawSchemaForMilestone2(t, ctx, pool, clientID, "trg1-pre-B") + + docID, err := cfg.GetDBQueries().CreateDocument(ctx, &repository.CreateDocumentParams{ + Clientid: clientID, + Hash: "trg1-pre-doc", + }) + require.NoError(t, err) + + // Bind to schemaA. + _, err = pool.Exec(ctx, + `UPDATE documents SET custom_schema_id = $1 WHERE id = $2`, schemaA, docID) + require.NoError(t, err) + + // No metadata, no extractions. Rebinding to schemaB must succeed. + _, err = pool.Exec(ctx, + `UPDATE documents SET custom_schema_id = $1 WHERE id = $2`, schemaB, docID) + require.NoError(t, err, + "rebinding before any metadata or extractions exist must succeed") + + // Verify the DB state is now schemaB. + var bound uuid.UUID + err = pool.QueryRow(ctx, + `SELECT custom_schema_id FROM documents WHERE id = $1`, docID).Scan(&bound) + require.NoError(t, err) + require.Equal(t, schemaB, bound) +} + +// TestMigration130_Trigger2_RejectsLegacyInsertOnCustomDocument covers §2.2 +// Trigger 2 behavioural case #1: document has custom_schema_id set, attempt +// direct INSERT into documentFieldExtractions → RAISE EXCEPTION with +// SQLSTATE 23514 (check_violation). +func TestMig130_Trg2_RejectsLegacyOnCustomDoc(t *testing.T) { + t.Parallel() + if testing.Short() { + t.SkipNow() + } + ctx := t.Context() + + cfg := &serviceconfig.BaseConfig{} + test.CreateDB(t, cfg) + pool := cfg.GetDBPool() + require.NotNil(t, pool) + + const clientID = "TRG2_CUSTOM" + resetClientForMilestone2Test(t, ctx, pool, clientID) + err := cfg.GetDBQueries().CreateClient(ctx, &repository.CreateClientParams{ + Name: "trg2-custom", + Clientid: clientID, + }) + require.NoError(t, err) + + schemaA := seedRawSchemaForMilestone2(t, ctx, pool, clientID, "trg2-custom-A") + + docID, err := cfg.GetDBQueries().CreateDocument(ctx, &repository.CreateDocumentParams{ + Clientid: clientID, + Hash: "trg2-custom-doc", + }) + require.NoError(t, err) + + // Bind document to schema. + _, err = pool.Exec(ctx, + `UPDATE documents SET custom_schema_id = $1 WHERE id = $2`, schemaA, docID) + require.NoError(t, err) + + // Attempt to insert a legacy extraction — trigger 2 must block. + // createdBy is required by the table (NOT NULL varchar(255)). + _, err = pool.Exec(ctx, + `INSERT INTO documentFieldExtractions (documentId, createdBy) VALUES ($1, 'tester')`, + docID) + require.Error(t, err, "legacy extraction insert must be rejected for custom-bound document") + + var pgErr *pgconn.PgError + require.True(t, errors.As(err, &pgErr), + "expected *pgconn.PgError, got %T: %v", err, err) + require.Equal(t, "23514", pgErr.Code, + "trigger 2 must raise with ERRCODE='check_violation' (SQLSTATE 23514)") + require.Contains(t, err.Error(), "bound to custom schema", + "trigger 2 error message must include 'bound to custom schema'") +} + +// TestMigration130_Trigger2_AllowsLegacyInsertOnLegacyDocument covers §2.2 +// Trigger 2 behavioural case #2 (no-op guard path): document has +// custom_schema_id NULL, legacy insert must succeed. +func TestMig130_Trg2_AllowsLegacyOnLegacyDoc(t *testing.T) { + t.Parallel() + if testing.Short() { + t.SkipNow() + } + ctx := t.Context() + + cfg := &serviceconfig.BaseConfig{} + test.CreateDB(t, cfg) + pool := cfg.GetDBPool() + require.NotNil(t, pool) + + const clientID = "TRG2_LEGACY" + resetClientForMilestone2Test(t, ctx, pool, clientID) + err := cfg.GetDBQueries().CreateClient(ctx, &repository.CreateClientParams{ + Name: "trg2-legacy", + Clientid: clientID, + }) + require.NoError(t, err) + + docID, err := cfg.GetDBQueries().CreateDocument(ctx, &repository.CreateDocumentParams{ + Clientid: clientID, + Hash: "trg2-legacy-doc", + }) + require.NoError(t, err) + + // custom_schema_id stays NULL. Insert should succeed. createdBy is + // required by the table (NOT NULL varchar(255)). + _, err = pool.Exec(ctx, + `INSERT INTO documentFieldExtractions (documentId, createdBy) VALUES ($1, 'tester')`, + docID) + require.NoError(t, err, + "legacy extraction insert on NULL-custom-schema document must succeed") +} + +// TestMigration130_Trigger2_ConcurrencyBlocksInsertDuringAssign covers §2.2 +// Trigger 2 concurrency case: +// - tx A takes SELECT documents FOR UPDATE on the target document, then +// UPDATE documents SET custom_schema_id = S (the AssignSchema pattern). +// - tx B attempts INSERT documentFieldExtractions on the same document. +// +// Expected: tx B's trigger function reads documents.custom_schema_id with +// FOR SHARE, which blocks until tx A commits. After tx A commits, tx B's +// trigger observes custom_schema_id IS NOT NULL and raises check_violation. +// +// This is the defense-in-depth proof for the race described in plan +// Section 7.3. +func TestMig130_Trg2_ConcurrencyBlocksInsert(t *testing.T) { + t.Parallel() + if testing.Short() { + t.SkipNow() + } + ctx := t.Context() + + cfg := &serviceconfig.BaseConfig{} + test.CreateDB(t, cfg) + pool := cfg.GetDBPool() + require.NotNil(t, pool) + + const clientID = "TRG2_RACE" + resetClientForMilestone2Test(t, ctx, pool, clientID) + err := cfg.GetDBQueries().CreateClient(ctx, &repository.CreateClientParams{ + Name: "trg2-race", + Clientid: clientID, + }) + require.NoError(t, err) + + schemaA := seedRawSchemaForMilestone2(t, ctx, pool, clientID, "trg2-race-A") + + docID, err := cfg.GetDBQueries().CreateDocument(ctx, &repository.CreateDocumentParams{ + Clientid: clientID, + Hash: "trg2-race-doc", + }) + require.NoError(t, err) + + // Begin two transactions on separate connections via pool.Begin — + // pgxpool allocates a fresh connection per Begin, so the two tx's + // do not share the same underlying session and the FOR SHARE / FOR + // UPDATE interaction runs against real concurrency semantics. + txA, err := pool.Begin(ctx) + require.NoError(t, err) + defer func() { _ = txA.Rollback(ctx) }() + + var lockedID uuid.UUID + err = txA.QueryRow(ctx, + `SELECT id FROM documents WHERE id = $1 FOR UPDATE`, docID).Scan(&lockedID) + require.NoError(t, err) + + // Do the UPDATE inside tx A but DON'T commit yet. + _, err = txA.Exec(ctx, + `UPDATE documents SET custom_schema_id = $1 WHERE id = $2`, schemaA, docID) + require.NoError(t, err) + + // Fire tx B on a separate goroutine. Its INSERT trigger will take + // FOR SHARE on the documents row and block until tx A commits. + var wg sync.WaitGroup + insertErrCh := make(chan error, 1) + wg.Add(1) + go func() { + defer wg.Done() + // Use a fresh context with a generous timeout so this goroutine + // cannot hang the test forever if the trigger doesn't block. + gctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + txB, err := pool.Begin(gctx) + if err != nil { + insertErrCh <- err + return + } + defer func() { _ = txB.Rollback(gctx) }() + _, err = txB.Exec(gctx, + `INSERT INTO documentFieldExtractions (documentId, createdBy) VALUES ($1, 'tester')`, + docID) + if err != nil { + insertErrCh <- err + return + } + // If the INSERT itself succeeded, the test will fail on the + // downstream `require.Error` below. But if it did succeed, we + // still need to roll back so we don't leak a commit. + insertErrCh <- nil + }() + + // Give the goroutine a moment to actually block on the FOR SHARE lock + // before we commit tx A. 250ms is generous — a normal lock acquisition + // is microseconds, but we want to see the trigger's FOR SHARE enter + // the wait state rather than race us to an error reply. + time.Sleep(250 * time.Millisecond) + + // Before committing, verify the INSERT has NOT yet completed — if it + // has, the trigger's FOR SHARE lock is missing. + select { + case err := <-insertErrCh: + t.Fatalf("tx B's INSERT returned too early (before tx A committed): %v", err) + default: + } + + // Commit tx A — this releases the FOR UPDATE lock and lets tx B's + // trigger proceed with its FOR SHARE read, see custom_schema_id set, + // and raise. + require.NoError(t, txA.Commit(ctx)) + + // Wait for tx B to finish and verify it reported the check_violation. + wg.Wait() + insertErr := <-insertErrCh + require.Error(t, insertErr, + "tx B's INSERT must be rejected after tx A committed the schema assign") + + var pgErr *pgconn.PgError + require.True(t, errors.As(insertErr, &pgErr), + "expected *pgconn.PgError, got %T: %v", insertErr, insertErr) + require.Equal(t, "23514", pgErr.Code, + "expected check_violation SQLSTATE 23514 from trigger 2") +} + +// TestMigration130_UpDownRoundtrip proves the migration 130 down.sql is +// complete: triggers and functions are all removed on down, re-added on up. +func TestMigration130_UpDownRoundtrip(t *testing.T) { + t.Parallel() + if testing.Short() { + t.SkipNow() + } + ctx := t.Context() + + cfg := &serviceconfig.BaseConfig{} + test.CreateDB(t, cfg) + pool := cfg.GetDBPool() + require.NotNil(t, pool) + + const ( + upPath = "../migrations/00000000000130_add_schema_invariant_triggers.up.sql" + downPath = "../migrations/00000000000130_add_schema_invariant_triggers.down.sql" + ) + upSQL, err := os.ReadFile(upPath) + require.NoError(t, err, "must be able to read migration 130 up.sql") + downSQL, err := os.ReadFile(downPath) + require.NoError(t, err, "must be able to read migration 130 down.sql") + + // Leave DB in migrated state on test exit. + t.Cleanup(func() { + _, _ = pool.Exec(context.Background(), string(upSQL)) + }) + + // 1. Down. + _, err = pool.Exec(ctx, string(downSQL)) + require.NoError(t, err, "down migration 130 must apply cleanly") + + for _, proname := range []string{ + "trg_prevent_schema_reassignment", + "trg_prevent_legacy_extraction_on_custom_document", + } { + var exists bool + err = pool.QueryRow(ctx, ` + SELECT EXISTS (SELECT 1 FROM pg_proc WHERE proname = $1) + `, proname).Scan(&exists) + require.NoError(t, err) + require.Falsef(t, exists, + "%s must be gone after down migration 130", proname) + } + + // 2. Back up. + _, err = pool.Exec(ctx, string(upSQL)) + require.NoError(t, err, "up migration 130 must re-apply cleanly") + + for _, proname := range []string{ + "trg_prevent_schema_reassignment", + "trg_prevent_legacy_extraction_on_custom_document", + } { + var exists bool + err = pool.QueryRow(ctx, ` + SELECT EXISTS (SELECT 1 FROM pg_proc WHERE proname = $1) + `, proname).Scan(&exists) + require.NoError(t, err) + require.Truef(t, exists, + "%s must exist again after re-running up migration 130", proname) + } +} + +// ---------- helpers ---------- + +// resetClientForMilestone2Test wipes every row owned by clientID in the +// order required by the FK graph so the test starts from a clean slate +// even when the testcontainer Postgres volume is reused across runs. +func resetClientForMilestone2Test(t testing.TB, ctx context.Context, pool queryable, clientID string) { + t.Helper() + // queryable does not expose Exec, so grab it via type assertion. The + // real *pgxpool.Pool implements Exec; we assert to that shape here. + type execer interface { + Exec(ctx context.Context, sql string, args ...any) (pgconn.CommandTag, error) + } + pe, ok := pool.(execer) + require.True(t, ok, "pool does not implement Exec") + + stmts := []string{ + `DELETE FROM document_custom_metadata + WHERE document_id IN (SELECT id FROM documents WHERE clientId = $1)`, + `DELETE FROM documentFieldExtractionVersions + WHERE documentId IN (SELECT id FROM documents WHERE clientId = $1)`, + `DELETE FROM documentFieldExtractions + WHERE documentId IN (SELECT id FROM documents WHERE clientId = $1)`, + `UPDATE documents SET custom_schema_id = NULL WHERE clientId = $1`, + `DELETE FROM client_metadata_schemas WHERE client_id = $1`, + `DELETE FROM documentEntries + WHERE documentId IN (SELECT id FROM documents WHERE clientId = $1)`, + `DELETE FROM documents WHERE clientId = $1`, + `DELETE FROM folders WHERE clientId = $1`, + `DELETE FROM clientCanSync WHERE clientId = $1`, + `DELETE FROM clients WHERE clientId = $1`, + } + for _, stmt := range stmts { + _, err := pe.Exec(ctx, stmt, clientID) + // Some DELETEs may fail if the table hasn't been created yet + // (e.g., document_custom_metadata before migration 129 lands). + // The failing tests below are the ones that need the table to + // exist; those tests will fail on a later assertion rather than + // here. Swallow "relation does not exist" errors so the reset + // never masks the real reason a test fails. + if err != nil && !strings.Contains(err.Error(), "does not exist") { + t.Fatalf("reset stmt failed: %s\nerr: %v", stmt, err) + } + } +} + +// seedRawSchemaForMilestone2 inserts a client_metadata_schemas row directly +// (skipping the customschema.Service path so this test has no dependency on +// Milestone 1 wiring). Returns the new schema row ID. Each call gets a +// unique `name` so repeated invocations inside one test don't trip +// uq_client_schema_name_version. +func seedRawSchemaForMilestone2(t testing.TB, ctx context.Context, pool queryable, clientID, name string) uuid.UUID { + t.Helper() + type execer interface { + QueryRow(ctx context.Context, sql string, args ...any) pgx.Row + } + pe, ok := pool.(execer) + require.True(t, ok, "pool does not implement QueryRow") + + const sql = ` + INSERT INTO client_metadata_schemas + (client_id, name, schema_def, version, created_by) + VALUES ($1, $2, $3::jsonb, 1, 'tester') + RETURNING id + ` + var id uuid.UUID + err := pe.QueryRow(ctx, sql, clientID, name, `{"type":"object","additionalProperties":false}`).Scan(&id) + require.NoError(t, err) + return id +} + +// insertExtractionVersionForMilestone2 inserts a legacy field extraction row +// and its matching version row for the given document. Trigger 1's "legacy +// field extractions already exist" branch checks documentFieldExtractionVersions +// (which is what `HasFieldExtraction` reuses in the service layer), so we must +// land both the parent documentFieldExtractions row (required by FK) and the +// version row the trigger actually looks for. +// +// The minimum column set for each table is exercised here so the helper works +// against the real DB without touching sqlc. documentFieldExtractions has +// `createdBy varchar(255) NOT NULL`, and documentFieldExtractionVersions has +// `fieldExtractionId uuid NOT NULL` plus `version bigint NOT NULL` plus +// `createdBy varchar(255) NOT NULL` (see migrations 112/113/117). +// +// **Important**: this helper is only ever called BEFORE the document has a +// custom_schema_id bound. Trigger 2 (created in migration 130) would block +// the documentFieldExtractions INSERT otherwise. +func insertExtractionVersionForMilestone2(t testing.TB, ctx context.Context, pool queryable, docID uuid.UUID) { + t.Helper() + type execer interface { + Exec(ctx context.Context, sql string, args ...any) (pgconn.CommandTag, error) + QueryRow(ctx context.Context, sql string, args ...any) pgx.Row + } + pe, ok := pool.(execer) + require.True(t, ok) + + var feID uuid.UUID + err := pe.QueryRow(ctx, + `INSERT INTO documentFieldExtractions (documentId, createdBy) VALUES ($1, $2) RETURNING id`, + docID, "tester").Scan(&feID) + require.NoError(t, err, "seed a legacy field extractions row") + + _, err = pe.Exec(ctx, + `INSERT INTO documentFieldExtractionVersions (fieldExtractionId, documentId, version, createdBy) + VALUES ($1, $2, $3, $4)`, + feID, docID, 1, "tester") + require.NoError(t, err, "seed a legacy extraction version row") +} + +// fetchDCMConstraintColumns returns the column list for a constraint on +// document_custom_metadata. If conname is empty, it takes the first match +// of the requested contype (used for the PRIMARY KEY lookup where the +// generated pk_ name is not worth hard-coding). +func fetchDCMConstraintColumns(ctx context.Context, q queryable, wantContype, conname string) ([]string, error) { + var rows pgx.Rows + var err error + if conname == "" { + rows, err = q.Query(ctx, ` + SELECT a.attname + FROM pg_constraint c + JOIN pg_class t ON t.oid = c.conrelid + JOIN unnest(c.conkey) WITH ORDINALITY AS k(attnum, ord) ON TRUE + JOIN pg_attribute a ON a.attrelid = c.conrelid AND a.attnum = k.attnum + WHERE t.relname = 'document_custom_metadata' + AND c.contype = $1 + ORDER BY k.ord + `, wantContype) + } else { + rows, err = q.Query(ctx, ` + SELECT a.attname + FROM pg_constraint c + JOIN pg_class t ON t.oid = c.conrelid + JOIN unnest(c.conkey) WITH ORDINALITY AS k(attnum, ord) ON TRUE + JOIN pg_attribute a ON a.attrelid = c.conrelid AND a.attnum = k.attnum + WHERE c.conname = $1 + AND t.relname = 'document_custom_metadata' + AND c.contype = $2 + ORDER BY k.ord + `, conname, wantContype) + } + if err != nil { + return nil, err + } + defer rows.Close() + + var cols []string + for rows.Next() { + var name string + if err := rows.Scan(&name); err != nil { + return nil, err + } + cols = append(cols, name) + } + return cols, rows.Err() +} diff --git a/internal/database/repository/milestone2_sqlc_queries_test.go b/internal/database/repository/milestone2_sqlc_queries_test.go new file mode 100644 index 00000000..a6445d4f --- /dev/null +++ b/internal/database/repository/milestone2_sqlc_queries_test.go @@ -0,0 +1,166 @@ +//go:build milestone2 + +// Package repository_test — Milestone 2 §2.3 failing SQLC query presence +// tests. +// +// Build tag: gated behind `milestone2` so the default `task fullsuite:ci` +// run does not execute it. See the comment in milestone2_migrations_test.go +// for the workflow the golang engineer follows to flip these tests from +// RED to GREEN. +// +// These tests prove that the next batch of SQLC queries required by +// Milestone 2 (plan Section 6.3 / tracking §2.3) are available on the +// generated *Queries type once the golang engineer runs `task generate` +// against a new internal/database/queries/custommetadata.sql query file. +// +// They use reflection to introspect the *Queries receiver rather than +// calling the methods directly so the test file can compile before the +// queries exist — a hard compile-time reference would break the whole +// package build and the rest of Milestone 1's tests with it. +// +// When the golang engineer adds the new SQL query blocks and re-runs +// `task generate`, sqlc will emit the corresponding Go methods and +// these tests will flip from failing to passing without any further +// edits. +package repository_test + +import ( + "reflect" + "testing" + + "queryorchestration/internal/database/repository" + + "github.com/stretchr/testify/require" +) + +// milestone2RequiredQueries is the full list of new SQLC-generated +// method names that §2.3 of the v4 tracking doc requires. Every row +// maps to a block in the forthcoming internal/database/queries/custommetadata.sql +// file. The comment after each name traces back to the tracking line. +// +// Deliberately NOT in this list (see the "v4" notes in the tracking doc): +// - DocumentHasLegacyExtractions: reuse existing HasFieldExtraction +// - DeleteDocumentCustomMetadata: FK cascade replaces it +// - NullifyDocumentCustomSchemaId: reset-metadata uses a direct UPDATE +// - DeleteClientMetadataSchemas: FK cascade replaces it +// - LockDocumentCustomMetadataForVersion: v3 name, replaced by +// LockDocumentForMetadataWrite +var milestone2RequiredQueries = []struct { + method string + trackRef string +}{ + { + method: "CreateDocumentCustomMetadata", + trackRef: "§2.3 CreateDocumentCustomMetadata (no schema_id parameter)", + }, + { + method: "GetCurrentDocumentCustomMetadata", + trackRef: "§2.3 GetCurrentDocumentCustomMetadata (ORDER BY version DESC LIMIT 1, joins documents + client_metadata_schemas)", + }, + { + method: "GetDocumentCustomMetadataByVersion", + trackRef: "§2.3 GetDocumentCustomMetadataByVersion (same join)", + }, + { + method: "GetDocumentCustomMetadataHistory", + trackRef: "§2.3 GetDocumentCustomMetadataHistory (no schema join)", + }, + { + method: "LockDocumentForMetadataWrite", + trackRef: "§2.3 LockDocumentForMetadataWrite (parent-row lock replacing v3's LockDocumentCustomMetadataForVersion)", + }, + { + method: "GetMaxDocumentMetadataVersion", + trackRef: "§2.3 GetMaxDocumentMetadataVersion (COALESCE(MAX(version), 0))", + }, + { + method: "SetDocumentCustomSchemaId", + trackRef: "§2.3 SetDocumentCustomSchemaId", + }, + { + method: "GetDocumentCustomSchemaId", + trackRef: "§2.3 GetDocumentCustomSchemaId", + }, +} + +// TestMilestone2_RequiredSQLCQueries asserts every method in +// milestone2RequiredQueries is present on the generated *repository.Queries +// type. Uses reflection so this file compiles even before the query file +// lands — a hard Go reference would break the whole test binary and +// every other Milestone 1 test with it. +// +// This test FAILS intentionally until the golang engineer adds the query +// blocks to internal/database/queries/custommetadata.sql and runs +// `task generate`. +func TestMilestone2_RequiredSQLCQueries(t *testing.T) { + t.Parallel() + // No DB needed — this is a pure type-introspection check. + // We still run under short mode since the assertion is a static + // shape check and should be part of the fast unit sweep. + + var q repository.Queries + qType := reflect.TypeOf(&q) + + for _, want := range milestone2RequiredQueries { + want := want + t.Run(want.method, func(t *testing.T) { + t.Parallel() + _, ok := qType.MethodByName(want.method) + require.Truef(t, ok, + "generated *repository.Queries must have method %q — source of truth: %s. "+ + "Add the SQL block to internal/database/queries/custommetadata.sql and run `task generate`.", + want.method, want.trackRef) + }) + } +} + +// TestMilestone2_RemovedV3QueriesAbsent asserts the v3 query names that +// v4 deliberately dropped never sneak back into the generated code. If a +// future contributor copies a v3 snippet into custommetadata.sql these +// assertions will fail loudly with a pointer to the tracking doc's +// "v4 removed" notes. +func TestMilestone2_RemovedV3QueriesAbsent(t *testing.T) { + t.Parallel() + forbidden := []struct { + method string + why string + }{ + { + method: "DocumentHasLegacyExtractions", + why: "v4 reuses existing HasFieldExtraction — see §2.3 'Reuse existing query'", + }, + { + method: "DeleteDocumentCustomMetadata", + why: "v4 relies on ON DELETE CASCADE via the document_id FK", + }, + { + method: "NullifyDocumentCustomSchemaId", + why: "v4 resets via direct UPDATE documents SET custom_schema_id = NULL", + }, + { + method: "DeleteClientMetadataSchemas", + why: "v4 relies on ON DELETE CASCADE via the client_id FK", + }, + { + method: "LockDocumentCustomMetadataForVersion", + why: "v4 uses LockDocumentForMetadataWrite (parent-row lock) instead", + }, + { + method: "GetSchemaMetadataRecordCount", + why: "v4 computes canDelete from GetSchemaDocumentCount alone", + }, + } + + var q repository.Queries + qType := reflect.TypeOf(&q) + for _, bad := range forbidden { + bad := bad + t.Run(bad.method, func(t *testing.T) { + t.Parallel() + _, ok := qType.MethodByName(bad.method) + require.Falsef(t, ok, + "*repository.Queries must NOT have method %q — %s", + bad.method, bad.why) + }) + } +} diff --git a/internal/database/repository/models.go b/internal/database/repository/models.go index f5acf815..eb224f1a 100644 --- a/internal/database/repository/models.go +++ b/internal/database/repository/models.go @@ -284,6 +284,59 @@ func (e Cleanmimetype) Valid() bool { return false } +type SchemaStatusType string + +const ( + SchemaStatusTypeActive SchemaStatusType = "active" + SchemaStatusTypeSuperseded SchemaStatusType = "superseded" + SchemaStatusTypeRetired SchemaStatusType = "retired" +) + +func (e *SchemaStatusType) Scan(src interface{}) error { + switch s := src.(type) { + case []byte: + *e = SchemaStatusType(s) + case string: + *e = SchemaStatusType(s) + default: + return fmt.Errorf("unsupported scan type for SchemaStatusType: %T", src) + } + return nil +} + +type NullSchemaStatusType struct { + SchemaStatusType SchemaStatusType + Valid bool // Valid is true if SchemaStatusType is not NULL +} + +// Scan implements the Scanner interface. +func (ns *NullSchemaStatusType) Scan(value interface{}) error { + if value == nil { + ns.SchemaStatusType, ns.Valid = "", false + return nil + } + ns.Valid = true + return ns.SchemaStatusType.Scan(value) +} + +// Value implements the driver Valuer interface. +func (ns NullSchemaStatusType) Value() (driver.Value, error) { + if !ns.Valid { + return nil, nil + } + return string(ns.SchemaStatusType), nil +} + +func (e SchemaStatusType) Valid() bool { + switch e { + case SchemaStatusTypeActive, + SchemaStatusTypeSuperseded, + SchemaStatusTypeRetired: + return true + } + return false +} + type BatchDocumentOutcome struct { ID uuid.UUID `db:"id"` BatchID uuid.UUID `db:"batch_id"` @@ -318,6 +371,18 @@ type Client struct { Name string `db:"name"` } +type ClientMetadataSchema struct { + ID uuid.UUID `db:"id"` + ClientID string `db:"client_id"` + Name string `db:"name"` + Description *string `db:"description"` + SchemaDef []byte `db:"schema_def"` + Version int32 `db:"version"` + Status SchemaStatusType `db:"status"` + CreatedAt pgtype.Timestamptz `db:"created_at"` + CreatedBy string `db:"created_by"` +} + type Clientcansync struct { Syncid uuid.UUID `db:"syncid"` Clientid string `db:"clientid"` @@ -437,8 +502,18 @@ type Document struct { Filename *string `db:"filename"` Folderid *uuid.UUID `db:"folderid"` // Original path provided during upload. IMMUTABLE after creation - never modify this value. - Originalpath *string `db:"originalpath"` - FileSizeBytes *int64 `db:"file_size_bytes"` + Originalpath *string `db:"originalpath"` + FileSizeBytes *int64 `db:"file_size_bytes"` + CustomSchemaID *uuid.UUID `db:"custom_schema_id"` +} + +type DocumentCustomMetadatum struct { + ID uuid.UUID `db:"id"` + DocumentID uuid.UUID `db:"document_id"` + Metadata []byte `db:"metadata"` + Version int32 `db:"version"` + CreatedAt pgtype.Timestamptz `db:"created_at"` + CreatedBy string `db:"created_by"` } type Documentclean struct { diff --git a/internal/document/get.go b/internal/document/get.go index 530654e1..4779f773 100644 --- a/internal/document/get.go +++ b/internal/document/get.go @@ -82,15 +82,17 @@ func (s *Service) GetEnriched(ctx context.Context, id uuid.UUID, includeTextReco } result := &DocumentEnriched{ - ID: docExternal.ID, - ClientID: docExternal.Clientid, - Hash: docExternal.Hash, - HasTextRecord: hasTextRecord, - FolderID: docEnriched.Folderid, - Filename: docEnriched.Filename, - OriginalPath: docEnriched.Originalpath, - Labels: labels, - FileSizeBytes: docEnriched.FileSizeBytes, + ID: docExternal.ID, + ClientID: docExternal.Clientid, + Hash: docExternal.Hash, + HasTextRecord: hasTextRecord, + FolderID: docEnriched.Folderid, + Filename: docEnriched.Filename, + OriginalPath: docEnriched.Originalpath, + Labels: labels, + FileSizeBytes: docEnriched.FileSizeBytes, + CustomSchemaID: docEnriched.CustomSchemaID, + HasCustomMetadata: docEnriched.HasCustomMetadata, } // 5. Optionally fetch full text record if requested and exists diff --git a/internal/document/get_test.go b/internal/document/get_test.go index d2befd49..08fff3ae 100644 --- a/internal/document/get_test.go +++ b/internal/document/get_test.go @@ -1,6 +1,7 @@ package document_test import ( + "context" "testing" "queryorchestration/internal/database/repository" @@ -210,3 +211,110 @@ func TestGetEnriched(t *testing.T) { require.Error(t, err) }) } + +// TestGetEnriched_CustomSchemaFields covers Milestone 2.3a: the +// DocumentEnriched struct surfaces CustomSchemaID and HasCustomMetadata +// populated from the extended GetDocumentEnriched query in a single +// round-trip. Three fixture shapes exercise every branch of the two new +// fields. +func TestGetEnriched_CustomSchemaFields(t *testing.T) { + ctx := t.Context() + cfg := &TestConfig{} + test.CreateDB(t, cfg) + svc := document.New(cfg) + + clientID := "custom-schema-enriched" + // testcontainer pg instances are reused across runs; wipe the client's + // rows before re-seeding so the test is idempotent. + resetClientForEnrichedTest(t, ctx, cfg, clientID) + require.NoError(t, cfg.GetDBQueries().CreateClient(ctx, &repository.CreateClientParams{ + Clientid: clientID, + Name: "Custom Schema Enriched", + })) + + // Seed one active schema for this client via direct SQL (avoids a + // hard dependency on the customschema package in the document tests). + var schemaID uuid.UUID + require.NoError(t, cfg.GetDBPool().QueryRow(ctx, ` + INSERT INTO client_metadata_schemas (client_id, name, schema_def, version, created_by) + VALUES ($1, $2, $3::jsonb, 1, 'tester') + RETURNING id + `, clientID, "enriched-fields", `{"type":"object","additionalProperties":false}`).Scan(&schemaID)) + + t.Run("document with no schema bound", func(t *testing.T) { + docID, err := cfg.GetDBQueries().CreateDocument(ctx, &repository.CreateDocumentParams{ + Clientid: clientID, + Hash: "custom-no-schema", + }) + require.NoError(t, err) + + result, err := svc.GetEnriched(ctx, docID, false) + require.NoError(t, err) + assert.Nil(t, result.CustomSchemaID, "unbound document must have CustomSchemaID = nil") + assert.False(t, result.HasCustomMetadata, "unbound document must have HasCustomMetadata = false") + }) + + t.Run("document with schema bound but no metadata", func(t *testing.T) { + docID, err := cfg.GetDBQueries().CreateDocument(ctx, &repository.CreateDocumentParams{ + Clientid: clientID, + Hash: "custom-schema-no-metadata", + }) + require.NoError(t, err) + _, err = cfg.GetDBPool().Exec(ctx, + `UPDATE documents SET custom_schema_id = $1 WHERE id = $2`, schemaID, docID) + require.NoError(t, err) + + result, err := svc.GetEnriched(ctx, docID, false) + require.NoError(t, err) + require.NotNil(t, result.CustomSchemaID, "bound document must carry CustomSchemaID") + assert.Equal(t, schemaID, *result.CustomSchemaID) + assert.False(t, result.HasCustomMetadata, "bound document with no metadata must be false") + }) + + t.Run("document with schema and at least one metadata row", func(t *testing.T) { + docID, err := cfg.GetDBQueries().CreateDocument(ctx, &repository.CreateDocumentParams{ + Clientid: clientID, + Hash: "custom-schema-with-metadata", + }) + require.NoError(t, err) + _, err = cfg.GetDBPool().Exec(ctx, + `UPDATE documents SET custom_schema_id = $1 WHERE id = $2`, schemaID, docID) + require.NoError(t, err) + _, err = cfg.GetDBPool().Exec(ctx, ` + INSERT INTO document_custom_metadata (document_id, metadata, version, created_by) + VALUES ($1, '{"k":"v"}'::jsonb, 1, 'tester') + `, docID) + require.NoError(t, err) + + result, err := svc.GetEnriched(ctx, docID, false) + require.NoError(t, err) + require.NotNil(t, result.CustomSchemaID) + assert.Equal(t, schemaID, *result.CustomSchemaID) + assert.True(t, result.HasCustomMetadata, "document with metadata row must be true") + }) +} + +// resetClientForEnrichedTest wipes every row owned by clientID in the order +// required by the FK graph so the test starts clean even when the +// testcontainer Postgres volume is reused across runs. Mirrors the pattern +// from internal/database/repository/milestone2_migrations_test.go. +func resetClientForEnrichedTest(t testing.TB, ctx context.Context, cfg serviceconfig.ConfigProvider, clientID string) { + t.Helper() + pool := cfg.GetDBPool() + stmts := []string{ + `DELETE FROM document_custom_metadata + WHERE document_id IN (SELECT id FROM documents WHERE clientId = $1)`, + `UPDATE documents SET custom_schema_id = NULL WHERE clientId = $1`, + `DELETE FROM client_metadata_schemas WHERE client_id = $1`, + `DELETE FROM documentEntries + WHERE documentId IN (SELECT id FROM documents WHERE clientId = $1)`, + `DELETE FROM documents WHERE clientId = $1`, + `DELETE FROM folders WHERE clientId = $1`, + `DELETE FROM clientCanSync WHERE clientId = $1`, + `DELETE FROM clients WHERE clientId = $1`, + } + for _, stmt := range stmts { + _, err := pool.Exec(ctx, stmt, clientID) + require.NoErrorf(t, err, "reset stmt failed: %s", stmt) + } +} diff --git a/internal/document/service.go b/internal/document/service.go index 3b330dd0..d80423eb 100644 --- a/internal/document/service.go +++ b/internal/document/service.go @@ -26,17 +26,26 @@ type DocumentExternal struct { // DocumentEnriched contains full document details including metadata, labels, and optionally text record. // Used by GET /document/{id} endpoint. // Note: Query functionality (Fields) has been removed. See remove_query_plan.md for details. +// +// Milestone 2.3a adds two fields for the mutable-metadata feature: +// - CustomSchemaID is the bound schema id (nil when the document has no schema). +// - HasCustomMetadata is true iff document_custom_metadata has >=1 row for the document. +// +// Both values are populated from the extended GetDocumentEnriched query in +// one round-trip; no follow-up DB call is made. type DocumentEnriched struct { - ID uuid.UUID - ClientID string - Hash string - HasTextRecord bool - FolderID *uuid.UUID - Filename *string - OriginalPath *string - Labels []LabelRecord - TextRecord *TextRecord // Only populated when includeTextRecord=true - FileSizeBytes *int64 // File size in bytes (nil for legacy documents) + ID uuid.UUID + ClientID string + Hash string + HasTextRecord bool + FolderID *uuid.UUID + Filename *string + OriginalPath *string + Labels []LabelRecord + TextRecord *TextRecord // Only populated when includeTextRecord=true + FileSizeBytes *int64 // File size in bytes (nil for legacy documents) + CustomSchemaID *uuid.UUID // Bound schema id (nil = no schema) + HasCustomMetadata bool // True iff any document_custom_metadata row exists } // LabelRecord represents a label applied to a document. diff --git a/internal/fieldextraction/service.go b/internal/fieldextraction/service.go index dd9229cc..53efb319 100644 --- a/internal/fieldextraction/service.go +++ b/internal/fieldextraction/service.go @@ -2,6 +2,7 @@ package fieldextraction import ( "context" + "errors" "fmt" "queryorchestration/internal/database/repository" @@ -11,6 +12,17 @@ import ( "github.com/jackc/pgx/v5" ) +// ErrDocumentNotFound is returned by CreateFieldExtraction when the +// target document does not exist. Handlers map this sentinel to 404. +var ErrDocumentNotFound = errors.New("document not found") + +// ErrMutualExclusivityViolation is returned by CreateFieldExtraction +// when the target document is already bound to a custom schema via +// documents.custom_schema_id. The mutable-metadata feature disallows +// mixing legacy field extractions with the new metadata system on the +// same document. Handlers map this sentinel to 409. +var ErrMutualExclusivityViolation = errors.New("document is bound to a custom schema and cannot accept legacy field extractions") + // Service provides field extraction operations type Service struct { cfg serviceconfig.ConfigProvider @@ -25,26 +37,8 @@ func New(cfg serviceconfig.ConfigProvider) *Service { // in a single transaction. All array fields must have consistent length. // Returns the created field extraction record. func (s *Service) CreateFieldExtraction(ctx context.Context, input *CreateFieldExtractionInput) (*repository.Documentfieldextraction, error) { - if input == nil { - return nil, fmt.Errorf("input cannot be nil") - } - - if input.DocumentID == uuid.Nil { - return nil, fmt.Errorf("documentID cannot be nil") - } - - if input.SingleFields == nil { - return nil, fmt.Errorf("singleFields cannot be nil") - } - - if input.SingleFields.Createdby == "" { - return nil, fmt.Errorf("createdBy cannot be empty") - } - - // Validate array consistency - _, err := ValidateArrayConsistency(input.ArrayFields) - if err != nil { - return nil, fmt.Errorf("array validation failed: %w", err) + if err := validateCreateFieldExtractionInput(input); err != nil { + return nil, err } // Begin transaction @@ -58,6 +52,25 @@ func (s *Service) CreateFieldExtraction(ctx context.Context, input *CreateFieldE queries := s.cfg.GetDBQueries().WithTx(tx) + // Milestone 2.5: take the parent-row lock on documents as the FIRST + // DML inside the tx. Serializes this write with any concurrent + // AssignSchema (which holds the same FOR UPDATE lock) so the + // mutual-exclusivity invariant cannot be broken under READ COMMITTED + // (see plan Section 7.3). A missing row → ErrDocumentNotFound; a + // non-null custom_schema_id on the locked row → + // ErrMutualExclusivityViolation. Both sentinels surface BEFORE any + // other DML so the 404/409 paths leave the DB unchanged. + lockedRow, err := queries.LockDocumentForLegacyExtractionWrite(ctx, input.DocumentID) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, ErrDocumentNotFound + } + return nil, fmt.Errorf("failed to lock document for legacy extraction: %w", err) + } + if lockedRow.CustomSchemaID != nil { + return nil, ErrMutualExclusivityViolation + } + // Set document ID in single fields input.SingleFields.Documentid = input.DocumentID @@ -195,3 +208,25 @@ func (s *Service) GetFieldExtractionByVersion(ctx context.Context, documentID uu return fieldExtraction, nil } + +// validateCreateFieldExtractionInput checks the basic shape invariants of +// the CreateFieldExtraction input before any DB work begins. Extracted so +// CreateFieldExtraction stays under the cyclomatic complexity ceiling. +func validateCreateFieldExtractionInput(input *CreateFieldExtractionInput) error { + if input == nil { + return fmt.Errorf("input cannot be nil") + } + if input.DocumentID == uuid.Nil { + return fmt.Errorf("documentID cannot be nil") + } + if input.SingleFields == nil { + return fmt.Errorf("singleFields cannot be nil") + } + if input.SingleFields.Createdby == "" { + return fmt.Errorf("createdBy cannot be empty") + } + if _, err := ValidateArrayConsistency(input.ArrayFields); err != nil { + return fmt.Errorf("array validation failed: %w", err) + } + return nil +} diff --git a/internal/fieldextraction/service_test.go b/internal/fieldextraction/service_test.go index 22bedbdc..983c549e 100644 --- a/internal/fieldextraction/service_test.go +++ b/internal/fieldextraction/service_test.go @@ -1,6 +1,7 @@ package fieldextraction_test import ( + "errors" "fmt" "testing" @@ -471,7 +472,14 @@ func TestTransactionRollback(t *testing.T) { _, err := svc.CreateFieldExtraction(ctx, input) require.Error(t, err) - assert.Contains(t, err.Error(), "failed to create field extraction") + // Milestone 2.5: CreateFieldExtraction now takes + // LockDocumentForLegacyExtractionWrite as its first DML, so a + // missing document surfaces as the typed sentinel + // ErrDocumentNotFound instead of the previous pass-through FK + // error. Either behavior satisfies the test's intent ("must not + // write anything"), so we assert on the sentinel form. + assert.True(t, errors.Is(err, fieldextraction.ErrDocumentNotFound), + "expected ErrDocumentNotFound, got %v", err) // Verify nothing was created history, err := svc.GetFieldExtractionHistory(ctx, nonExistentDocID) @@ -479,3 +487,61 @@ func TestTransactionRollback(t *testing.T) { assert.Len(t, history, 0) }) } + +// TestCreateFieldExtraction_MutualExclusivity covers Milestone 2.5: a +// document bound to a custom schema must reject legacy field extraction +// writes with ErrMutualExclusivityViolation, and nothing must be written +// to the DB on the rejection path. +func TestCreateFieldExtraction_MutualExclusivity(t *testing.T) { + ctx := t.Context() + cfg := &TestConfig{} + test.CreateDB(t, cfg) + svc := fieldextraction.New(cfg) + + clientID := "test-client-mutex" + require.NoError(t, cfg.GetDBQueries().CreateClient(ctx, &repository.CreateClientParams{ + Clientid: clientID, + Name: "Test Client Mutex", + })) + + // Seed an active schema directly so this test has no dependency on + // the customschema package. + var schemaID uuid.UUID + require.NoError(t, cfg.GetDBPool().QueryRow(ctx, ` + INSERT INTO client_metadata_schemas (client_id, name, schema_def, version, created_by) + VALUES ($1, $2, $3::jsonb, 1, 'tester') + RETURNING id + `, clientID, "mutex-test", `{"type":"object","additionalProperties":false}`).Scan(&schemaID)) + + // Create a document and bind it to the schema. + docID, err := cfg.GetDBQueries().CreateDocument(ctx, &repository.CreateDocumentParams{ + Clientid: clientID, + Hash: "mutex-test-doc", + }) + require.NoError(t, err) + _, err = cfg.GetDBPool().Exec(ctx, + `UPDATE documents SET custom_schema_id = $1 WHERE id = $2`, schemaID, docID) + require.NoError(t, err) + + // Attempting to create a legacy field extraction against this + // document must return ErrMutualExclusivityViolation. + input := &fieldextraction.CreateFieldExtractionInput{ + DocumentID: docID, + SingleFields: &repository.AddFieldExtractionParams{ + Documentid: docID, + Createdby: "user123", + }, + ArrayFields: []*repository.AddFieldExtractionArrayFieldParams{}, + } + _, err = svc.CreateFieldExtraction(ctx, input) + require.Error(t, err) + assert.True(t, errors.Is(err, fieldextraction.ErrMutualExclusivityViolation), + "expected ErrMutualExclusivityViolation, got %v", err) + + // DB state must be unchanged: no documentFieldExtractions row. + var count int + require.NoError(t, cfg.GetDBPool().QueryRow(ctx, + `SELECT COUNT(*) FROM documentFieldExtractions WHERE documentId = $1`, + docID).Scan(&count)) + assert.Equal(t, 0, count, "no field extraction row must be written on the rejection path") +} diff --git a/internal/test/database.go b/internal/test/database.go index 46440193..c727ab2c 100644 --- a/internal/test/database.go +++ b/internal/test/database.go @@ -86,6 +86,31 @@ func CreateDBWithParams(t testing.TB, cfg serviceconfig.ConfigProvider, dcfg *Cr err = cfg.SetDBPool(t.Context()) require.NoError(t, err) + + // Truncate all transactional tables so each test run starts with a clean slate. + // The postgres container is reused across runs; without this, seed data from a + // previous run causes duplicate key violations. + // Excluded: + // schema_migrations — golang-migrate tracking table; truncating would force + // migrations to re-run and break the NoChange guard. + // labels — reference/lookup table seeded by migration 110; its rows + // are never created/deleted by the application and must be + // present for the documentlabels FK to resolve. + _, err = cfg.GetDBPool().Exec(t.Context(), ` + DO $$ DECLARE + r RECORD; + BEGIN + FOR r IN ( + SELECT tablename + FROM pg_tables + WHERE schemaname = 'public' + AND tablename NOT IN ('schema_migrations', 'labels') + ) LOOP + EXECUTE 'TRUNCATE TABLE public.' || quote_ident(r.tablename) || ' RESTART IDENTITY CASCADE'; + END LOOP; + END $$; + `) + require.NoError(t, err) } } diff --git a/pkg/queryAPI/api.gen.go b/pkg/queryAPI/api.gen.go index 1ff9d92e..aed4d920 100644 --- a/pkg/queryAPI/api.gen.go +++ b/pkg/queryAPI/api.gen.go @@ -81,6 +81,13 @@ const ( RightClosedInterval FieldFilterCondition = "right_closed_interval" ) +// Defines values for SchemaStatus. +const ( + SchemaStatusActive SchemaStatus = "active" + SchemaStatusRetired SchemaStatus = "retired" + SchemaStatusSuperseded SchemaStatus = "superseded" +) + // Defines values for ListAdminUsersParamsStatus. const ( All ListAdminUsersParamsStatus = "all" @@ -101,6 +108,14 @@ const ( Desc ListAdminUsersParamsSortOrder = "desc" ) +// Defines values for ListCustomSchemasParamsStatus. +const ( + ListCustomSchemasParamsStatusActive ListCustomSchemasParamsStatus = "active" + ListCustomSchemasParamsStatusAny ListCustomSchemasParamsStatus = "any" + ListCustomSchemasParamsStatusRetired ListCustomSchemasParamsStatus = "retired" + ListCustomSchemasParamsStatusSuperseded ListCustomSchemasParamsStatus = "superseded" +) + // AdminUserActionResponse Response for user enable/disable actions with success status and timestamp type AdminUserActionResponse struct { // Action Action performed @@ -579,6 +594,142 @@ type CollectorSet struct { MinimumCleanerVersion *CodeVersion `json:"minimum_cleaner_version,omitempty"` } +// CustomMetadataHistoryResponse Paged list of version summaries, newest first. +type CustomMetadataHistoryResponse struct { + Versions []struct { + CreatedAt time.Time `json:"createdAt"` + CreatedBy string `json:"createdBy"` + Version int32 `json:"version"` + } `json:"versions"` +} + +// CustomMetadataRequest POST /custom-metadata body. No createdBy field — the actor is derived from the JWT sub claim. +type CustomMetadataRequest struct { + DocumentId openapi_types.UUID `json:"documentId"` + + // Metadata Arbitrary JSON object that must conform to the document's bound schema_def. The byte-level cap (1 048 576) lives in the service layer constant MaxMetadataPayloadBytes. + Metadata map[string]interface{} `json:"metadata"` +} + +// CustomMetadataResponse Full metadata row decorated with the document's bound schema id, name, and version. +type CustomMetadataResponse struct { + CreatedAt time.Time `json:"createdAt"` + + // CreatedBy Cognito subject ID (JWT sub claim) of the writer. + CreatedBy string `json:"createdBy"` + DocumentId openapi_types.UUID `json:"documentId"` + Id openapi_types.UUID `json:"id"` + Metadata map[string]interface{} `json:"metadata"` + SchemaId nullable.Nullable[openapi_types.UUID] `json:"schemaId,omitempty"` + SchemaName nullable.Nullable[string] `json:"schemaName,omitempty"` + SchemaVersion nullable.Nullable[int32] `json:"schemaVersion,omitempty"` + Version int32 `json:"version"` +} + +// CustomSchemaListResponse List of custom metadata schemas for a client. Each row omits the full JSON Schema document; use GET /super-admin/custom-schemas/ {schemaId} to retrieve it. +type CustomSchemaListResponse struct { + // Schemas The schema rows matching the query filters. + Schemas []struct { + // CanDelete True when documentCount is zero. + CanDelete bool `json:"canDelete"` + + // ClientId The client that owns this schema. + ClientId string `json:"clientId"` + + // CreatedAt Timestamp when this schema row was created. + CreatedAt time.Time `json:"createdAt"` + + // CreatedBy Cognito subject ID (JWT sub claim) of the caller. Opaque UUID, not an email. + CreatedBy string `json:"createdBy"` + + // Description Optional human-readable description of the schema. + Description *string `json:"description,omitempty"` + + // DocumentCount Number of documents referencing this schema version. + DocumentCount int32 `json:"documentCount"` + + // Id The schema row ID. + Id openapi_types.UUID `json:"id"` + + // Name The schema name (shared across versions in a lineage). + Name string `json:"name"` + + // Status Lifecycle state of a client_metadata_schemas row. `active` is + // the version currently assignable to new documents. `superseded` + // is automatically set when a newer version of the same (clientId, + // name) lineage is created via POST /versions. `retired` is set + // when a super_admin explicitly deletes an unused schema. + Status SchemaStatus `json:"status"` + + // Version Monotonic version number within the lineage. + Version int32 `json:"version"` + } `json:"schemas"` +} + +// CustomSchemaRequest Request body for creating a new custom metadata schema. Does not include a createdBy field; the handler derives the actor from the authenticated JWT subject. +type CustomSchemaRequest struct { + // ClientId The client that owns this schema. + ClientId string `json:"clientId"` + + // Description Optional human-readable description of the schema. + Description *string `json:"description,omitempty"` + + // Name Unique schema name within the client lineage. + Name string `json:"name"` + + // Schema The full JSON Schema document (Draft 2020-12) used to validate custom document metadata. Must declare root type=object and must explicitly set additionalProperties. The maxProperties cap is a defense-in-depth limit against malformed schema payloads; the byte-level cap (65 536) lives in the customschema validator. + Schema map[string]interface{} `json:"schema"` +} + +// CustomSchemaResponse Full custom metadata schema row including the JSON Schema document and lifecycle metadata. +type CustomSchemaResponse struct { + // CanDelete True when documentCount is zero and the schema can be retired. + CanDelete bool `json:"canDelete"` + + // ClientId The client that owns this schema. + ClientId string `json:"clientId"` + + // CreatedAt Timestamp when this schema row was created. + CreatedAt time.Time `json:"createdAt"` + + // CreatedBy Cognito subject ID (JWT sub claim) of the caller. Opaque UUID, not an email. + CreatedBy string `json:"createdBy"` + + // Description Optional human-readable description of the schema. + Description *string `json:"description,omitempty"` + + // DocumentCount Number of documents currently referencing this schema version. + DocumentCount int32 `json:"documentCount"` + + // Id The schema row ID. + Id openapi_types.UUID `json:"id"` + + // Name The schema name (shared across versions in a lineage). + Name string `json:"name"` + + // Schema The full JSON Schema document (Draft 2020-12). + Schema map[string]interface{} `json:"schema"` + + // Status Lifecycle state of a client_metadata_schemas row. `active` is + // the version currently assignable to new documents. `superseded` + // is automatically set when a newer version of the same (clientId, + // name) lineage is created via POST /versions. `retired` is set + // when a super_admin explicitly deletes an unused schema. + Status SchemaStatus `json:"status"` + + // Version Monotonic version number within the (clientId, name) lineage. + Version int32 `json:"version"` +} + +// CustomSchemaVersionRequest Request body for creating a new version of an existing custom metadata schema. The parent schema row supplies clientId and name; the actor is derived from the authenticated JWT subject. +type CustomSchemaVersionRequest struct { + // Description Optional human-readable description of this new version. + Description *string `json:"description,omitempty"` + + // Schema The full JSON Schema document (Draft 2020-12) for the new version. Same validation rules as CustomSchemaRequest.schema. + Schema map[string]interface{} `json:"schema"` +} + // DeleteResponse Response for hard-delete operations when verbose=true type DeleteResponse struct { // DeletedDocuments S3 paths of orphaned source documents (only populated when verbose=true) @@ -608,6 +759,9 @@ type DocumentEnriched struct { // ClientId The client external id ClientId ClientID `json:"client_id"` + // CustomSchemaId Mutable-metadata feature: the client_metadata_schemas row id this document is currently bound to, or null when the document has not been opted in to the custom schema system. A document with a non-null customSchemaId rejects legacy field extraction writes. + CustomSchemaId *openapi_types.UUID `json:"customSchemaId,omitempty"` + // FileSizeBytes File size in bytes (null for legacy documents) FileSizeBytes *int64 `json:"fileSizeBytes,omitempty"` @@ -617,6 +771,9 @@ type DocumentEnriched struct { // FolderId Parent folder ID, null if document is not in a folder FolderId *openapi_types.UUID `json:"folderId,omitempty"` + // HasCustomMetadata True iff at least one document_custom_metadata row exists for this document. A document can have customSchemaId set but hasCustomMetadata false (schema bound, no metadata written yet). + HasCustomMetadata *bool `json:"hasCustomMetadata,omitempty"` + // HasTextRecord Whether a text extraction (field extraction) record exists for this document HasTextRecord bool `json:"hasTextRecord"` @@ -669,6 +826,44 @@ type DocumentInFolder struct { TextRecord *FieldExtractionResponse `json:"textRecord,omitempty"` } +// DocumentMetadataResetResponse Result of a reset-metadata operation. The previous* fields describe the document's schema binding before the reset ran so the caller can log or display what was wiped. Fields are null when the document had no schema bound at reset time (the idempotent no-op case, returned alongside metadataVersionsDeleted set to 0). +type DocumentMetadataResetResponse struct { + // DocumentId The document whose metadata was reset. + DocumentId openapi_types.UUID `json:"documentId"` + + // MetadataVersionsDeleted Number of document_custom_metadata rows that existed (and were deleted) at reset time. Zero is valid: the reset endpoint is idempotent on already-clean documents. + MetadataVersionsDeleted int32 `json:"metadataVersionsDeleted"` + + // PreviousSchemaId The document's custom_schema_id before the reset ran. Null when no schema was bound. + PreviousSchemaId nullable.Nullable[openapi_types.UUID] `json:"previousSchemaId"` + + // PreviousSchemaName Name of the previously bound schema, or null when no schema was bound. + PreviousSchemaName nullable.Nullable[string] `json:"previousSchemaName"` + + // PreviousSchemaVersion Version of the previously bound schema row, or null when no schema was bound. + PreviousSchemaVersion nullable.Nullable[int32] `json:"previousSchemaVersion"` + + // ResetAt UTC timestamp when the reset completed. + ResetAt time.Time `json:"resetAt"` + + // ResetBy Cognito subject ID (JWT sub claim) of the caller. Opaque UUID, not an email. + ResetBy string `json:"resetBy"` +} + +// DocumentSchemaAssignRequest Request body for PATCH /super-admin/documents/{id}/schema. The actor is derived from the JWT sub claim; the body has no createdBy field. A null customSchemaId clears the binding. +type DocumentSchemaAssignRequest struct { + // CustomSchemaId The client_metadata_schemas row id to bind, or null to clear. + CustomSchemaId nullable.Nullable[openapi_types.UUID] `json:"customSchemaId,omitempty"` +} + +// DocumentSchemaAssignResponse Result of a schema binding mutation. Returns the bound schema metadata (or nulls when cleared). +type DocumentSchemaAssignResponse struct { + CustomSchemaId nullable.Nullable[openapi_types.UUID] `json:"customSchemaId,omitempty"` + DocumentId openapi_types.UUID `json:"documentId"` + SchemaName nullable.Nullable[string] `json:"schemaName,omitempty"` + SchemaVersion nullable.Nullable[int32] `json:"schemaVersion,omitempty"` +} + // DocumentSummary The document summary properties. type DocumentSummary struct { // Hash The document hash @@ -1185,6 +1380,13 @@ type LabelRecord struct { // ListDocuments The documents in the client. type ListDocuments = []DocumentSummary +// SchemaStatus Lifecycle state of a client_metadata_schemas row. `active` is +// the version currently assignable to new documents. `superseded` +// is automatically set when a newer version of the same (clientId, +// name) lineage is created via POST /versions. `retired` is set +// when a super_admin explicitly deletes an unused schema. +type SchemaStatus string + // SingleFields Single-value fields for a document extraction (1:1 relationship) type SingleFields struct { // AareteDerivedAmendmentNum Amendment number derived by Aarete @@ -1428,6 +1630,33 @@ type ListClientFoldersParams struct { Metrics *bool `form:"metrics,omitempty" json:"metrics,omitempty"` } +// GetCurrentCustomMetadataParams defines parameters for GetCurrentCustomMetadata. +type GetCurrentCustomMetadataParams struct { + // DocumentId The document whose current metadata is being requested. + DocumentId openapi_types.UUID `form:"documentId" json:"documentId"` +} + +// GetCustomMetadataHistoryParams defines parameters for GetCustomMetadataHistory. +type GetCustomMetadataHistoryParams struct { + // DocumentId The document whose metadata history is being requested. + DocumentId openapi_types.UUID `form:"documentId" json:"documentId"` + + // Limit Maximum number of version summaries to return per page (1-200, default 50). + Limit *int32 `form:"limit,omitempty" json:"limit,omitempty"` + + // Offset Number of version summaries to skip before the page starts (default 0). + Offset *int32 `form:"offset,omitempty" json:"offset,omitempty"` +} + +// GetCustomMetadataByVersionParams defines parameters for GetCustomMetadataByVersion. +type GetCustomMetadataByVersionParams struct { + // DocumentId The document whose historical metadata version is being requested. + DocumentId openapi_types.UUID `form:"documentId" json:"documentId"` + + // Version The metadata version number (>= 1) to return. + Version int32 `form:"version" json:"version"` +} + // DeleteDocumentParams defines parameters for DeleteDocument. type DeleteDocumentParams struct { // Verbose When true, returns S3 paths of orphaned source documents in the response body @@ -1491,6 +1720,30 @@ type LoginCallbackParams struct { State string `form:"state" json:"state"` } +// ListCustomSchemasParams defines parameters for ListCustomSchemas. +type ListCustomSchemasParams struct { + // ClientId The client whose schemas to list. + ClientId string `form:"clientId" json:"clientId"` + + // Name Filter by schema name (exact match). + Name *string `form:"name,omitempty" json:"name,omitempty"` + + // Status Filter by lifecycle status. The sentinel `any` returns rows in all statuses. Defaults to `active` when omitted. + Status *ListCustomSchemasParamsStatus `form:"status,omitempty" json:"status,omitempty"` + + // IncludeAllVersions When false, return only the latest version per (clientId, name) lineage. When true, return every version that matches the other filters. + IncludeAllVersions *bool `form:"includeAllVersions,omitempty" json:"includeAllVersions,omitempty"` + + // Limit Maximum number of results to return. + Limit *int32 `form:"limit,omitempty" json:"limit,omitempty"` + + // Offset Number of results to skip for pagination. + Offset *int32 `form:"offset,omitempty" json:"offset,omitempty"` +} + +// ListCustomSchemasParamsStatus defines parameters for ListCustomSchemas. +type ListCustomSchemasParamsStatus string + // ListUISettingsParams defines parameters for ListUISettings. type ListUISettingsParams struct { // Namespace Filter by namespace (omit to list all). @@ -1527,6 +1780,9 @@ type UploadDocumentBatchMultipartRequestBody UploadDocumentBatchMultipartBody // TriggerExportJSONRequestBody defines body for TriggerExport for application/json ContentType. type TriggerExportJSONRequestBody = ExportTrigger +// SetCustomMetadataJSONRequestBody defines body for SetCustomMetadata for application/json ContentType. +type SetCustomMetadataJSONRequestBody = CustomMetadataRequest + // ApplyLabelJSONRequestBody defines body for ApplyLabel for application/json ContentType. type ApplyLabelJSONRequestBody = LabelApplication @@ -1539,6 +1795,15 @@ type CreateFolderJSONRequestBody = FolderCreate // RenameFolderJSONRequestBody defines body for RenameFolder for application/json ContentType. type RenameFolderJSONRequestBody = FolderRename +// CreateCustomSchemaJSONRequestBody defines body for CreateCustomSchema for application/json ContentType. +type CreateCustomSchemaJSONRequestBody = CustomSchemaRequest + +// CreateCustomSchemaVersionJSONRequestBody defines body for CreateCustomSchemaVersion for application/json ContentType. +type CreateCustomSchemaVersionJSONRequestBody = CustomSchemaVersionRequest + +// AssignDocumentSchemaJSONRequestBody defines body for AssignDocumentSchema for application/json ContentType. +type AssignDocumentSchemaJSONRequestBody = DocumentSchemaAssignRequest + // CreateUISettingJSONRequestBody defines body for CreateUISetting for application/json ContentType. type CreateUISettingJSONRequestBody = UISettingCreate @@ -1724,6 +1989,20 @@ type ClientInterface interface { // ListClients request ListClients(ctx context.Context, reqEditors ...RequestEditorFn) (*http.Response, error) + // GetCurrentCustomMetadata request + GetCurrentCustomMetadata(ctx context.Context, params *GetCurrentCustomMetadataParams, reqEditors ...RequestEditorFn) (*http.Response, error) + + // SetCustomMetadataWithBody request with any body + SetCustomMetadataWithBody(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) + + SetCustomMetadata(ctx context.Context, body SetCustomMetadataJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) + + // GetCustomMetadataHistory request + GetCustomMetadataHistory(ctx context.Context, params *GetCustomMetadataHistoryParams, reqEditors ...RequestEditorFn) (*http.Response, error) + + // GetCustomMetadataByVersion request + GetCustomMetadataByVersion(ctx context.Context, params *GetCustomMetadataByVersionParams, reqEditors ...RequestEditorFn) (*http.Response, error) + // DeleteDocument request DeleteDocument(ctx context.Context, id DocumentID, params *DeleteDocumentParams, reqEditors ...RequestEditorFn) (*http.Response, error) @@ -1804,6 +2083,33 @@ type ClientInterface interface { // Logout request Logout(ctx context.Context, reqEditors ...RequestEditorFn) (*http.Response, error) + // ListCustomSchemas request + ListCustomSchemas(ctx context.Context, params *ListCustomSchemasParams, reqEditors ...RequestEditorFn) (*http.Response, error) + + // CreateCustomSchemaWithBody request with any body + CreateCustomSchemaWithBody(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) + + CreateCustomSchema(ctx context.Context, body CreateCustomSchemaJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) + + // DeleteCustomSchema request + DeleteCustomSchema(ctx context.Context, schemaId openapi_types.UUID, reqEditors ...RequestEditorFn) (*http.Response, error) + + // GetCustomSchema request + GetCustomSchema(ctx context.Context, schemaId openapi_types.UUID, reqEditors ...RequestEditorFn) (*http.Response, error) + + // CreateCustomSchemaVersionWithBody request with any body + CreateCustomSchemaVersionWithBody(ctx context.Context, schemaId openapi_types.UUID, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) + + CreateCustomSchemaVersion(ctx context.Context, schemaId openapi_types.UUID, body CreateCustomSchemaVersionJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) + + // ResetDocumentMetadata request + ResetDocumentMetadata(ctx context.Context, id openapi_types.UUID, reqEditors ...RequestEditorFn) (*http.Response, error) + + // AssignDocumentSchemaWithBody request with any body + AssignDocumentSchemaWithBody(ctx context.Context, id openapi_types.UUID, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) + + AssignDocumentSchema(ctx context.Context, id openapi_types.UUID, body AssignDocumentSchemaJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) + // ListUISettings request ListUISettings(ctx context.Context, params *ListUISettingsParams, reqEditors ...RequestEditorFn) (*http.Response, error) @@ -2280,6 +2586,66 @@ func (c *Client) ListClients(ctx context.Context, reqEditors ...RequestEditorFn) return c.Client.Do(req) } +func (c *Client) GetCurrentCustomMetadata(ctx context.Context, params *GetCurrentCustomMetadataParams, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewGetCurrentCustomMetadataRequest(c.Server, params) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + +func (c *Client) SetCustomMetadataWithBody(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewSetCustomMetadataRequestWithBody(c.Server, contentType, body) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + +func (c *Client) SetCustomMetadata(ctx context.Context, body SetCustomMetadataJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewSetCustomMetadataRequest(c.Server, body) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + +func (c *Client) GetCustomMetadataHistory(ctx context.Context, params *GetCustomMetadataHistoryParams, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewGetCustomMetadataHistoryRequest(c.Server, params) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + +func (c *Client) GetCustomMetadataByVersion(ctx context.Context, params *GetCustomMetadataByVersionParams, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewGetCustomMetadataByVersionRequest(c.Server, params) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + func (c *Client) DeleteDocument(ctx context.Context, id DocumentID, params *DeleteDocumentParams, reqEditors ...RequestEditorFn) (*http.Response, error) { req, err := NewDeleteDocumentRequest(c.Server, id, params) if err != nil { @@ -2616,6 +2982,126 @@ func (c *Client) Logout(ctx context.Context, reqEditors ...RequestEditorFn) (*ht return c.Client.Do(req) } +func (c *Client) ListCustomSchemas(ctx context.Context, params *ListCustomSchemasParams, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewListCustomSchemasRequest(c.Server, params) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + +func (c *Client) CreateCustomSchemaWithBody(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewCreateCustomSchemaRequestWithBody(c.Server, contentType, body) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + +func (c *Client) CreateCustomSchema(ctx context.Context, body CreateCustomSchemaJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewCreateCustomSchemaRequest(c.Server, body) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + +func (c *Client) DeleteCustomSchema(ctx context.Context, schemaId openapi_types.UUID, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewDeleteCustomSchemaRequest(c.Server, schemaId) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + +func (c *Client) GetCustomSchema(ctx context.Context, schemaId openapi_types.UUID, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewGetCustomSchemaRequest(c.Server, schemaId) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + +func (c *Client) CreateCustomSchemaVersionWithBody(ctx context.Context, schemaId openapi_types.UUID, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewCreateCustomSchemaVersionRequestWithBody(c.Server, schemaId, contentType, body) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + +func (c *Client) CreateCustomSchemaVersion(ctx context.Context, schemaId openapi_types.UUID, body CreateCustomSchemaVersionJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewCreateCustomSchemaVersionRequest(c.Server, schemaId, body) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + +func (c *Client) ResetDocumentMetadata(ctx context.Context, id openapi_types.UUID, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewResetDocumentMetadataRequest(c.Server, id) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + +func (c *Client) AssignDocumentSchemaWithBody(ctx context.Context, id openapi_types.UUID, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewAssignDocumentSchemaRequestWithBody(c.Server, id, contentType, body) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + +func (c *Client) AssignDocumentSchema(ctx context.Context, id openapi_types.UUID, body AssignDocumentSchemaJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewAssignDocumentSchemaRequest(c.Server, id, body) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + func (c *Client) ListUISettings(ctx context.Context, params *ListUISettingsParams, reqEditors ...RequestEditorFn) (*http.Response, error) { req, err := NewListUISettingsRequest(c.Server, params) if err != nil { @@ -4171,6 +4657,225 @@ func NewListClientsRequest(server string) (*http.Request, error) { return req, nil } +// NewGetCurrentCustomMetadataRequest generates requests for GetCurrentCustomMetadata +func NewGetCurrentCustomMetadataRequest(server string, params *GetCurrentCustomMetadataParams) (*http.Request, error) { + var err error + + serverURL, err := url.Parse(server) + if err != nil { + return nil, err + } + + operationPath := fmt.Sprintf("/custom-metadata") + if operationPath[0] == '/' { + operationPath = "." + operationPath + } + + queryURL, err := serverURL.Parse(operationPath) + if err != nil { + return nil, err + } + + if params != nil { + queryValues := queryURL.Query() + + if queryFrag, err := runtime.StyleParamWithLocation("form", true, "documentId", runtime.ParamLocationQuery, params.DocumentId); err != nil { + return nil, err + } else if parsed, err := url.ParseQuery(queryFrag); err != nil { + return nil, err + } else { + for k, v := range parsed { + for _, v2 := range v { + queryValues.Add(k, v2) + } + } + } + + queryURL.RawQuery = queryValues.Encode() + } + + req, err := http.NewRequest("GET", queryURL.String(), nil) + if err != nil { + return nil, err + } + + return req, nil +} + +// NewSetCustomMetadataRequest calls the generic SetCustomMetadata builder with application/json body +func NewSetCustomMetadataRequest(server string, body SetCustomMetadataJSONRequestBody) (*http.Request, error) { + var bodyReader io.Reader + buf, err := json.Marshal(body) + if err != nil { + return nil, err + } + bodyReader = bytes.NewReader(buf) + return NewSetCustomMetadataRequestWithBody(server, "application/json", bodyReader) +} + +// NewSetCustomMetadataRequestWithBody generates requests for SetCustomMetadata with any type of body +func NewSetCustomMetadataRequestWithBody(server string, contentType string, body io.Reader) (*http.Request, error) { + var err error + + serverURL, err := url.Parse(server) + if err != nil { + return nil, err + } + + operationPath := fmt.Sprintf("/custom-metadata") + if operationPath[0] == '/' { + operationPath = "." + operationPath + } + + queryURL, err := serverURL.Parse(operationPath) + if err != nil { + return nil, err + } + + req, err := http.NewRequest("POST", queryURL.String(), body) + if err != nil { + return nil, err + } + + req.Header.Add("Content-Type", contentType) + + return req, nil +} + +// NewGetCustomMetadataHistoryRequest generates requests for GetCustomMetadataHistory +func NewGetCustomMetadataHistoryRequest(server string, params *GetCustomMetadataHistoryParams) (*http.Request, error) { + var err error + + serverURL, err := url.Parse(server) + if err != nil { + return nil, err + } + + operationPath := fmt.Sprintf("/custom-metadata/history") + if operationPath[0] == '/' { + operationPath = "." + operationPath + } + + queryURL, err := serverURL.Parse(operationPath) + if err != nil { + return nil, err + } + + if params != nil { + queryValues := queryURL.Query() + + if queryFrag, err := runtime.StyleParamWithLocation("form", true, "documentId", runtime.ParamLocationQuery, params.DocumentId); err != nil { + return nil, err + } else if parsed, err := url.ParseQuery(queryFrag); err != nil { + return nil, err + } else { + for k, v := range parsed { + for _, v2 := range v { + queryValues.Add(k, v2) + } + } + } + + if params.Limit != nil { + + if queryFrag, err := runtime.StyleParamWithLocation("form", true, "limit", runtime.ParamLocationQuery, *params.Limit); err != nil { + return nil, err + } else if parsed, err := url.ParseQuery(queryFrag); err != nil { + return nil, err + } else { + for k, v := range parsed { + for _, v2 := range v { + queryValues.Add(k, v2) + } + } + } + + } + + if params.Offset != nil { + + if queryFrag, err := runtime.StyleParamWithLocation("form", true, "offset", runtime.ParamLocationQuery, *params.Offset); err != nil { + return nil, err + } else if parsed, err := url.ParseQuery(queryFrag); err != nil { + return nil, err + } else { + for k, v := range parsed { + for _, v2 := range v { + queryValues.Add(k, v2) + } + } + } + + } + + queryURL.RawQuery = queryValues.Encode() + } + + req, err := http.NewRequest("GET", queryURL.String(), nil) + if err != nil { + return nil, err + } + + return req, nil +} + +// NewGetCustomMetadataByVersionRequest generates requests for GetCustomMetadataByVersion +func NewGetCustomMetadataByVersionRequest(server string, params *GetCustomMetadataByVersionParams) (*http.Request, error) { + var err error + + serverURL, err := url.Parse(server) + if err != nil { + return nil, err + } + + operationPath := fmt.Sprintf("/custom-metadata/version") + if operationPath[0] == '/' { + operationPath = "." + operationPath + } + + queryURL, err := serverURL.Parse(operationPath) + if err != nil { + return nil, err + } + + if params != nil { + queryValues := queryURL.Query() + + if queryFrag, err := runtime.StyleParamWithLocation("form", true, "documentId", runtime.ParamLocationQuery, params.DocumentId); err != nil { + return nil, err + } else if parsed, err := url.ParseQuery(queryFrag); err != nil { + return nil, err + } else { + for k, v := range parsed { + for _, v2 := range v { + queryValues.Add(k, v2) + } + } + } + + if queryFrag, err := runtime.StyleParamWithLocation("form", true, "version", runtime.ParamLocationQuery, params.Version); err != nil { + return nil, err + } else if parsed, err := url.ParseQuery(queryFrag); err != nil { + return nil, err + } else { + for k, v := range parsed { + for _, v2 := range v { + queryValues.Add(k, v2) + } + } + } + + queryURL.RawQuery = queryValues.Encode() + } + + req, err := http.NewRequest("GET", queryURL.String(), nil) + if err != nil { + return nil, err + } + + return req, nil +} + // NewDeleteDocumentRequest generates requests for DeleteDocument func NewDeleteDocumentRequest(server string, id DocumentID, params *DeleteDocumentParams) (*http.Request, error) { var err error @@ -5166,6 +5871,367 @@ func NewLogoutRequest(server string) (*http.Request, error) { return req, nil } +// NewListCustomSchemasRequest generates requests for ListCustomSchemas +func NewListCustomSchemasRequest(server string, params *ListCustomSchemasParams) (*http.Request, error) { + var err error + + serverURL, err := url.Parse(server) + if err != nil { + return nil, err + } + + operationPath := fmt.Sprintf("/super-admin/custom-schemas") + if operationPath[0] == '/' { + operationPath = "." + operationPath + } + + queryURL, err := serverURL.Parse(operationPath) + if err != nil { + return nil, err + } + + if params != nil { + queryValues := queryURL.Query() + + if queryFrag, err := runtime.StyleParamWithLocation("form", true, "clientId", runtime.ParamLocationQuery, params.ClientId); err != nil { + return nil, err + } else if parsed, err := url.ParseQuery(queryFrag); err != nil { + return nil, err + } else { + for k, v := range parsed { + for _, v2 := range v { + queryValues.Add(k, v2) + } + } + } + + if params.Name != nil { + + if queryFrag, err := runtime.StyleParamWithLocation("form", true, "name", runtime.ParamLocationQuery, *params.Name); err != nil { + return nil, err + } else if parsed, err := url.ParseQuery(queryFrag); err != nil { + return nil, err + } else { + for k, v := range parsed { + for _, v2 := range v { + queryValues.Add(k, v2) + } + } + } + + } + + if params.Status != nil { + + if queryFrag, err := runtime.StyleParamWithLocation("form", true, "status", runtime.ParamLocationQuery, *params.Status); err != nil { + return nil, err + } else if parsed, err := url.ParseQuery(queryFrag); err != nil { + return nil, err + } else { + for k, v := range parsed { + for _, v2 := range v { + queryValues.Add(k, v2) + } + } + } + + } + + if params.IncludeAllVersions != nil { + + if queryFrag, err := runtime.StyleParamWithLocation("form", true, "includeAllVersions", runtime.ParamLocationQuery, *params.IncludeAllVersions); err != nil { + return nil, err + } else if parsed, err := url.ParseQuery(queryFrag); err != nil { + return nil, err + } else { + for k, v := range parsed { + for _, v2 := range v { + queryValues.Add(k, v2) + } + } + } + + } + + if params.Limit != nil { + + if queryFrag, err := runtime.StyleParamWithLocation("form", true, "limit", runtime.ParamLocationQuery, *params.Limit); err != nil { + return nil, err + } else if parsed, err := url.ParseQuery(queryFrag); err != nil { + return nil, err + } else { + for k, v := range parsed { + for _, v2 := range v { + queryValues.Add(k, v2) + } + } + } + + } + + if params.Offset != nil { + + if queryFrag, err := runtime.StyleParamWithLocation("form", true, "offset", runtime.ParamLocationQuery, *params.Offset); err != nil { + return nil, err + } else if parsed, err := url.ParseQuery(queryFrag); err != nil { + return nil, err + } else { + for k, v := range parsed { + for _, v2 := range v { + queryValues.Add(k, v2) + } + } + } + + } + + queryURL.RawQuery = queryValues.Encode() + } + + req, err := http.NewRequest("GET", queryURL.String(), nil) + if err != nil { + return nil, err + } + + return req, nil +} + +// NewCreateCustomSchemaRequest calls the generic CreateCustomSchema builder with application/json body +func NewCreateCustomSchemaRequest(server string, body CreateCustomSchemaJSONRequestBody) (*http.Request, error) { + var bodyReader io.Reader + buf, err := json.Marshal(body) + if err != nil { + return nil, err + } + bodyReader = bytes.NewReader(buf) + return NewCreateCustomSchemaRequestWithBody(server, "application/json", bodyReader) +} + +// NewCreateCustomSchemaRequestWithBody generates requests for CreateCustomSchema with any type of body +func NewCreateCustomSchemaRequestWithBody(server string, contentType string, body io.Reader) (*http.Request, error) { + var err error + + serverURL, err := url.Parse(server) + if err != nil { + return nil, err + } + + operationPath := fmt.Sprintf("/super-admin/custom-schemas") + if operationPath[0] == '/' { + operationPath = "." + operationPath + } + + queryURL, err := serverURL.Parse(operationPath) + if err != nil { + return nil, err + } + + req, err := http.NewRequest("POST", queryURL.String(), body) + if err != nil { + return nil, err + } + + req.Header.Add("Content-Type", contentType) + + return req, nil +} + +// NewDeleteCustomSchemaRequest generates requests for DeleteCustomSchema +func NewDeleteCustomSchemaRequest(server string, schemaId openapi_types.UUID) (*http.Request, error) { + var err error + + var pathParam0 string + + pathParam0, err = runtime.StyleParamWithLocation("simple", false, "schemaId", runtime.ParamLocationPath, schemaId) + if err != nil { + return nil, err + } + + serverURL, err := url.Parse(server) + if err != nil { + return nil, err + } + + operationPath := fmt.Sprintf("/super-admin/custom-schemas/%s", pathParam0) + if operationPath[0] == '/' { + operationPath = "." + operationPath + } + + queryURL, err := serverURL.Parse(operationPath) + if err != nil { + return nil, err + } + + req, err := http.NewRequest("DELETE", queryURL.String(), nil) + if err != nil { + return nil, err + } + + return req, nil +} + +// NewGetCustomSchemaRequest generates requests for GetCustomSchema +func NewGetCustomSchemaRequest(server string, schemaId openapi_types.UUID) (*http.Request, error) { + var err error + + var pathParam0 string + + pathParam0, err = runtime.StyleParamWithLocation("simple", false, "schemaId", runtime.ParamLocationPath, schemaId) + if err != nil { + return nil, err + } + + serverURL, err := url.Parse(server) + if err != nil { + return nil, err + } + + operationPath := fmt.Sprintf("/super-admin/custom-schemas/%s", pathParam0) + if operationPath[0] == '/' { + operationPath = "." + operationPath + } + + queryURL, err := serverURL.Parse(operationPath) + if err != nil { + return nil, err + } + + req, err := http.NewRequest("GET", queryURL.String(), nil) + if err != nil { + return nil, err + } + + return req, nil +} + +// NewCreateCustomSchemaVersionRequest calls the generic CreateCustomSchemaVersion builder with application/json body +func NewCreateCustomSchemaVersionRequest(server string, schemaId openapi_types.UUID, body CreateCustomSchemaVersionJSONRequestBody) (*http.Request, error) { + var bodyReader io.Reader + buf, err := json.Marshal(body) + if err != nil { + return nil, err + } + bodyReader = bytes.NewReader(buf) + return NewCreateCustomSchemaVersionRequestWithBody(server, schemaId, "application/json", bodyReader) +} + +// NewCreateCustomSchemaVersionRequestWithBody generates requests for CreateCustomSchemaVersion with any type of body +func NewCreateCustomSchemaVersionRequestWithBody(server string, schemaId openapi_types.UUID, contentType string, body io.Reader) (*http.Request, error) { + var err error + + var pathParam0 string + + pathParam0, err = runtime.StyleParamWithLocation("simple", false, "schemaId", runtime.ParamLocationPath, schemaId) + if err != nil { + return nil, err + } + + serverURL, err := url.Parse(server) + if err != nil { + return nil, err + } + + operationPath := fmt.Sprintf("/super-admin/custom-schemas/%s/versions", pathParam0) + if operationPath[0] == '/' { + operationPath = "." + operationPath + } + + queryURL, err := serverURL.Parse(operationPath) + if err != nil { + return nil, err + } + + req, err := http.NewRequest("POST", queryURL.String(), body) + if err != nil { + return nil, err + } + + req.Header.Add("Content-Type", contentType) + + return req, nil +} + +// NewResetDocumentMetadataRequest generates requests for ResetDocumentMetadata +func NewResetDocumentMetadataRequest(server string, id openapi_types.UUID) (*http.Request, error) { + var err error + + var pathParam0 string + + pathParam0, err = runtime.StyleParamWithLocation("simple", false, "id", runtime.ParamLocationPath, id) + if err != nil { + return nil, err + } + + serverURL, err := url.Parse(server) + if err != nil { + return nil, err + } + + operationPath := fmt.Sprintf("/super-admin/documents/%s/reset-metadata", pathParam0) + if operationPath[0] == '/' { + operationPath = "." + operationPath + } + + queryURL, err := serverURL.Parse(operationPath) + if err != nil { + return nil, err + } + + req, err := http.NewRequest("POST", queryURL.String(), nil) + if err != nil { + return nil, err + } + + return req, nil +} + +// NewAssignDocumentSchemaRequest calls the generic AssignDocumentSchema builder with application/json body +func NewAssignDocumentSchemaRequest(server string, id openapi_types.UUID, body AssignDocumentSchemaJSONRequestBody) (*http.Request, error) { + var bodyReader io.Reader + buf, err := json.Marshal(body) + if err != nil { + return nil, err + } + bodyReader = bytes.NewReader(buf) + return NewAssignDocumentSchemaRequestWithBody(server, id, "application/json", bodyReader) +} + +// NewAssignDocumentSchemaRequestWithBody generates requests for AssignDocumentSchema with any type of body +func NewAssignDocumentSchemaRequestWithBody(server string, id openapi_types.UUID, contentType string, body io.Reader) (*http.Request, error) { + var err error + + var pathParam0 string + + pathParam0, err = runtime.StyleParamWithLocation("simple", false, "id", runtime.ParamLocationPath, id) + if err != nil { + return nil, err + } + + serverURL, err := url.Parse(server) + if err != nil { + return nil, err + } + + operationPath := fmt.Sprintf("/super-admin/documents/%s/schema", pathParam0) + if operationPath[0] == '/' { + operationPath = "." + operationPath + } + + queryURL, err := serverURL.Parse(operationPath) + if err != nil { + return nil, err + } + + req, err := http.NewRequest("PATCH", queryURL.String(), body) + if err != nil { + return nil, err + } + + req.Header.Add("Content-Type", contentType) + + return req, nil +} + // NewListUISettingsRequest generates requests for ListUISettings func NewListUISettingsRequest(server string, params *ListUISettingsParams) (*http.Request, error) { var err error @@ -5519,6 +6585,20 @@ type ClientWithResponsesInterface interface { // ListClientsWithResponse request ListClientsWithResponse(ctx context.Context, reqEditors ...RequestEditorFn) (*ListClientsResponse, error) + // GetCurrentCustomMetadataWithResponse request + GetCurrentCustomMetadataWithResponse(ctx context.Context, params *GetCurrentCustomMetadataParams, reqEditors ...RequestEditorFn) (*GetCurrentCustomMetadataResponse, error) + + // SetCustomMetadataWithBodyWithResponse request with any body + SetCustomMetadataWithBodyWithResponse(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*SetCustomMetadataResponse, error) + + SetCustomMetadataWithResponse(ctx context.Context, body SetCustomMetadataJSONRequestBody, reqEditors ...RequestEditorFn) (*SetCustomMetadataResponse, error) + + // GetCustomMetadataHistoryWithResponse request + GetCustomMetadataHistoryWithResponse(ctx context.Context, params *GetCustomMetadataHistoryParams, reqEditors ...RequestEditorFn) (*GetCustomMetadataHistoryResponse, error) + + // GetCustomMetadataByVersionWithResponse request + GetCustomMetadataByVersionWithResponse(ctx context.Context, params *GetCustomMetadataByVersionParams, reqEditors ...RequestEditorFn) (*GetCustomMetadataByVersionResponse, error) + // DeleteDocumentWithResponse request DeleteDocumentWithResponse(ctx context.Context, id DocumentID, params *DeleteDocumentParams, reqEditors ...RequestEditorFn) (*DeleteDocumentResponse, error) @@ -5599,6 +6679,33 @@ type ClientWithResponsesInterface interface { // LogoutWithResponse request LogoutWithResponse(ctx context.Context, reqEditors ...RequestEditorFn) (*LogoutResponse, error) + // ListCustomSchemasWithResponse request + ListCustomSchemasWithResponse(ctx context.Context, params *ListCustomSchemasParams, reqEditors ...RequestEditorFn) (*ListCustomSchemasResponse, error) + + // CreateCustomSchemaWithBodyWithResponse request with any body + CreateCustomSchemaWithBodyWithResponse(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*CreateCustomSchemaResponse, error) + + CreateCustomSchemaWithResponse(ctx context.Context, body CreateCustomSchemaJSONRequestBody, reqEditors ...RequestEditorFn) (*CreateCustomSchemaResponse, error) + + // DeleteCustomSchemaWithResponse request + DeleteCustomSchemaWithResponse(ctx context.Context, schemaId openapi_types.UUID, reqEditors ...RequestEditorFn) (*DeleteCustomSchemaResponse, error) + + // GetCustomSchemaWithResponse request + GetCustomSchemaWithResponse(ctx context.Context, schemaId openapi_types.UUID, reqEditors ...RequestEditorFn) (*GetCustomSchemaResponse, error) + + // CreateCustomSchemaVersionWithBodyWithResponse request with any body + CreateCustomSchemaVersionWithBodyWithResponse(ctx context.Context, schemaId openapi_types.UUID, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*CreateCustomSchemaVersionResponse, error) + + CreateCustomSchemaVersionWithResponse(ctx context.Context, schemaId openapi_types.UUID, body CreateCustomSchemaVersionJSONRequestBody, reqEditors ...RequestEditorFn) (*CreateCustomSchemaVersionResponse, error) + + // ResetDocumentMetadataWithResponse request + ResetDocumentMetadataWithResponse(ctx context.Context, id openapi_types.UUID, reqEditors ...RequestEditorFn) (*ResetDocumentMetadataResponse, error) + + // AssignDocumentSchemaWithBodyWithResponse request with any body + AssignDocumentSchemaWithBodyWithResponse(ctx context.Context, id openapi_types.UUID, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*AssignDocumentSchemaResponse, error) + + AssignDocumentSchemaWithResponse(ctx context.Context, id openapi_types.UUID, body AssignDocumentSchemaJSONRequestBody, reqEditors ...RequestEditorFn) (*AssignDocumentSchemaResponse, error) + // ListUISettingsWithResponse request ListUISettingsWithResponse(ctx context.Context, params *ListUISettingsParams, reqEditors ...RequestEditorFn) (*ListUISettingsResponse, error) @@ -6428,6 +7535,119 @@ func (r ListClientsResponse) StatusCode() int { return 0 } +type GetCurrentCustomMetadataResponse struct { + Body []byte + HTTPResponse *http.Response + JSON200 *CustomMetadataResponse + JSON400 *InvalidRequest + JSON401 *Unauthorized + JSON403 *Forbidden + JSON404 *NotFound + JSON429 *TooManyRequests + JSON500 *InternalError +} + +// Status returns HTTPResponse.Status +func (r GetCurrentCustomMetadataResponse) Status() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Status + } + return http.StatusText(0) +} + +// StatusCode returns HTTPResponse.StatusCode +func (r GetCurrentCustomMetadataResponse) StatusCode() int { + if r.HTTPResponse != nil { + return r.HTTPResponse.StatusCode + } + return 0 +} + +type SetCustomMetadataResponse struct { + Body []byte + HTTPResponse *http.Response + JSON201 *CustomMetadataResponse + JSON400 *InvalidRequest + JSON401 *Unauthorized + JSON403 *Forbidden + JSON404 *NotFound + JSON409 *Conflict + JSON429 *TooManyRequests + JSON500 *InternalError +} + +// Status returns HTTPResponse.Status +func (r SetCustomMetadataResponse) Status() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Status + } + return http.StatusText(0) +} + +// StatusCode returns HTTPResponse.StatusCode +func (r SetCustomMetadataResponse) StatusCode() int { + if r.HTTPResponse != nil { + return r.HTTPResponse.StatusCode + } + return 0 +} + +type GetCustomMetadataHistoryResponse struct { + Body []byte + HTTPResponse *http.Response + JSON200 *CustomMetadataHistoryResponse + JSON400 *InvalidRequest + JSON401 *Unauthorized + JSON403 *Forbidden + JSON404 *NotFound + JSON429 *TooManyRequests + JSON500 *InternalError +} + +// Status returns HTTPResponse.Status +func (r GetCustomMetadataHistoryResponse) Status() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Status + } + return http.StatusText(0) +} + +// StatusCode returns HTTPResponse.StatusCode +func (r GetCustomMetadataHistoryResponse) StatusCode() int { + if r.HTTPResponse != nil { + return r.HTTPResponse.StatusCode + } + return 0 +} + +type GetCustomMetadataByVersionResponse struct { + Body []byte + HTTPResponse *http.Response + JSON200 *CustomMetadataResponse + JSON400 *InvalidRequest + JSON401 *Unauthorized + JSON403 *Forbidden + JSON404 *NotFound + JSON429 *TooManyRequests + JSON500 *InternalError +} + +// Status returns HTTPResponse.Status +func (r GetCustomMetadataByVersionResponse) Status() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Status + } + return http.StatusText(0) +} + +// StatusCode returns HTTPResponse.StatusCode +func (r GetCustomMetadataByVersionResponse) StatusCode() int { + if r.HTTPResponse != nil { + return r.HTTPResponse.StatusCode + } + return 0 +} + type DeleteDocumentResponse struct { Body []byte HTTPResponse *http.Response @@ -7073,6 +8293,204 @@ func (r LogoutResponse) StatusCode() int { return 0 } +type ListCustomSchemasResponse struct { + Body []byte + HTTPResponse *http.Response + JSON200 *CustomSchemaListResponse + JSON400 *InvalidRequest + JSON401 *Unauthorized + JSON403 *Forbidden + JSON429 *TooManyRequests + JSON500 *InternalError +} + +// Status returns HTTPResponse.Status +func (r ListCustomSchemasResponse) Status() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Status + } + return http.StatusText(0) +} + +// StatusCode returns HTTPResponse.StatusCode +func (r ListCustomSchemasResponse) StatusCode() int { + if r.HTTPResponse != nil { + return r.HTTPResponse.StatusCode + } + return 0 +} + +type CreateCustomSchemaResponse struct { + Body []byte + HTTPResponse *http.Response + JSON201 *CustomSchemaResponse + JSON400 *InvalidRequest + JSON401 *Unauthorized + JSON403 *Forbidden + JSON409 *Conflict + JSON429 *TooManyRequests + JSON500 *InternalError +} + +// Status returns HTTPResponse.Status +func (r CreateCustomSchemaResponse) Status() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Status + } + return http.StatusText(0) +} + +// StatusCode returns HTTPResponse.StatusCode +func (r CreateCustomSchemaResponse) StatusCode() int { + if r.HTTPResponse != nil { + return r.HTTPResponse.StatusCode + } + return 0 +} + +type DeleteCustomSchemaResponse struct { + Body []byte + HTTPResponse *http.Response + JSON400 *InvalidRequest + JSON401 *Unauthorized + JSON403 *Forbidden + JSON404 *NotFound + JSON409 *Conflict + JSON429 *TooManyRequests + JSON500 *InternalError +} + +// Status returns HTTPResponse.Status +func (r DeleteCustomSchemaResponse) Status() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Status + } + return http.StatusText(0) +} + +// StatusCode returns HTTPResponse.StatusCode +func (r DeleteCustomSchemaResponse) StatusCode() int { + if r.HTTPResponse != nil { + return r.HTTPResponse.StatusCode + } + return 0 +} + +type GetCustomSchemaResponse struct { + Body []byte + HTTPResponse *http.Response + JSON200 *CustomSchemaResponse + JSON400 *InvalidRequest + JSON401 *Unauthorized + JSON403 *Forbidden + JSON404 *NotFound + JSON429 *TooManyRequests + JSON500 *InternalError +} + +// Status returns HTTPResponse.Status +func (r GetCustomSchemaResponse) Status() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Status + } + return http.StatusText(0) +} + +// StatusCode returns HTTPResponse.StatusCode +func (r GetCustomSchemaResponse) StatusCode() int { + if r.HTTPResponse != nil { + return r.HTTPResponse.StatusCode + } + return 0 +} + +type CreateCustomSchemaVersionResponse struct { + Body []byte + HTTPResponse *http.Response + JSON201 *CustomSchemaResponse + JSON400 *InvalidRequest + JSON401 *Unauthorized + JSON403 *Forbidden + JSON404 *NotFound + JSON409 *Conflict + JSON429 *TooManyRequests + JSON500 *InternalError +} + +// Status returns HTTPResponse.Status +func (r CreateCustomSchemaVersionResponse) Status() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Status + } + return http.StatusText(0) +} + +// StatusCode returns HTTPResponse.StatusCode +func (r CreateCustomSchemaVersionResponse) StatusCode() int { + if r.HTTPResponse != nil { + return r.HTTPResponse.StatusCode + } + return 0 +} + +type ResetDocumentMetadataResponse struct { + Body []byte + HTTPResponse *http.Response + JSON200 *DocumentMetadataResetResponse + JSON400 *InvalidRequest + JSON401 *Unauthorized + JSON403 *Forbidden + JSON404 *NotFound + JSON409 *Conflict + JSON429 *TooManyRequests + JSON500 *InternalError +} + +// Status returns HTTPResponse.Status +func (r ResetDocumentMetadataResponse) Status() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Status + } + return http.StatusText(0) +} + +// StatusCode returns HTTPResponse.StatusCode +func (r ResetDocumentMetadataResponse) StatusCode() int { + if r.HTTPResponse != nil { + return r.HTTPResponse.StatusCode + } + return 0 +} + +type AssignDocumentSchemaResponse struct { + Body []byte + HTTPResponse *http.Response + JSON200 *DocumentSchemaAssignResponse + JSON400 *InvalidRequest + JSON401 *Unauthorized + JSON403 *Forbidden + JSON404 *NotFound + JSON409 *Conflict + JSON429 *TooManyRequests + JSON500 *InternalError +} + +// Status returns HTTPResponse.Status +func (r AssignDocumentSchemaResponse) Status() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Status + } + return http.StatusText(0) +} + +// StatusCode returns HTTPResponse.StatusCode +func (r AssignDocumentSchemaResponse) StatusCode() int { + if r.HTTPResponse != nil { + return r.HTTPResponse.StatusCode + } + return 0 +} + type ListUISettingsResponse struct { Body []byte HTTPResponse *http.Response @@ -7539,6 +8957,50 @@ func (c *ClientWithResponses) ListClientsWithResponse(ctx context.Context, reqEd return ParseListClientsResponse(rsp) } +// GetCurrentCustomMetadataWithResponse request returning *GetCurrentCustomMetadataResponse +func (c *ClientWithResponses) GetCurrentCustomMetadataWithResponse(ctx context.Context, params *GetCurrentCustomMetadataParams, reqEditors ...RequestEditorFn) (*GetCurrentCustomMetadataResponse, error) { + rsp, err := c.GetCurrentCustomMetadata(ctx, params, reqEditors...) + if err != nil { + return nil, err + } + return ParseGetCurrentCustomMetadataResponse(rsp) +} + +// SetCustomMetadataWithBodyWithResponse request with arbitrary body returning *SetCustomMetadataResponse +func (c *ClientWithResponses) SetCustomMetadataWithBodyWithResponse(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*SetCustomMetadataResponse, error) { + rsp, err := c.SetCustomMetadataWithBody(ctx, contentType, body, reqEditors...) + if err != nil { + return nil, err + } + return ParseSetCustomMetadataResponse(rsp) +} + +func (c *ClientWithResponses) SetCustomMetadataWithResponse(ctx context.Context, body SetCustomMetadataJSONRequestBody, reqEditors ...RequestEditorFn) (*SetCustomMetadataResponse, error) { + rsp, err := c.SetCustomMetadata(ctx, body, reqEditors...) + if err != nil { + return nil, err + } + return ParseSetCustomMetadataResponse(rsp) +} + +// GetCustomMetadataHistoryWithResponse request returning *GetCustomMetadataHistoryResponse +func (c *ClientWithResponses) GetCustomMetadataHistoryWithResponse(ctx context.Context, params *GetCustomMetadataHistoryParams, reqEditors ...RequestEditorFn) (*GetCustomMetadataHistoryResponse, error) { + rsp, err := c.GetCustomMetadataHistory(ctx, params, reqEditors...) + if err != nil { + return nil, err + } + return ParseGetCustomMetadataHistoryResponse(rsp) +} + +// GetCustomMetadataByVersionWithResponse request returning *GetCustomMetadataByVersionResponse +func (c *ClientWithResponses) GetCustomMetadataByVersionWithResponse(ctx context.Context, params *GetCustomMetadataByVersionParams, reqEditors ...RequestEditorFn) (*GetCustomMetadataByVersionResponse, error) { + rsp, err := c.GetCustomMetadataByVersion(ctx, params, reqEditors...) + if err != nil { + return nil, err + } + return ParseGetCustomMetadataByVersionResponse(rsp) +} + // DeleteDocumentWithResponse request returning *DeleteDocumentResponse func (c *ClientWithResponses) DeleteDocumentWithResponse(ctx context.Context, id DocumentID, params *DeleteDocumentParams, reqEditors ...RequestEditorFn) (*DeleteDocumentResponse, error) { rsp, err := c.DeleteDocument(ctx, id, params, reqEditors...) @@ -7787,6 +9249,93 @@ func (c *ClientWithResponses) LogoutWithResponse(ctx context.Context, reqEditors return ParseLogoutResponse(rsp) } +// ListCustomSchemasWithResponse request returning *ListCustomSchemasResponse +func (c *ClientWithResponses) ListCustomSchemasWithResponse(ctx context.Context, params *ListCustomSchemasParams, reqEditors ...RequestEditorFn) (*ListCustomSchemasResponse, error) { + rsp, err := c.ListCustomSchemas(ctx, params, reqEditors...) + if err != nil { + return nil, err + } + return ParseListCustomSchemasResponse(rsp) +} + +// CreateCustomSchemaWithBodyWithResponse request with arbitrary body returning *CreateCustomSchemaResponse +func (c *ClientWithResponses) CreateCustomSchemaWithBodyWithResponse(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*CreateCustomSchemaResponse, error) { + rsp, err := c.CreateCustomSchemaWithBody(ctx, contentType, body, reqEditors...) + if err != nil { + return nil, err + } + return ParseCreateCustomSchemaResponse(rsp) +} + +func (c *ClientWithResponses) CreateCustomSchemaWithResponse(ctx context.Context, body CreateCustomSchemaJSONRequestBody, reqEditors ...RequestEditorFn) (*CreateCustomSchemaResponse, error) { + rsp, err := c.CreateCustomSchema(ctx, body, reqEditors...) + if err != nil { + return nil, err + } + return ParseCreateCustomSchemaResponse(rsp) +} + +// DeleteCustomSchemaWithResponse request returning *DeleteCustomSchemaResponse +func (c *ClientWithResponses) DeleteCustomSchemaWithResponse(ctx context.Context, schemaId openapi_types.UUID, reqEditors ...RequestEditorFn) (*DeleteCustomSchemaResponse, error) { + rsp, err := c.DeleteCustomSchema(ctx, schemaId, reqEditors...) + if err != nil { + return nil, err + } + return ParseDeleteCustomSchemaResponse(rsp) +} + +// GetCustomSchemaWithResponse request returning *GetCustomSchemaResponse +func (c *ClientWithResponses) GetCustomSchemaWithResponse(ctx context.Context, schemaId openapi_types.UUID, reqEditors ...RequestEditorFn) (*GetCustomSchemaResponse, error) { + rsp, err := c.GetCustomSchema(ctx, schemaId, reqEditors...) + if err != nil { + return nil, err + } + return ParseGetCustomSchemaResponse(rsp) +} + +// CreateCustomSchemaVersionWithBodyWithResponse request with arbitrary body returning *CreateCustomSchemaVersionResponse +func (c *ClientWithResponses) CreateCustomSchemaVersionWithBodyWithResponse(ctx context.Context, schemaId openapi_types.UUID, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*CreateCustomSchemaVersionResponse, error) { + rsp, err := c.CreateCustomSchemaVersionWithBody(ctx, schemaId, contentType, body, reqEditors...) + if err != nil { + return nil, err + } + return ParseCreateCustomSchemaVersionResponse(rsp) +} + +func (c *ClientWithResponses) CreateCustomSchemaVersionWithResponse(ctx context.Context, schemaId openapi_types.UUID, body CreateCustomSchemaVersionJSONRequestBody, reqEditors ...RequestEditorFn) (*CreateCustomSchemaVersionResponse, error) { + rsp, err := c.CreateCustomSchemaVersion(ctx, schemaId, body, reqEditors...) + if err != nil { + return nil, err + } + return ParseCreateCustomSchemaVersionResponse(rsp) +} + +// ResetDocumentMetadataWithResponse request returning *ResetDocumentMetadataResponse +func (c *ClientWithResponses) ResetDocumentMetadataWithResponse(ctx context.Context, id openapi_types.UUID, reqEditors ...RequestEditorFn) (*ResetDocumentMetadataResponse, error) { + rsp, err := c.ResetDocumentMetadata(ctx, id, reqEditors...) + if err != nil { + return nil, err + } + return ParseResetDocumentMetadataResponse(rsp) +} + +// AssignDocumentSchemaWithBodyWithResponse request with arbitrary body returning *AssignDocumentSchemaResponse +func (c *ClientWithResponses) AssignDocumentSchemaWithBodyWithResponse(ctx context.Context, id openapi_types.UUID, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*AssignDocumentSchemaResponse, error) { + rsp, err := c.AssignDocumentSchemaWithBody(ctx, id, contentType, body, reqEditors...) + if err != nil { + return nil, err + } + return ParseAssignDocumentSchemaResponse(rsp) +} + +func (c *ClientWithResponses) AssignDocumentSchemaWithResponse(ctx context.Context, id openapi_types.UUID, body AssignDocumentSchemaJSONRequestBody, reqEditors ...RequestEditorFn) (*AssignDocumentSchemaResponse, error) { + rsp, err := c.AssignDocumentSchema(ctx, id, body, reqEditors...) + if err != nil { + return nil, err + } + return ParseAssignDocumentSchemaResponse(rsp) +} + // ListUISettingsWithResponse request returning *ListUISettingsResponse func (c *ClientWithResponses) ListUISettingsWithResponse(ctx context.Context, params *ListUISettingsParams, reqEditors ...RequestEditorFn) (*ListUISettingsResponse, error) { rsp, err := c.ListUISettings(ctx, params, reqEditors...) @@ -9671,6 +11220,285 @@ func ParseListClientsResponse(rsp *http.Response) (*ListClientsResponse, error) return response, nil } +// ParseGetCurrentCustomMetadataResponse parses an HTTP response from a GetCurrentCustomMetadataWithResponse call +func ParseGetCurrentCustomMetadataResponse(rsp *http.Response) (*GetCurrentCustomMetadataResponse, error) { + bodyBytes, err := io.ReadAll(rsp.Body) + defer func() { _ = rsp.Body.Close() }() + if err != nil { + return nil, err + } + + response := &GetCurrentCustomMetadataResponse{ + Body: bodyBytes, + HTTPResponse: rsp, + } + + switch { + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: + var dest CustomMetadataResponse + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON200 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 400: + var dest InvalidRequest + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON400 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 401: + var dest Unauthorized + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON401 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 403: + var dest Forbidden + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON403 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 404: + var dest NotFound + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON404 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 429: + var dest TooManyRequests + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON429 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 500: + var dest InternalError + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON500 = &dest + + } + + return response, nil +} + +// ParseSetCustomMetadataResponse parses an HTTP response from a SetCustomMetadataWithResponse call +func ParseSetCustomMetadataResponse(rsp *http.Response) (*SetCustomMetadataResponse, error) { + bodyBytes, err := io.ReadAll(rsp.Body) + defer func() { _ = rsp.Body.Close() }() + if err != nil { + return nil, err + } + + response := &SetCustomMetadataResponse{ + Body: bodyBytes, + HTTPResponse: rsp, + } + + switch { + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 201: + var dest CustomMetadataResponse + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON201 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 400: + var dest InvalidRequest + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON400 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 401: + var dest Unauthorized + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON401 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 403: + var dest Forbidden + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON403 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 404: + var dest NotFound + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON404 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 409: + var dest Conflict + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON409 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 429: + var dest TooManyRequests + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON429 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 500: + var dest InternalError + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON500 = &dest + + } + + return response, nil +} + +// ParseGetCustomMetadataHistoryResponse parses an HTTP response from a GetCustomMetadataHistoryWithResponse call +func ParseGetCustomMetadataHistoryResponse(rsp *http.Response) (*GetCustomMetadataHistoryResponse, error) { + bodyBytes, err := io.ReadAll(rsp.Body) + defer func() { _ = rsp.Body.Close() }() + if err != nil { + return nil, err + } + + response := &GetCustomMetadataHistoryResponse{ + Body: bodyBytes, + HTTPResponse: rsp, + } + + switch { + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: + var dest CustomMetadataHistoryResponse + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON200 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 400: + var dest InvalidRequest + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON400 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 401: + var dest Unauthorized + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON401 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 403: + var dest Forbidden + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON403 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 404: + var dest NotFound + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON404 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 429: + var dest TooManyRequests + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON429 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 500: + var dest InternalError + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON500 = &dest + + } + + return response, nil +} + +// ParseGetCustomMetadataByVersionResponse parses an HTTP response from a GetCustomMetadataByVersionWithResponse call +func ParseGetCustomMetadataByVersionResponse(rsp *http.Response) (*GetCustomMetadataByVersionResponse, error) { + bodyBytes, err := io.ReadAll(rsp.Body) + defer func() { _ = rsp.Body.Close() }() + if err != nil { + return nil, err + } + + response := &GetCustomMetadataByVersionResponse{ + Body: bodyBytes, + HTTPResponse: rsp, + } + + switch { + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: + var dest CustomMetadataResponse + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON200 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 400: + var dest InvalidRequest + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON400 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 401: + var dest Unauthorized + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON401 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 403: + var dest Forbidden + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON403 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 404: + var dest NotFound + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON404 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 429: + var dest TooManyRequests + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON429 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 500: + var dest InternalError + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON500 = &dest + + } + + return response, nil +} + // ParseDeleteDocumentResponse parses an HTTP response from a DeleteDocumentWithResponse call func ParseDeleteDocumentResponse(rsp *http.Response) (*DeleteDocumentResponse, error) { bodyBytes, err := io.ReadAll(rsp.Body) @@ -11066,6 +12894,496 @@ func ParseLogoutResponse(rsp *http.Response) (*LogoutResponse, error) { return response, nil } +// ParseListCustomSchemasResponse parses an HTTP response from a ListCustomSchemasWithResponse call +func ParseListCustomSchemasResponse(rsp *http.Response) (*ListCustomSchemasResponse, error) { + bodyBytes, err := io.ReadAll(rsp.Body) + defer func() { _ = rsp.Body.Close() }() + if err != nil { + return nil, err + } + + response := &ListCustomSchemasResponse{ + Body: bodyBytes, + HTTPResponse: rsp, + } + + switch { + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: + var dest CustomSchemaListResponse + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON200 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 400: + var dest InvalidRequest + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON400 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 401: + var dest Unauthorized + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON401 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 403: + var dest Forbidden + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON403 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 429: + var dest TooManyRequests + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON429 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 500: + var dest InternalError + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON500 = &dest + + } + + return response, nil +} + +// ParseCreateCustomSchemaResponse parses an HTTP response from a CreateCustomSchemaWithResponse call +func ParseCreateCustomSchemaResponse(rsp *http.Response) (*CreateCustomSchemaResponse, error) { + bodyBytes, err := io.ReadAll(rsp.Body) + defer func() { _ = rsp.Body.Close() }() + if err != nil { + return nil, err + } + + response := &CreateCustomSchemaResponse{ + Body: bodyBytes, + HTTPResponse: rsp, + } + + switch { + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 201: + var dest CustomSchemaResponse + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON201 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 400: + var dest InvalidRequest + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON400 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 401: + var dest Unauthorized + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON401 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 403: + var dest Forbidden + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON403 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 409: + var dest Conflict + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON409 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 429: + var dest TooManyRequests + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON429 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 500: + var dest InternalError + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON500 = &dest + + } + + return response, nil +} + +// ParseDeleteCustomSchemaResponse parses an HTTP response from a DeleteCustomSchemaWithResponse call +func ParseDeleteCustomSchemaResponse(rsp *http.Response) (*DeleteCustomSchemaResponse, error) { + bodyBytes, err := io.ReadAll(rsp.Body) + defer func() { _ = rsp.Body.Close() }() + if err != nil { + return nil, err + } + + response := &DeleteCustomSchemaResponse{ + Body: bodyBytes, + HTTPResponse: rsp, + } + + switch { + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 400: + var dest InvalidRequest + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON400 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 401: + var dest Unauthorized + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON401 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 403: + var dest Forbidden + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON403 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 404: + var dest NotFound + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON404 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 409: + var dest Conflict + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON409 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 429: + var dest TooManyRequests + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON429 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 500: + var dest InternalError + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON500 = &dest + + } + + return response, nil +} + +// ParseGetCustomSchemaResponse parses an HTTP response from a GetCustomSchemaWithResponse call +func ParseGetCustomSchemaResponse(rsp *http.Response) (*GetCustomSchemaResponse, error) { + bodyBytes, err := io.ReadAll(rsp.Body) + defer func() { _ = rsp.Body.Close() }() + if err != nil { + return nil, err + } + + response := &GetCustomSchemaResponse{ + Body: bodyBytes, + HTTPResponse: rsp, + } + + switch { + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: + var dest CustomSchemaResponse + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON200 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 400: + var dest InvalidRequest + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON400 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 401: + var dest Unauthorized + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON401 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 403: + var dest Forbidden + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON403 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 404: + var dest NotFound + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON404 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 429: + var dest TooManyRequests + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON429 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 500: + var dest InternalError + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON500 = &dest + + } + + return response, nil +} + +// ParseCreateCustomSchemaVersionResponse parses an HTTP response from a CreateCustomSchemaVersionWithResponse call +func ParseCreateCustomSchemaVersionResponse(rsp *http.Response) (*CreateCustomSchemaVersionResponse, error) { + bodyBytes, err := io.ReadAll(rsp.Body) + defer func() { _ = rsp.Body.Close() }() + if err != nil { + return nil, err + } + + response := &CreateCustomSchemaVersionResponse{ + Body: bodyBytes, + HTTPResponse: rsp, + } + + switch { + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 201: + var dest CustomSchemaResponse + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON201 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 400: + var dest InvalidRequest + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON400 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 401: + var dest Unauthorized + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON401 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 403: + var dest Forbidden + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON403 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 404: + var dest NotFound + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON404 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 409: + var dest Conflict + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON409 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 429: + var dest TooManyRequests + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON429 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 500: + var dest InternalError + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON500 = &dest + + } + + return response, nil +} + +// ParseResetDocumentMetadataResponse parses an HTTP response from a ResetDocumentMetadataWithResponse call +func ParseResetDocumentMetadataResponse(rsp *http.Response) (*ResetDocumentMetadataResponse, error) { + bodyBytes, err := io.ReadAll(rsp.Body) + defer func() { _ = rsp.Body.Close() }() + if err != nil { + return nil, err + } + + response := &ResetDocumentMetadataResponse{ + Body: bodyBytes, + HTTPResponse: rsp, + } + + switch { + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: + var dest DocumentMetadataResetResponse + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON200 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 400: + var dest InvalidRequest + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON400 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 401: + var dest Unauthorized + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON401 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 403: + var dest Forbidden + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON403 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 404: + var dest NotFound + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON404 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 409: + var dest Conflict + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON409 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 429: + var dest TooManyRequests + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON429 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 500: + var dest InternalError + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON500 = &dest + + } + + return response, nil +} + +// ParseAssignDocumentSchemaResponse parses an HTTP response from a AssignDocumentSchemaWithResponse call +func ParseAssignDocumentSchemaResponse(rsp *http.Response) (*AssignDocumentSchemaResponse, error) { + bodyBytes, err := io.ReadAll(rsp.Body) + defer func() { _ = rsp.Body.Close() }() + if err != nil { + return nil, err + } + + response := &AssignDocumentSchemaResponse{ + Body: bodyBytes, + HTTPResponse: rsp, + } + + switch { + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: + var dest DocumentSchemaAssignResponse + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON200 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 400: + var dest InvalidRequest + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON400 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 401: + var dest Unauthorized + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON401 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 403: + var dest Forbidden + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON403 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 404: + var dest NotFound + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON404 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 409: + var dest Conflict + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON409 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 429: + var dest TooManyRequests + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON429 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 500: + var dest InternalError + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON500 = &dest + + } + + return response, nil +} + // ParseListUISettingsResponse parses an HTTP response from a ListUISettingsWithResponse call func ParseListUISettingsResponse(rsp *http.Response) (*ListUISettingsResponse, error) { bodyBytes, err := io.ReadAll(rsp.Body) diff --git a/plans/metadata.progress.review.codex.md b/plans/metadata.progress.review.codex.md new file mode 100644 index 00000000..ccc7adac --- /dev/null +++ b/plans/metadata.progress.review.codex.md @@ -0,0 +1,19 @@ +# Mutable Metadata Progress Review + +Scope: this is a file-state review only. Q reported that `task fullsuite:ci` is now passing; I did not rerun it. I reviewed the current tracking doc, journal, docs, and mutable-metadata implementation files. + +## Findings + +1. **High: the progress artifacts are still contradictory enough to mislead the next handoff.** The tracking file marks Milestone `0-INT` as `COMPLETE` at [plans/mutable.metadata.plan.combo.v4.tracking.md](/Users/jaybrown/dev/clients/aarete/query-orchestration.2/plans/mutable.metadata.plan.combo.v4.tracking.md:39), but the same section still contains unchecked “What remains” items at [plans/mutable.metadata.plan.combo.v4.tracking.md](/Users/jaybrown/dev/clients/aarete/query-orchestration.2/plans/mutable.metadata.plan.combo.v4.tracking.md:57). More importantly, the detailed Milestone 3 checklist is still entirely `[ ]` at [plans/mutable.metadata.plan.combo.v4.tracking.md](/Users/jaybrown/dev/clients/aarete/query-orchestration.2/plans/mutable.metadata.plan.combo.v4.tracking.md:428), even though reset query/service/handler work already exists in [internal/customschema/service_reset.go](/Users/jaybrown/dev/clients/aarete/query-orchestration.2/internal/customschema/service_reset.go:44) and [api/queryAPI/customschemas_reset.go](/Users/jaybrown/dev/clients/aarete/query-orchestration.2/api/queryAPI/customschemas_reset.go:22). The journal is also stale: it still says `api/queryAPI/customschemas_reset_test.go` “does NOT exist yet” at [journals/implement_mutableMetadata.md](/Users/jaybrown/dev/clients/aarete/query-orchestration.2/journals/implement_mutableMetadata.md:427), but that file now exists and contains reset endpoint tests at [api/queryAPI/customschemas_reset_test.go](/Users/jaybrown/dev/clients/aarete/query-orchestration.2/api/queryAPI/customschemas_reset_test.go:54). There is also a naming mismatch: the tracking checklist still refers to `ResetDocumentMetadataResponse` at [plans/mutable.metadata.plan.combo.v4.tracking.md](/Users/jaybrown/dev/clients/aarete/query-orchestration.2/plans/mutable.metadata.plan.combo.v4.tracking.md:459), while the actual spec/code use `DocumentMetadataResetResponse` at [serviceAPIs/queryAPI.yaml](/Users/jaybrown/dev/clients/aarete/query-orchestration.2/serviceAPIs/queryAPI.yaml:4216). The codebase has moved forward; the handoff documents have not. + +2. **Medium-High: `SetDocumentMetadata` still does not implement the documented retry-on-unique-violation fallback.** The tracking file marks this complete at [plans/mutable.metadata.plan.combo.v4.tracking.md](/Users/jaybrown/dev/clients/aarete/query-orchestration.2/plans/mutable.metadata.plan.combo.v4.tracking.md:308), but the implementation in [internal/customschema/service_metadata.go](/Users/jaybrown/dev/clients/aarete/query-orchestration.2/internal/customschema/service_metadata.go:136) computes `nextVersion`, performs a single `CreateDocumentCustomMetadata` call, and returns immediately on insert error. I could not find a `23505` / `uq_doc_custom_metadata_version` branch or retry loop. The test file also has happy-path, validation, and pagination coverage in [internal/customschema/service_metadata_test.go](/Users/jaybrown/dev/clients/aarete/query-orchestration.2/internal/customschema/service_metadata_test.go:152), but no injected unique-violation retry case. So this is still both an implementation gap and a tracking-doc overstatement. + +3. **Medium: Milestone 3 coverage is still materially short of the planned test surface, even if the suite is green.** The plan/tracking expect a full upgrade flow, schema-version concurrency, and reset/version authorization-matrix coverage at [plans/mutable.metadata.plan.combo.v4.tracking.md](/Users/jaybrown/dev/clients/aarete/query-orchestration.2/plans/mutable.metadata.plan.combo.v4.tracking.md:470). The current reset test file covers only the reset endpoint’s direct status paths: missing subject, not found, legacy conflict, happy path, and idempotent clean-doc in [api/queryAPI/customschemas_reset_test.go](/Users/jaybrown/dev/clients/aarete/query-orchestration.2/api/queryAPI/customschemas_reset_test.go:54), [api/queryAPI/customschemas_reset_test.go](/Users/jaybrown/dev/clients/aarete/query-orchestration.2/api/queryAPI/customschemas_reset_test.go:77), [api/queryAPI/customschemas_reset_test.go](/Users/jaybrown/dev/clients/aarete/query-orchestration.2/api/queryAPI/customschemas_reset_test.go:91), [api/queryAPI/customschemas_reset_test.go](/Users/jaybrown/dev/clients/aarete/query-orchestration.2/api/queryAPI/customschemas_reset_test.go:118), and [api/queryAPI/customschemas_reset_test.go](/Users/jaybrown/dev/clients/aarete/query-orchestration.2/api/queryAPI/customschemas_reset_test.go:183). I found no current tests for the planned “bind v1 -> write -> create v2 -> reset -> rebind -> write” flow, no end-to-end schema version concurrency test, and no reset/version auth-matrix test in the checked `api/queryAPI` test files. A green suite here means “no current regressions detected,” not “Milestone 3 is fully proven against its own checklist.” + +4. **Medium: the human-facing docs still stop at Milestone 2 even though the OpenAPI spec now exposes reset-metadata.** The source-of-truth spec contains `POST /super-admin/documents/{id}/reset-metadata` and `DocumentMetadataResetResponse` at [serviceAPIs/queryAPI.yaml](/Users/jaybrown/dev/clients/aarete/query-orchestration.2/serviceAPIs/queryAPI.yaml:2995) and [serviceAPIs/queryAPI.yaml](/Users/jaybrown/dev/clients/aarete/query-orchestration.2/serviceAPIs/queryAPI.yaml:4216). But the summary doc’s quick-reference table stops at `PATCH /super-admin/documents/{id}/schema` plus the four `/custom-metadata` routes at [docs/ai.generated/queryapi.summary.md](/Users/jaybrown/dev/clients/aarete/query-orchestration.2/docs/ai.generated/queryapi.summary.md:53), and the service description section likewise ends with schema assignment at [docs/ai.generated/queryapi.summary.md](/Users/jaybrown/dev/clients/aarete/query-orchestration.2/docs/ai.generated/queryapi.summary.md:518). The API documentation authorization table also lists `PATCH /super-admin/documents/{id}/schema` and the four `/custom-metadata` routes, but not reset-metadata, at [docs/ai.generated/03-api-documentation.md](/Users/jaybrown/dev/clients/aarete/query-orchestration.2/docs/ai.generated/03-api-documentation.md:1980). This is now a real user-facing drift, not just a future-doc TODO. + +5. **Medium-Low: the new metadata audit events still emit an empty `client_id`, which weakens the audit trail.** `AuditRecord` carries `ClientID` in [internal/customschema/audit.go](/Users/jaybrown/dev/clients/aarete/query-orchestration.2/internal/customschema/audit.go:30), but `SetDocumentMetadata` hardcodes `ClientID: ""` at [internal/customschema/service_metadata.go](/Users/jaybrown/dev/clients/aarete/query-orchestration.2/internal/customschema/service_metadata.go:162) even though it already loaded the bound schema row earlier at [internal/customschema/service_metadata.go](/Users/jaybrown/dev/clients/aarete/query-orchestration.2/internal/customschema/service_metadata.go:120). `ResetDocumentMetadata` similarly records `metadata.reset` without populating `ClientID` at [internal/customschema/service_reset.go](/Users/jaybrown/dev/clients/aarete/query-orchestration.2/internal/customschema/service_reset.go:78). `schema.assign` does populate client id correctly at [internal/customschema/service_metadata.go](/Users/jaybrown/dev/clients/aarete/query-orchestration.2/internal/customschema/service_metadata.go:381), so the inconsistency is limited to the newer metadata/reset write paths. + +## Overall Read + +The codebase is in a materially better state than the previous review: reset-metadata now exists in the spec and code, and there is at least direct endpoint coverage for the reset path. The remaining problems are mostly about truthfulness and completeness rather than obvious compilation/runtime breakage: the tracking/journal state is behind the code, the planned retry fallback in `SetDocumentMetadata` still has not landed, Milestone 3’s full planned test surface is not yet present, and the human-facing docs have not caught up with the new reset endpoint. diff --git a/plans/mutable.metadata.plan.claude.md b/plans/mutable.metadata.plan.claude.md new file mode 100644 index 00000000..cd2d1719 --- /dev/null +++ b/plans/mutable.metadata.plan.claude.md @@ -0,0 +1,1100 @@ +# Mutable Metadata & Per-Client Custom Schemas - Design Plan + +## Status: DRAFT - Updated with Q's design decisions (2026-03-23) + +--- + +## 1. Problem Statement + +Today, all field extraction data is stored in a rigid 3-table schema with ~130 statically-defined columns (19 single-value fields + 112 array fields). Every client shares the same field definitions. This means: + +- Clients cannot define domain-specific fields (e.g., "aircraft engineering" vs. "auto engineering" document classes) +- Adding new fields requires database migrations and code changes across the full stack +- The system cannot accommodate varying data shapes per document class within a single client + +**Goal**: Allow each client to curate their own collections of document schemas, where each schema defines the custom fields (with types and constraints) that a document's metadata must conform to. + +--- + +## 2. Current Architecture Summary + +### 2.1 Existing Tables + +``` +documentFieldExtractions -- 19 single-value fields (1:1 per document) +documentFieldExtractionVersions -- Version tracking with row-level locking +documentFieldExtractionArrayFields -- 112 array fields (1:N per extraction) +``` + +### 2.2 Existing API Endpoints + +| Method | Path | Description | +|--------|------|-------------| +| POST | `/field-extractions` | Create versioned extraction (singleFields + arrayFields) | +| GET | `/field-extractions` | Get current (latest version) extraction for a document | +| GET | `/field-extractions/version` | Get extraction by specific version | +| GET | `/field-extractions/history` | List all versions for a document | + +### 2.3 Key Relationships + +- `documents.id` -> `documentFieldExtractions.documentId` (1:N via versions) +- `documentFieldExtractions.id` -> `documentFieldExtractionArrayFields.fieldExtractionId` (1:N) +- `documentFieldExtractions.id` -> `documentFieldExtractionVersions.fieldExtractionId` (1:N) +- Documents belong to clients via `documents.clientId` +- No per-client customization of field definitions exists today + +--- + +## 3. Design Principles + +1. **Additive, not destructive** - The existing static field extraction system continues to work unchanged. Custom schemas are an orthogonal capability. +2. **Schema-as-data** - Schemas are stored as JSON Schema documents in the database, not as DDL. No migrations needed when clients add/modify schemas. +3. **Immutable schema versions** - When a schema is updated, a new version (with new ID) is created. Old versions remain for historical reference. No backward compatibility checking is required between versions since each version gets a distinct ID. +4. **Schema binding is permanent** - Once custom metadata is written to a document with a schema ID, that schema ID cannot be changed on the document. This guarantees data integrity. +5. **JSON blob storage** - Custom metadata is stored as JSONB in PostgreSQL, validated against the schema at write time. This avoids dynamic column creation. +6. **Client isolation** - Schemas are scoped to clients. A client cannot reference another client's schema. +7. **Schema size limit** - Schema definitions are capped at 64KB (`MaxSchemaDefinitionBytes = 65536`). This is a tunable constant that can be increased later if needed. +8. **No JSONB search/filter** - The system does NOT support querying or filtering documents by custom metadata values. Custom metadata is opaque storage validated on write. If search is needed in the future, it would require GIN indexes on the JSONB columns and a dedicated query API (separate phase). +9. **Admin-scoped schema management** - Schema CRUD operations live under `/admin` and require `client_admin` role. Custom metadata read/write on documents is available to regular users. +10. **No migration from static fields** - There is no migration path from the existing static field extraction data into the custom schema system. They are independent systems. Out of scope. + +--- + +## 4. Database Schema Design + +### 4.1 New Table: `client_metadata_schemas` + +Stores client-defined JSON Schema documents. Each row is an immutable version of a schema definition. + +```sql +CREATE TABLE client_metadata_schemas ( + id uuid PRIMARY KEY DEFAULT uuid_generate_v7(), + client_id varchar(255) NOT NULL REFERENCES clients(clientId), + name varchar(255) NOT NULL, + description text, + schema_def jsonb NOT NULL, -- The JSON Schema document (max 64KB enforced at app layer) + version int NOT NULL DEFAULT 1, -- Auto-incremented per (client_id, name) + is_active boolean NOT NULL DEFAULT true, + created_at timestamptz NOT NULL DEFAULT NOW(), + created_by varchar(255) NOT NULL, + + CONSTRAINT uq_client_schema_name_version UNIQUE (client_id, name, version), + CONSTRAINT chk_schema_def_size CHECK (pg_column_size(schema_def) <= 65536) +); + +CREATE INDEX idx_cms_client_id ON client_metadata_schemas(client_id); +CREATE INDEX idx_cms_client_name ON client_metadata_schemas(client_id, name); +CREATE INDEX idx_cms_active ON client_metadata_schemas(client_id, is_active); +``` + +> **Note**: The 64KB limit is enforced at both the application layer (Go constant `MaxSchemaDefinitionBytes = 65536`) and the database layer (CHECK constraint). The app-layer check provides a better error message; the DB constraint is a safety net. + +**Design decisions:** + +- **`name` + `version`** scoped to client: Allows human-friendly naming ("aircraft-engineering-v3") with automatic versioning. When a schema is "updated," a new row is inserted with `version = max(version) + 1` for that `(client_id, name)`. +- **`schema_def` is JSONB**: Stores the full JSON Schema document. PostgreSQL can index into it if needed later. +- **`is_active`**: Soft-delete flag. When a schema is "deleted," it is marked inactive. Schemas referenced by existing documents cannot be hard-deleted. +- **`id` is the schema version ID**: This is what documents reference. Each update creates a new `id`. The `(client_id, name, version)` tuple provides the human-readable lineage. + +### 4.2 New Table: `document_custom_metadata` + +Stores the actual custom metadata for documents, validated against the referenced schema. + +```sql +CREATE TABLE document_custom_metadata ( + id uuid PRIMARY KEY DEFAULT uuid_generate_v7(), + document_id uuid NOT NULL REFERENCES documents(id), + schema_id uuid NOT NULL REFERENCES client_metadata_schemas(id), + metadata jsonb NOT NULL, -- The actual custom field values + version int NOT NULL DEFAULT 1, -- Versioned like field extractions + created_at timestamptz NOT NULL DEFAULT NOW(), + created_by varchar(255) NOT NULL, + + CONSTRAINT uq_doc_custom_metadata_version UNIQUE (document_id, version) +); + +CREATE INDEX idx_dcm_document_id ON document_custom_metadata(document_id); +CREATE INDEX idx_dcm_schema_id ON document_custom_metadata(schema_id); +``` + +**Design decisions:** + +- **Versioned**: Like field extractions, custom metadata supports version history. Each write creates a new version. +- **`schema_id` is immutable per document**: Enforced at the application layer (see Rule 4.3). All versions of custom metadata for a document must reference the same schema ID. +- **`metadata` is JSONB**: Validated against `client_metadata_schemas.schema_def` at write time. Stored as a single blob for simplicity and query flexibility. + +### 4.3 Alteration: `documents` Table + +Add an optional schema binding column: + +```sql +ALTER TABLE documents + ADD COLUMN custom_schema_id uuid NULL REFERENCES client_metadata_schemas(id); + +CREATE INDEX idx_documents_custom_schema_id ON documents(custom_schema_id); +``` + +**Behavior:** + +- `NULL` = document uses only the legacy static field extraction system (default, backward-compatible) +- Once set AND custom metadata has been written, this field becomes immutable (enforced in application code) +- Can be set before metadata is written (pre-binding) and changed freely until first metadata write +- Must reference a schema owned by the same client as the document (cross-client reference check) + +### 4.4 New View: `current_document_custom_metadata` + +```sql +CREATE VIEW current_document_custom_metadata AS +SELECT DISTINCT ON (document_id) + id, + document_id, + schema_id, + metadata, + version, + created_at, + created_by +FROM document_custom_metadata +ORDER BY document_id, version DESC; +``` + +### 4.5 Entity Relationship Diagram + +``` +clients + | + |-- 1:N --> client_metadata_schemas (per-client schema definitions) + | | + | |-- referenced by --> documents.custom_schema_id + | |-- referenced by --> document_custom_metadata.schema_id + | + |-- 1:N --> documents + | + |-- 1:N --> document_custom_metadata (versioned JSONB blobs) + |-- 1:N --> documentFieldExtractions (existing static fields) +``` + +--- + +## 5. API Design + +### 5.1 Schema Management Endpoints (Admin) + +All under the existing `AdminService` tag. These endpoints require the `client_admin` role (see Section 5.5 for auth details). Endpoints are prefixed with `/admin/custom-schemas`. + +#### `POST /admin/custom-schemas` + +Create a new schema for a client. + +**Request:** +```json +{ + "clientId": "acme-corp", + "name": "aircraft-engineering", + "description": "Custom fields for aircraft engineering documents", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "aircraft_model": { + "type": "string", + "maxLength": 100 + }, + "certification_date": { + "type": "string", + "format": "date" + }, + "max_altitude_ft": { + "type": "number", + "minimum": 0, + "maximum": 100000 + }, + "engine_count": { + "type": "integer", + "minimum": 1, + "maximum": 12 + } + }, + "required": ["aircraft_model"], + "additionalProperties": false + }, + "createdBy": "admin@acme.com" +} +``` + +**Response (201):** +```json +{ + "id": "019577a3-...", + "clientId": "acme-corp", + "name": "aircraft-engineering", + "description": "Custom fields for aircraft engineering documents", + "schema": { ... }, + "version": 1, + "isActive": true, + "createdAt": "2026-03-23T12:00:00Z", + "createdBy": "admin@acme.com" +} +``` + +**Validation:** +- The `schema` field MUST be a valid JSON Schema document (validated server-side using a JSON Schema meta-validator) +- The `schema` field must be <= 64KB in size (returns 400 with message referencing the limit) +- `name` must be unique within the client (for active schemas at the same version) +- `additionalProperties: false` is recommended but not required + +#### `PUT /admin/custom-schemas/{schemaId}` + +Update an existing schema. Creates a new version with a new ID. The old version remains intact. No backward compatibility check is performed -- the new version is treated as an independent schema that happens to share a name lineage. + +**Request:** +```json +{ + "description": "Updated aircraft engineering fields (added wingspan)", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "aircraft_model": { "type": "string", "maxLength": 100 }, + "certification_date": { "type": "string", "format": "date" }, + "max_altitude_ft": { "type": "number", "minimum": 0, "maximum": 100000 }, + "engine_count": { "type": "integer", "minimum": 1, "maximum": 12 }, + "wingspan_meters": { "type": "number", "minimum": 0 } + }, + "required": ["aircraft_model"], + "additionalProperties": false + }, + "createdBy": "admin@acme.com" +} +``` + +**Response (201):** +```json +{ + "id": "019577b4-...", + "clientId": "acme-corp", + "name": "aircraft-engineering", + "version": 2, + "previousVersionId": "019577a3-...", + ... +} +``` + +**Behavior:** +- The `schemaId` in the path identifies the schema to "update" (really: create a new version of) +- The new version inherits the `name` from the previous version +- The old version remains active and referenced by existing documents +- Returns the newly created schema version + +#### `GET /admin/custom-schemas` + +List all schemas for a client. + +**Query Parameters:** +- `clientId` (required): The client whose schemas to list +- `name` (optional): Filter by schema name +- `activeOnly` (optional, default: true): Only return active schemas +- `includeAllVersions` (optional, default: false): Return all versions or just latest per name + +**Response (200):** +```json +{ + "schemas": [ + { + "id": "019577b4-...", + "clientId": "acme-corp", + "name": "aircraft-engineering", + "description": "...", + "version": 2, + "isActive": true, + "documentCount": 45, + "createdAt": "...", + "createdBy": "..." + }, + { + "id": "019577c1-...", + "clientId": "acme-corp", + "name": "auto-engineering", + "description": "...", + "version": 1, + "isActive": true, + "documentCount": 120, + "createdAt": "...", + "createdBy": "..." + } + ] +} +``` + +**Note:** `documentCount` shows how many documents reference each schema version. This is computed via a subquery or join. + +#### `GET /admin/custom-schemas/{schemaId}` + +Get a specific schema by ID (returns full schema definition). + +**Response (200):** +```json +{ + "id": "019577a3-...", + "clientId": "acme-corp", + "name": "aircraft-engineering", + "description": "...", + "schema": { ... }, + "version": 1, + "isActive": true, + "documentCount": 45, + "createdAt": "...", + "createdBy": "..." +} +``` + +#### `DELETE /admin/custom-schemas/{schemaId}` + +Soft-delete a schema. Only succeeds if no documents reference it. + +**Response (204):** No content on success. + +**Response (409 Conflict):** +```json +{ + "error": "Schema is in use by 45 documents and cannot be deleted" +} +``` + +**Behavior:** +- Sets `is_active = false` +- Fails with 409 if any documents reference this schema ID +- Inactive schemas still serve validation for existing documents but cannot be assigned to new documents + +--- + +### 5.2 Document Schema Assignment Endpoints + +#### `PATCH /document/{id}/schema` + +Assign or change a custom schema on a single document. + +**Request:** +```json +{ + "customSchemaId": "019577a3-..." +} +``` + +**Response (200):** +```json +{ + "documentId": "...", + "customSchemaId": "019577a3-...", + "schemaName": "aircraft-engineering", + "schemaVersion": 1 +} +``` + +**Validation rules:** +- Schema must belong to the same client as the document +- Schema must be active +- If the document already has custom metadata written, the schema cannot be changed (returns 409) +- Setting `customSchemaId` to `null` removes the binding (only if no metadata exists) + +#### `POST /admin/folders/{folderId}/assign-schema` (Bulk Folder Assignment) + +Assign a custom schema to ALL documents in a folder and all its subfolders (recursive). + +**Request:** +```json +{ + "customSchemaId": "019577a3-...", + "createdBy": "admin@acme.com" +} +``` + +**Response (200):** +```json +{ + "folderId": "...", + "customSchemaId": "019577a3-...", + "schemaName": "aircraft-engineering", + "documentsUpdated": 87, + "documentsSkipped": 3, + "skippedReasons": [ + {"documentId": "...", "reason": "Document already has metadata with a different schema"}, + {"documentId": "...", "reason": "Document already has metadata with a different schema"}, + {"documentId": "...", "reason": "Document already assigned to same schema (no-op)"} + ] +} +``` + +**Behavior:** +- Uses `WITH RECURSIVE` CTE to walk the entire folder subtree (same pattern as folder deletion) +- For each document in the tree: + - If the document has no schema and no custom metadata -> assign the schema + - If the document already has the same schema -> skip (no-op, counted in `documentsSkipped`) + - If the document has a different schema but no metadata yet -> reassign to new schema + - If the document has a different schema AND has metadata -> skip with reason (cannot change) +- Runs in a single transaction +- Returns summary of what was updated and what was skipped (with reasons) +- Requires `client_admin` role (admin endpoint) + +#### `GET /document/{id}` (Modified) + +Add `customSchemaId` and `customMetadata` fields to `DocumentEnriched`: + +```json +{ + "id": "...", + "clientId": "acme-corp", + "hash": "...", + "customSchemaId": "019577a3-...", + "customSchemaName": "aircraft-engineering", + "hasCustomMetadata": true, + "customMetadata": { ... }, + ...existing fields... +} +``` + +**Query parameter:** `customMetadata` (boolean, default: false) - when true, includes the full custom metadata in the response (like the existing `textRecord` parameter). + +--- + +### 5.5 Authorization & Permit.io Changes + +#### New Role: `client_admin` + +A new Permit.io role needs to be created between `user_admin` and `client_user`: + +| Role | Schema CRUD | Schema Assignment | Metadata Read | Metadata Write | Existing Admin | +|------|------------|-------------------|---------------|----------------|----------------| +| `super_admin` | Yes | Yes | Yes | Yes | Yes (full) | +| `client_admin` | Yes | Yes | Yes | Yes | No | +| `user_admin` | No | No | Yes | Yes | Yes (full) | +| `auditor` | Read-only | No | Read-only | No | Read-only | +| `client_user` | No | No | Yes | Yes | No | + +**Permit.io policy additions** (in `permit_policies.yaml`): + +```yaml +client_admin: + admin: + - get + - post + - patch + - delete + - head + # Plus all permissions that client_user has: + client: + - get + - post + - patch + documents: + - get + - post + document: + - get + - post + - patch + - delete + folders: + - get + - post + - patch + field-extractions: + - get + - post + custom-metadata: + - get + - post + export: + - get + - post +``` + +**Key distinction**: `client_admin` gets `admin:*` permissions (for schema management under `/admin/custom-schemas`) plus all document/metadata operations. Unlike `super_admin`, it does NOT get system-wide admin capabilities (user management, EULA management, etc.). + +> **Open question**: The current Permit.io setup maps `/admin/*` to the `admin` resource uniformly. If `client_admin` should access `/admin/custom-schemas` but NOT `/admin/users`, the resource mapping may need to become more granular (e.g., separate `admin-schemas` resource). Alternative: use a single `admin` resource and control via tenant-scoped checks. This is an implementation detail to resolve during Phase 6. + +--- + +### 5.3 Custom Metadata CRUD Endpoints (New) + +All under a new `CustomMetadataService` tag. + +#### `POST /custom-metadata` + +Create or update custom metadata for a document. Creates a new version. + +**Request:** +```json +{ + "documentId": "...", + "metadata": { + "aircraft_model": "Boeing 737-800", + "certification_date": "2024-06-15", + "max_altitude_ft": 41000, + "engine_count": 2 + }, + "createdBy": "analyst@acme.com" +} +``` + +**Response (201):** +```json +{ + "id": "...", + "documentId": "...", + "schemaId": "019577a3-...", + "metadata": { ... }, + "version": 1, + "createdAt": "...", + "createdBy": "analyst@acme.com" +} +``` + +**Validation:** +- Document must have a `custom_schema_id` assigned (400 if not) +- The `metadata` payload is validated against the JSON Schema stored in `client_metadata_schemas.schema_def` +- If validation fails, returns 400 with detailed validation errors: + ```json + { + "error": "Metadata does not conform to schema", + "validationErrors": [ + {"field": "engine_count", "message": "must be >= 1, got 0"}, + {"field": "aircraft_model", "message": "required field missing"} + ] + } + ``` +- On first write, locks the document's `custom_schema_id` (makes it immutable) +- Subsequent writes create new versions but must use the same schema + +#### `GET /custom-metadata` + +Get current (latest version) custom metadata for a document. + +**Query Parameters:** +- `documentId` (required) + +**Response (200):** +```json +{ + "id": "...", + "documentId": "...", + "schemaId": "019577a3-...", + "schemaName": "aircraft-engineering", + "metadata": { ... }, + "version": 3, + "createdAt": "...", + "createdBy": "..." +} +``` + +#### `GET /custom-metadata/version` + +Get a specific version of custom metadata. + +**Query Parameters:** +- `documentId` (required) +- `version` (required, >= 1) + +#### `GET /custom-metadata/history` + +Get version history for a document's custom metadata. + +**Query Parameters:** +- `documentId` (required) + +**Response (200):** +```json +{ + "versions": [ + {"id": "...", "documentId": "...", "version": 3, "createdAt": "...", "createdBy": "..."}, + {"id": "...", "documentId": "...", "version": 2, "createdAt": "...", "createdBy": "..."}, + {"id": "...", "documentId": "...", "version": 1, "createdAt": "...", "createdBy": "..."} + ] +} +``` + +--- + +## 6. Validation Strategy + +### 6.1 JSON Schema Validation + +Use a Go JSON Schema validation library for both: + +1. **Schema validation** (meta-validation): When a client submits a new schema, validate it against the JSON Schema meta-schema to ensure it is a well-formed JSON Schema document. +2. **Metadata validation**: When custom metadata is submitted, validate the payload against the document's assigned schema. + +**Recommended library**: `github.com/santhosh-tekuri/jsonschema/v6` + +Reasons: +- Supports JSON Schema draft 2020-12 +- Active maintenance +- Good error reporting +- Supports custom formats + +### 6.2 Supported JSON Schema Features + +The system will support the following JSON Schema keywords for custom field definitions: + +| Feature | JSON Schema Keyword | Example | +|---------|-------------------|---------| +| Data type | `type` | `"string"`, `"number"`, `"integer"`, `"boolean"`, `"array"`, `"object"` | +| Required fields | `required` | `["field1", "field2"]` | +| String length | `minLength`, `maxLength` | `"maxLength": 100` | +| Numeric range | `minimum`, `maximum` | `"minimum": 0` | +| Pattern matching | `pattern` | `"pattern": "^[A-Z]{2}$"` | +| Enum values | `enum` | `"enum": ["active", "inactive"]` | +| Date/time format | `format` | `"format": "date"` | +| Array items | `items`, `minItems`, `maxItems` | Nested array support | +| Nested objects | `properties` | Hierarchical field groups | +| No extra fields | `additionalProperties` | `false` to enforce strict schemas | + +### 6.3 Validation Error Reporting + +Validation errors will be returned as structured JSON with: +- The JSON pointer to the failing field (`/aircraft_model`) +- The constraint that was violated (`maxLength`) +- A human-readable message + +--- + +## 7. Service Layer Design + +### 7.1 New Service: `internal/customschema/` + +``` +internal/customschema/ + service.go -- CustomSchemaService (CRUD + validation) + models.go -- Input/output types + validator.go -- JSON Schema meta-validation + payload validation + constants.go -- MaxSchemaDefinitionBytes and other limits + service_test.go -- Integration tests +``` + +**Constants (`constants.go`):** + +```go +const ( + // MaxSchemaDefinitionBytes is the maximum size of a JSON Schema definition. + // This limit can be increased if clients need larger schemas. + MaxSchemaDefinitionBytes = 65536 // 64KB +) +``` + +**Key methods:** + +```go +type Service struct { + cfg serviceconfig.ConfigProvider + validator *SchemaValidator +} + +// Schema management (admin operations) +func (s *Service) CreateSchema(ctx, input CreateSchemaInput) (*Schema, error) +func (s *Service) UpdateSchema(ctx, schemaID uuid.UUID, input UpdateSchemaInput) (*Schema, error) +func (s *Service) GetSchema(ctx, schemaID uuid.UUID) (*Schema, error) +func (s *Service) ListSchemas(ctx, clientID string, filters ListFilters) ([]SchemaSummary, error) +func (s *Service) DeleteSchema(ctx, schemaID uuid.UUID) error + +// Metadata operations (regular user operations) +func (s *Service) SetDocumentMetadata(ctx, input SetMetadataInput) (*CustomMetadata, error) +func (s *Service) GetCurrentMetadata(ctx, documentID uuid.UUID) (*CustomMetadata, error) +func (s *Service) GetMetadataByVersion(ctx, documentID uuid.UUID, version int) (*CustomMetadata, error) +func (s *Service) GetMetadataHistory(ctx, documentID uuid.UUID) ([]MetadataVersion, error) + +// Document schema binding +func (s *Service) AssignSchema(ctx, documentID uuid.UUID, schemaID uuid.UUID) error +func (s *Service) AssignSchemaToFolder(ctx, folderID uuid.UUID, schemaID uuid.UUID, createdBy string) (*BulkAssignResult, error) +``` + +**BulkAssignResult:** + +```go +type BulkAssignResult struct { + FolderID uuid.UUID + SchemaID uuid.UUID + DocumentsUpdated int + DocumentsSkipped int + SkippedReasons []SkippedDocument +} + +type SkippedDocument struct { + DocumentID uuid.UUID + Reason string +} +``` + +### 7.2 Validator Component + +```go +type SchemaValidator struct{} + +// ValidateSchemaDefinition checks that a schema_def is valid JSON Schema +func (v *SchemaValidator) ValidateSchemaDefinition(schemaDef json.RawMessage) error + +// ValidateMetadata checks that metadata conforms to a compiled schema +func (v *SchemaValidator) ValidateMetadata(schemaDef json.RawMessage, metadata json.RawMessage) ([]ValidationError, error) +``` + +### 7.3 API Controller: `api/queryAPI/customschemas.go` + +Follows the same pattern as `fieldextractions.go`: +- Parse and validate request +- Call service layer +- Convert between API and service types +- Return appropriate HTTP status codes + +--- + +## 8. Migration Plan + +### 8.1 Migration Files + +Three new migrations: + +**Migration 120: Create `client_metadata_schemas` table** +``` +00000000000120_create_client_metadata_schemas.up.sql +00000000000120_create_client_metadata_schemas.down.sql +``` + +**Migration 121: Create `document_custom_metadata` table + view** +``` +00000000000121_create_document_custom_metadata.up.sql +00000000000121_create_document_custom_metadata.down.sql +``` + +**Migration 122: Add `custom_schema_id` to `documents` table** +``` +00000000000122_add_custom_schema_id_to_documents.up.sql +00000000000122_add_custom_schema_id_to_documents.down.sql +``` + +### 8.2 SQLC Queries + +New query file: `internal/database/queries/customschemas.sql` + +**Schema operations:** +- `CreateClientMetadataSchema` - Insert new schema +- `GetClientMetadataSchema` - Get by ID +- `ListClientMetadataSchemas` - List by client with filters +- `GetLatestSchemaByName` - Get latest version of a named schema +- `GetSchemaDocumentCount` - Count documents using a schema +- `DeactivateClientMetadataSchema` - Soft-delete +- `GetMaxSchemaVersion` - For auto-incrementing version + +**Metadata operations:** +- `CreateDocumentCustomMetadata` - Insert new metadata version +- `GetCurrentDocumentCustomMetadata` - Latest version (via view) +- `GetDocumentCustomMetadataByVersion` - Specific version +- `GetDocumentCustomMetadataHistory` - All versions +- `LockDocumentCustomMetadataForVersion` - Row-level locking + +**Document modifications:** +- `SetDocumentCustomSchemaId` - Update documents.custom_schema_id +- `GetDocumentCustomSchemaId` - Read documents.custom_schema_id +- `BulkSetDocumentCustomSchemaIdInFolderTree` - Set schema for all docs in folder subtree (WITH RECURSIVE) +- `GetDocumentsWithMetadataInFolderTree` - Find docs that already have metadata (for skip reporting) + +### 8.3 OpenAPI Spec Changes + +Add to `serviceAPIs/queryAPI.yaml`: +- New admin paths: `/admin/custom-schemas`, `/admin/custom-schemas/{schemaId}`, `/admin/folders/{folderId}/assign-schema` +- New user paths: `/custom-metadata`, `/custom-metadata/version`, `/custom-metadata/history`, `/document/{id}/schema` +- New schemas: `CustomSchemaRequest`, `CustomSchemaResponse`, `CustomSchemaListResponse`, `CustomMetadataRequest`, `CustomMetadataResponse`, `CustomMetadataHistoryResponse`, `ValidationErrorResponse`, `BulkSchemaAssignRequest`, `BulkSchemaAssignResponse` +- Modified schemas: `DocumentEnriched` (add `customSchemaId`, `customSchemaName`, `hasCustomMetadata`, `customMetadata` fields) + +--- + +## 9. Invariants & Business Rules + +| # | Rule | Enforcement | +|---|------|-------------| +| 1 | Schema must be valid JSON Schema | Application: meta-validation on create/update | +| 2 | Schema `name` + `version` unique per client | Database: unique constraint | +| 3 | Updated schema gets new ID and version | Application: INSERT, not UPDATE | +| 4 | Schema deletion blocked if documents reference it | Application: count check before deactivation | +| 5 | Document's `custom_schema_id` is immutable once metadata exists | Application: check in AssignSchema and SetMetadata | +| 6 | Custom metadata must conform to assigned schema | Application: JSON Schema validation on write | +| 7 | All custom metadata versions for a document use same schema ID | Application: consistency check on write | +| 8 | Schema must belong to same client as document | Application: cross-reference check | +| 9 | Only active schemas can be assigned to new documents | Application: is_active check | +| 10 | Custom metadata is versioned (no in-place updates) | Database: version column + unique constraint | +| 11 | Schema definition must be <= 64KB | Application: size check + Database: CHECK constraint | +| 12 | Schema management requires `client_admin` role | Permit.io: role-based access on `/admin` resource | +| 13 | No query/filter on custom metadata values | By design: not implemented (see Principle 8) | + +--- + +## 10. Relationship to Existing Field Extraction System + +The existing static field extraction system (`/field-extractions`) and the new custom schema system (`/custom-schemas` + `/custom-metadata`) are **independent and complementary**: + +- A document can have BOTH static field extractions AND custom metadata +- A document can have EITHER one, or neither +- They are stored in separate tables with separate versioning +- The existing 130 static fields continue to serve the current healthcare contract analysis use case +- Custom schemas serve new, client-specific use cases that don't fit the static model + +**No breaking changes** to the existing API or database schema. + +--- + +## 11. Implementation Order + +| Phase | Work | Dependencies | +|-------|------|--------------| +| 1 | Database migrations (tables, view, document column) | None | +| 2 | SQLC queries + `task generate` | Phase 1 | +| 3 | Schema validator component (`internal/customschema/validator.go`) | JSON Schema library added to go.mod | +| 4 | Service layer (`internal/customschema/service.go`) incl. bulk folder assignment | Phase 2, 3 | +| 5 | OpenAPI spec updates (admin + user endpoints) | None (can parallel with 1-3) | +| 6 | Permit.io: Create `client_admin` role + policy updates | None (can parallel with 1-5) | +| 7 | API controllers + code generation (admin schema CRUD, metadata CRUD) | Phase 4, 5 | +| 8 | Document endpoint modifications (PATCH schema, GET enrichment) | Phase 4, 7 | +| 9 | Bulk folder schema assignment endpoint | Phase 4, 7 | +| 10 | Integration tests | Phase 9 | +| 11 | Documentation updates (`docs/ai.generated/`) incl. sample schemas | Phase 10 | + +--- + +## 12. Testing Strategy + +- **Schema validation tests**: Valid and invalid JSON Schema documents, edge cases (empty schema, deeply nested, self-referential, oversized >64KB) +- **Metadata validation tests**: Conforming and non-conforming payloads, all supported JSON Schema keywords +- **Service integration tests**: Full CRUD lifecycle using testcontainers (real PostgreSQL) +- **API integration tests**: HTTP-level tests for all new endpoints (admin + user) +- **Invariant tests**: Schema immutability, cross-client isolation, deletion guards, size limits +- **Bulk folder assignment tests**: Recursive assignment, skip behavior for locked docs, empty folders, deeply nested folder trees +- **Authorization tests**: `client_admin` can manage schemas, `client_user` cannot, `super_admin` can +- **Backward compatibility tests**: Existing field extraction endpoints continue to work unchanged + +--- + +## 13. Resolved Design Decisions + +| # | Question | Decision | +|---|----------|----------| +| 1 | Schema size limits | 64KB cap via `MaxSchemaDefinitionBytes` constant (tunable) | +| 2 | Schema backward compatibility | Not required. New version = new ID. Old docs keep old schema. | +| 3 | Bulk operations | Yes. `POST /admin/folders/{folderId}/assign-schema` assigns recursively through folder tree. | +| 4 | Custom metadata search/filter | **Not supported.** No GIN indexes, no query API. Metadata is opaque storage. | +| 5 | Schema templates | No system templates. Documentation will include sample schemas covering all supported types. | +| 6 | Existing field migration path | **Out of scope.** Static fields and custom schemas are independent systems. | +| 7 | Endpoint naming/placement | Schema CRUD under `/admin/custom-schemas` (requires `client_admin` role, not `super_admin`). Metadata CRUD under `/custom-metadata` (regular user access). | + +--- + +## Appendix A: Sample JSON Schemas + +These examples demonstrate all supported JSON Schema features and data types. They should be included in the API documentation. + +### A.1 Comprehensive Schema (All Supported Types) + +```json +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "document_title": { + "type": "string", + "minLength": 1, + "maxLength": 500, + "description": "Title of the document" + }, + "document_code": { + "type": "string", + "pattern": "^[A-Z]{3}-[0-9]{4}$", + "description": "Document code in format XXX-0000" + }, + "status": { + "type": "string", + "enum": ["draft", "review", "approved", "rejected", "archived"], + "description": "Current document status" + }, + "review_date": { + "type": "string", + "format": "date", + "description": "Date of last review (YYYY-MM-DD)" + }, + "created_timestamp": { + "type": "string", + "format": "date-time", + "description": "Creation timestamp (ISO 8601)" + }, + "page_count": { + "type": "integer", + "minimum": 1, + "maximum": 10000, + "description": "Total number of pages" + }, + "confidence_score": { + "type": "number", + "minimum": 0.0, + "maximum": 1.0, + "description": "Extraction confidence (0.0 to 1.0)" + }, + "is_verified": { + "type": "boolean", + "description": "Whether the extraction has been human-verified" + }, + "tags": { + "type": "array", + "items": { "type": "string", "maxLength": 50 }, + "minItems": 0, + "maxItems": 20, + "description": "Classification tags" + }, + "numeric_values": { + "type": "array", + "items": { "type": "number" }, + "maxItems": 100, + "description": "Extracted numeric values" + }, + "contact_info": { + "type": "object", + "properties": { + "name": { "type": "string", "maxLength": 200 }, + "email": { "type": "string", "format": "email" }, + "phone": { "type": "string", "pattern": "^\\+?[0-9\\-\\s()]+$" } + }, + "required": ["name"], + "additionalProperties": false, + "description": "Primary contact information" + } + }, + "required": ["document_title", "status"], + "additionalProperties": false +} +``` + +### A.2 Aircraft Engineering Schema (Domain Example) + +```json +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "aircraft_model": { + "type": "string", + "maxLength": 100 + }, + "manufacturer": { + "type": "string", + "enum": ["Boeing", "Airbus", "Embraer", "Bombardier", "Other"] + }, + "certification_date": { + "type": "string", + "format": "date" + }, + "max_altitude_ft": { + "type": "integer", + "minimum": 0, + "maximum": 100000 + }, + "max_range_nm": { + "type": "number", + "minimum": 0 + }, + "engine_count": { + "type": "integer", + "minimum": 1, + "maximum": 12 + }, + "engine_type": { + "type": "string", + "enum": ["turbofan", "turboprop", "turbojet", "piston", "electric"] + }, + "wingspan_meters": { + "type": "number", + "minimum": 0, + "maximum": 100 + }, + "is_pressurized": { + "type": "boolean" + }, + "applicable_regulations": { + "type": "array", + "items": { + "type": "string", + "pattern": "^(FAR|EASA|CAAC)-[0-9A-Z\\.]+$" + }, + "maxItems": 50 + } + }, + "required": ["aircraft_model", "manufacturer"], + "additionalProperties": false +} +``` + +### A.3 Auto Engineering Schema (Domain Example) + +```json +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "vehicle_make": { + "type": "string", + "maxLength": 100 + }, + "vehicle_model": { + "type": "string", + "maxLength": 100 + }, + "model_year": { + "type": "integer", + "minimum": 1900, + "maximum": 2100 + }, + "vin": { + "type": "string", + "pattern": "^[A-HJ-NPR-Z0-9]{17}$", + "description": "17-character Vehicle Identification Number" + }, + "engine_displacement_liters": { + "type": "number", + "minimum": 0, + "maximum": 20 + }, + "horsepower": { + "type": "integer", + "minimum": 0 + }, + "transmission_type": { + "type": "string", + "enum": ["manual", "automatic", "cvt", "dct", "ev"] + }, + "is_electric": { + "type": "boolean" + }, + "safety_ratings": { + "type": "object", + "properties": { + "nhtsa_overall": { "type": "integer", "minimum": 1, "maximum": 5 }, + "iihs_rating": { "type": "string", "enum": ["Poor", "Marginal", "Acceptable", "Good", "Good+"] } + }, + "additionalProperties": false + }, + "recall_ids": { + "type": "array", + "items": { "type": "string", "maxLength": 20 }, + "maxItems": 100 + } + }, + "required": ["vehicle_make", "vehicle_model", "model_year"], + "additionalProperties": false +} +``` + +### Supported JSON Schema Data Types Summary + +| JSON Schema Type | Go Equivalent | Example Constraint Keywords | +|-----------------|---------------|---------------------------| +| `string` | `string` | `minLength`, `maxLength`, `pattern`, `format`, `enum` | +| `integer` | `int64` | `minimum`, `maximum` | +| `number` | `float64` | `minimum`, `maximum` | +| `boolean` | `bool` | (none) | +| `array` | `[]T` | `items`, `minItems`, `maxItems` | +| `object` | `map/struct` | `properties`, `required`, `additionalProperties` | + +**Supported `format` values**: `date`, `date-time`, `email`, `uri`, `uuid` diff --git a/plans/mutable.metadata.plan.codex.comparison.claude.md b/plans/mutable.metadata.plan.codex.comparison.claude.md new file mode 100644 index 00000000..39a2fd74 --- /dev/null +++ b/plans/mutable.metadata.plan.codex.comparison.claude.md @@ -0,0 +1,242 @@ +# Codex Plan vs Claude Plan - Comparison Analysis + +**Date**: 2026-03-23 +**Purpose**: Identify issues or strengths in the Codex plan that our Claude plan may have missed or should consider adopting. + +--- + +## 1. Agreement / Shared Ground + +Both plans converge on the same high-level design, which is reassuring: + +- Additive hybrid approach: keep legacy fixed-column field extractions, add new JSONB-backed custom metadata +- Schema-as-data stored as JSON Schema documents in JSONB +- Immutable schema versions (new row per revision, new ID) +- Nullable schema reference on the `documents` table +- Schema binding locks once metadata is written +- Application-layer JSON Schema validation (not DB-side `json_matches_schema()`) +- `DISTINCT ON` view for current metadata version +- No GIN index initially +- JSON Schema draft 2020-12 +- Client-scoped schemas with cross-client isolation +- Version history for metadata records + +These are the foundational decisions and both plans agree on all of them. No action needed. + +--- + +## 2. Issues Codex Raises That We Should Evaluate + +### 2.1 DB Triggers for Critical Invariants (STRONG - Consider Adopting) + +**What Codex says**: Two database triggers should enforce invariants: +1. Prevent `documents.metadataSchemaId` changes after any extraction exists (legacy or custom) +2. Validate `documents.clientId = documentMetadataSchemas.clientId` on insert/update + +**Our plan**: Relies entirely on application-layer enforcement. + +**Assessment**: This is the strongest point in the Codex plan that we missed. Application-layer enforcement is correct but fragile -- a future developer could bypass the service layer (e.g., direct SQLC query, migration script, admin fix-up query) and silently violate the invariant. DB triggers make the invariant durable regardless of code path. + +**Risk of NOT adopting**: A data integrity bug that is invisible until documents have mismatched schemas. Hard to detect, hard to fix retroactively. + +**Recommendation**: **Adopt.** Add both triggers. The cost is two small trigger functions in the migration. The benefit is defense-in-depth for the two most critical invariants (schema lock and client isolation). This aligns with our existing pattern of using DB constraints as safety nets alongside app-layer checks. + +--- + +### 2.2 Three-State Schema Status Enum (MODERATE - Consider Adopting) + +**What Codex says**: Uses a `metadataSchemaStatus` enum with three values: `active`, `superseded`, `retired`. + +**Our plan**: Uses a boolean `is_active` (true/false). + +**Assessment**: The three-state model captures a meaningful distinction: +- `superseded`: automatically set when a newer version is created -- the schema is "replaced" but not manually disabled +- `retired`: explicitly disabled by an admin -- different intent, possibly different business rules (e.g., retired schemas might warrant a warning in the UI) + +With a boolean, both cases collapse to `is_active = false` and the system cannot distinguish "replaced by v2" from "admin decided this schema is bad." + +**Risk of NOT adopting**: Minor. We can always add the distinction later. But it's cleaner to model it upfront since it costs nothing extra at the DB level and avoids a later migration. + +**Recommendation**: **Consider adopting.** A three-state enum is a small change that gives us more precise semantics. If we keep the boolean, we should at minimum document that `is_active = false` covers both cases and note it as a known simplification. + +--- + +### 2.3 `schemaHash` for Deduplication Detection (WEAK - Note But Skip) + +**What Codex says**: Includes a `schemaHash text NOT NULL` column on the schema table. + +**Our plan**: No hash column. + +**Assessment**: Useful for detecting accidental duplicate submissions (same schema content submitted as a "new version"). Could also speed up equality checks. However, it adds complexity (what hash algorithm? is it normalized JSON before hashing?) and the use case is marginal for v1. An admin accidentally creating a duplicate version is a minor annoyance, not a data integrity issue. + +**Recommendation**: **Skip for now.** Note it as a potential future addition. If duplicate detection becomes a real problem, we can add it without schema changes (just a new column + backfill). + +--- + +### 2.4 `supersedesSchemaId` / Explicit Version Chain (WEAK - Skip) + +**What Codex says**: Each schema row has a `supersedesSchemaId uuid REFERENCES documentMetadataSchemas(id)` creating an explicit linked-list chain of versions. + +**Our plan**: Relies on `(client_id, name, version)` ordering to determine lineage. The `PUT` response includes `previousVersionId` but it's computed, not stored. + +**Assessment**: Both approaches work. The explicit FK chain makes traversal trivial (`SELECT * FROM ... WHERE supersedesSchemaId = ?`) but `(name, version)` ordering is equally functional and simpler. An explicit chain also creates complexity around edge cases: what if a superseded schema is itself deleted? What if someone creates a revision of a superseded (not latest) version? + +**Recommendation**: **Skip.** Our `(name, version)` ordering is simpler and sufficient. The lineage is already navigable via `ORDER BY version`. + +--- + +### 2.5 `documentClass` Column (WEAK - Skip) + +**What Codex says**: Includes a `documentClass text` column on the schema table for categorization. + +**Our plan**: No document class column. The schema `name` and `description` serve the categorization role. + +**Assessment**: This is a thin metadata field. It could be useful for filtering schemas by document type (e.g., "show me all schemas for aircraft engineering documents"). But `name` already serves this purpose, and adding a separate classification dimension without clear UI or API requirements is speculative. + +**Recommendation**: **Skip.** Can be added later if a classification use case emerges. The schema `name` and `description` fields handle this for now. + +--- + +### 2.6 Metadata Table Design: Two Tables vs One (MODERATE - Evaluate) + +**What Codex says**: Uses two tables mirroring the existing legacy pattern: +- `documentMetadataExtractions` (the actual JSONB data) +- `documentMetadataExtractionVersions` (version tracking) + +**Our plan**: Uses a single `document_custom_metadata` table with a `version` column combining both concerns. + +**Assessment**: This is a tradeoff: + +| Aspect | Two-table (Codex) | One-table (Ours) | +|--------|-------------------|------------------| +| Consistency with existing code | Matches legacy `documentFieldExtractions` + `documentFieldExtractionVersions` pattern | New pattern, different from legacy | +| Simplicity | More tables, more joins | Fewer tables, simpler queries | +| Row-level locking for version increment | Version rows can be locked independently of data rows | Must lock the last data row for version increment | +| Query complexity | Need JOIN for full data | Direct SELECT | + +The existing codebase uses the two-table pattern for field extractions. Matching it means developers familiar with the legacy code can reason about the new code by analogy. Breaking from it means two different versioning patterns in the same codebase. + +**Risk of NOT adopting**: Cognitive overhead for developers who expect the two-table pattern. Potential row-locking issues if we need to increment versions under concurrency (though our traffic patterns likely don't warrant this concern). + +**Recommendation**: **Evaluate.** The consistency argument is real. However, our single-table approach is simpler and YAGNI applies -- we don't need the version table separation unless we have a concrete reason. If Q prefers consistency with the legacy pattern, we should switch. Otherwise, keep our simpler design and document why we deviated. + +--- + +### 2.7 Mutual Exclusivity: Legacy OR Custom (MODERATE - Discuss with Q) + +**What Codex says**: Strict mutual exclusivity: +- If `metadataSchemaId IS NULL`, only legacy `/field-extractions` endpoints work +- If `metadataSchemaId IS NOT NULL`, only custom `/field-extractions/metadata` endpoints work +- Assigning a schema to a document with legacy extractions returns `409 Conflict` + +**Our plan**: A document can have BOTH static field extractions AND custom metadata. They are "independent and complementary." + +**Assessment**: This is a fundamental design philosophy difference: + +- **Codex (exclusive)**: Cleaner model, prevents confusion about which system is "authoritative" for a document. Simpler to reason about. But constrains flexibility -- what if a document needs both legacy fields AND custom fields during a transition period? + +- **Ours (both allowed)**: More flexible. Allows gradual migration. But introduces ambiguity: if a document has both, which is the "real" metadata? Could lead to inconsistencies if different systems write to different stores for the same document. + +**Risk of our approach**: A document could end up with conflicting data in legacy fields and custom metadata, with no clear source of truth. This is a data governance concern, not a technical one. + +**Recommendation**: **Discuss with Q.** This is a business decision. If the expectation is that documents will gradually migrate from legacy to custom, allowing both may be needed during transition. If the expectation is a clean cut-over, mutual exclusivity is cleaner. Our plan should explicitly document whichever choice is made and why. + +--- + +### 2.8 Route Structure: `/field-extractions/...` vs Split (MINOR) + +**What Codex says**: All new endpoints under `/field-extractions/...` to stay aligned with existing Permit resource naming. + +**Our plan**: Admin schemas under `/admin/custom-schemas`, metadata under `/custom-metadata`. + +**Assessment**: Our split is intentional and defensible -- it separates admin-scoped operations from user-scoped operations, which maps cleanly to role-based access. The Codex approach keeps everything grouped under one resource family, which simplifies Permit configuration but mixes admin and user operations under the same prefix. + +**Recommendation**: **Keep our approach.** The admin/user split is cleaner for RBAC. We already account for the Permit.io changes needed. + +--- + +### 2.9 Schema Size Limit: 256KB vs 64KB (MINOR) + +**What Codex says**: 256KB limit. + +**Our plan**: 64KB limit with a tunable constant. + +**Assessment**: Both are reasonable. 64KB is generous for a JSON Schema document (most schemas will be 1-5KB). 256KB is very generous. Since we made it a tunable constant (`MaxSchemaDefinitionBytes`), the exact value is easy to change. + +**Recommendation**: **Keep 64KB.** It's more than enough, and a lower limit is a better default. If a client hits it, we bump the constant. + +--- + +### 2.10 Schema Validation: `additionalProperties` Required (MINOR - Consider) + +**What Codex says**: The server should reject schema creation unless `additionalProperties` is explicitly present at root level. + +**Our plan**: `additionalProperties: false` is "recommended but not required." + +**Assessment**: Requiring it prevents a common mistake -- submitting a schema without `additionalProperties`, which defaults to `true` in JSON Schema, meaning the schema accepts any extra fields. This is almost never what the client admin intends. Requiring explicit declaration forces a conscious choice. + +**Recommendation**: **Consider adopting.** Requiring `additionalProperties` to be explicitly set (not necessarily `false`, just present) is a small validation rule that prevents a common gotcha. Low cost, moderate value. + +--- + +### 2.11 Request Body Should Not Carry `schemaId` (ALREADY HANDLED) + +**What Codex says**: The metadata write request body should not include `schemaId` -- the server derives it from the document's assigned schema. + +**Our plan**: Same approach. The `POST /custom-metadata` request contains `documentId` and `metadata` but not `schemaId`. The response includes `schemaId` (server-derived). + +**Assessment**: Both plans agree. No action needed. + +--- + +### 2.12 Usage Stats in Schema Responses (MINOR - Already Handled Differently) + +**What Codex says**: Schema detail response includes a `usage` block: `assignedDocumentCount`, `metadataRecordCount`, `canDelete`. + +**Our plan**: Schema list/detail responses include `documentCount`. + +**Assessment**: The Codex response is slightly richer (`canDelete` is convenient for UIs, `metadataRecordCount` distinguishes "assigned but no data yet" from "actively in use"). Our `documentCount` covers the main use case. + +**Recommendation**: **Consider adding `canDelete`.** It's a simple boolean derived from the count and saves the client a mental step. `metadataRecordCount` is nice-to-have but not essential for v1. + +--- + +## 3. Things Our Plan Has That Codex Missed + +### 3.1 Bulk Folder Assignment + +Our plan includes `POST /admin/folders/{folderId}/assign-schema` with recursive subfolder traversal, skip reporting, and transactional safety. Codex has no bulk operations at all. This is a significant operational feature for large clients. + +### 3.2 `client_admin` Role + +Our plan defines a new `client_admin` role with clear permission boundaries. Codex mentions the need for Permit.io updates but doesn't define a new role or permission model. + +### 3.3 Detailed Service Layer Design + +Our plan provides Go code outlines for the service, validator, and controller layers. Codex stays at the SQL/API level and doesn't describe the Go implementation structure. + +### 3.4 Sample Schemas / Documentation + +Our plan includes comprehensive sample schemas covering all supported JSON Schema types. Codex doesn't address documentation. + +### 3.5 More Granular Implementation Phases + +Our plan has 11 phases with explicit dependency tracking. Codex has 4 broad phases. + +--- + +## 4. Summary: Recommended Actions + +| # | Codex Issue | Strength | Action | +|---|------------|----------|--------| +| 2.1 | DB triggers for invariants | **STRONG** | **Adopt** -- add triggers for schema lock + client match | +| 2.2 | Three-state schema status | MODERATE | **Consider** -- enum is cleaner than boolean | +| 2.3 | `schemaHash` | Weak | Skip | +| 2.4 | `supersedesSchemaId` chain | Weak | Skip | +| 2.5 | `documentClass` column | Weak | Skip | +| 2.6 | Two-table metadata pattern | MODERATE | **Discuss with Q** -- consistency vs simplicity | +| 2.7 | Mutual exclusivity (legacy OR custom) | MODERATE | **Discuss with Q** -- business decision | +| 2.10 | Require `additionalProperties` present | Minor | **Consider** -- prevents common mistake | +| 2.12 | `canDelete` in responses | Minor | **Consider** -- small UX improvement | + +**Bottom line**: The Codex plan is a competent alternative design that arrives at the same destination via slightly different choices. The one genuinely strong point we should adopt is **DB triggers for critical invariants** (2.1). The three moderate items (2.2, 2.6, 2.7) deserve a decision from Q. Everything else is either already covered in our plan or is a minor preference difference that doesn't materially affect the design. diff --git a/plans/mutable.metadata.plan.codex.md b/plans/mutable.metadata.plan.codex.md new file mode 100644 index 00000000..b832b37a --- /dev/null +++ b/plans/mutable.metadata.plan.codex.md @@ -0,0 +1,539 @@ +# Mutable Metadata Plan + +## What I Studied + +The current field extraction design is rigid end to end: + +- `serviceAPIs/queryAPI.yaml` hard-codes `FieldExtractionRequest`, `SingleFields`, and `ArrayFieldItem`. +- `internal/database/migrations_collapsed/00000000000008_field_extractions.up.sql` hard-codes 19 single-value columns and 112 array-value columns. +- `internal/database/queries/fieldextractions.sql` and `internal/fieldextraction/service.go` implement document-scoped versioning by inserting immutable rows and a separate versions table. +- `internal/database/migrations_collapsed/00000000000006_documents.up.sql` shows that `documents` currently has no schema pointer. +- `internal/document/get.go`, `internal/folder/service.go`, and `serviceAPIs/queryAPI.yaml` expose field extraction data as `textRecord` / `hasTextRecord`, even though it is really structured metadata. +- `internal/server/api/listener.go` uses the OpenAPI middleware for request-shape validation, but dynamic per-client schema validation does not exist today. +- `internal/database/README.md` mentions `json_matches_schema()`, but I did not find an active migration or function definition for it, so this plan assumes application-side JSON Schema validation. +- `cmd/auth_related/permit.setup/permit_policies.yaml` only maps the existing field extraction endpoints, so any new routes here will require Permit mapping updates. + +## Recommendation + +Use an additive hybrid design. + +- Keep the current fixed-column field extraction model for legacy documents where no custom schema is assigned. +- Add a new client-scoped JSON Schema registry. +- Add a new JSONB-backed metadata record store for documents that opt into a custom schema. +- Pin each document to exactly one schema revision through a nullable document column. +- Allow schema assignment changes only until the first metadata record exists for that document. +- Do not try to generate physical columns per client schema. The current codebase is too strongly coupled to generated OpenAPI types, SQLC queries, and static SQL for that to scale cleanly. + +This gives the team a safe path: + +- Existing `POST/GET /field-extractions` flows keep working for legacy data. +- New clients can use fully custom schemas without changing the database schema every time they add fields. +- The system can later deprecate the legacy fixed-field model without blocking this first implementation. + +## Core Design + +### 1. Document-level schema pinning + +Add a nullable schema reference to `documents`. + +Recommended column name: + +- `metadataSchemaId uuid NULL` + +Meaning: + +- `NULL`: this document uses the legacy fixed `SingleFields` + `ArrayFieldItem` model. +- non-`NULL`: this document uses the custom JSON Schema-backed metadata model. + +Rule: + +- A document may change `metadataSchemaId` only while it has no field extraction record of any kind. +- Once the first legacy extraction or custom metadata record exists, schema assignment is locked forever. + +### 2. Client-managed schema registry + +Store each schema revision as its own immutable row. + +Recommended table: + +```sql +CREATE TYPE metadataSchemaStatus AS ENUM ('active', 'superseded', 'retired'); + +CREATE TABLE documentMetadataSchemas ( + id uuid PRIMARY KEY DEFAULT uuid_generate_v7(), + clientId varchar(255) NOT NULL REFERENCES clients(clientId), + schemaKey varchar(255) NOT NULL, + version int NOT NULL, + title text NOT NULL, + description text, + documentClass text, + status metadataSchemaStatus NOT NULL DEFAULT 'active', + supersedesSchemaId uuid REFERENCES documentMetadataSchemas(id), + schema jsonb NOT NULL, + schemaHash text NOT NULL, + createdAt timestamp NOT NULL DEFAULT NOW(), + createdBy varchar(255) NOT NULL, + CHECK (jsonb_typeof(schema) = 'object'), + UNIQUE (clientId, schemaKey, version) +); + +CREATE INDEX idx_documentmetadataschemas_client + ON documentMetadataSchemas(clientId); + +CREATE INDEX idx_documentmetadataschemas_client_key_status + ON documentMetadataSchemas(clientId, schemaKey, status); +``` + +Why a single table instead of family + version tables: + +- every revision already needs a new system-assigned ID +- `schemaKey + version` is enough to group revisions +- it matches the current repo preference for straightforward tables and version rows + +Suggested semantics: + +- `schemaKey`: stable logical name inside a client, for example `aircraft-engineering` +- `version`: starts at `1`, increments on each revision +- `id`: exact revision identifier that documents reference +- `status`: + - `active`: assignable + - `superseded`: replaced by a newer revision + - `retired`: manually disabled for future assignment + +### 3. Custom metadata record storage + +Use JSONB for the extracted metadata itself. + +Recommended tables: + +```sql +CREATE TABLE documentMetadataExtractions ( + id uuid PRIMARY KEY DEFAULT uuid_generate_v7(), + documentId uuid NOT NULL REFERENCES documents(id), + metadataSchemaId uuid NOT NULL REFERENCES documentMetadataSchemas(id), + metadata jsonb NOT NULL, + createdAt timestamp NOT NULL DEFAULT NOW(), + createdBy varchar(255) NOT NULL, + CHECK (jsonb_typeof(metadata) = 'object') +); + +CREATE INDEX idx_documentmetadataextractions_document + ON documentMetadataExtractions(documentId); + +CREATE INDEX idx_documentmetadataextractions_schema + ON documentMetadataExtractions(metadataSchemaId); + +CREATE TABLE documentMetadataExtractionVersions ( + id uuid PRIMARY KEY DEFAULT uuid_generate_v7(), + metadataExtractionId uuid NOT NULL REFERENCES documentMetadataExtractions(id), + documentId uuid NOT NULL REFERENCES documents(id), + metadataSchemaId uuid NOT NULL REFERENCES documentMetadataSchemas(id), + version bigint NOT NULL, + createdAt timestamp NOT NULL DEFAULT NOW(), + createdBy varchar(255) NOT NULL, + UNIQUE (documentId, version) +); + +CREATE INDEX idx_documentmetadataextractionversions_document + ON documentMetadataExtractionVersions(documentId); +``` + +Recommended view: + +```sql +CREATE VIEW currentDocumentMetadataExtractions AS +SELECT DISTINCT ON (dme.documentId) + dme.id, + dme.documentId, + dme.metadataSchemaId, + dme.metadata, + dmev.version, + dmev.createdAt, + dmev.createdBy +FROM documentMetadataExtractions dme +JOIN documentMetadataExtractionVersions dmev + ON dmev.metadataExtractionId = dme.id +ORDER BY dme.documentId, dmev.version DESC, dmev.id DESC; +``` + +Important choice: + +- do not add a GIN index on `metadata` initially +- index only `documentId`, `metadataSchemaId`, and version paths for now +- add GIN or projected search columns later only if querying custom fields becomes a real requirement + +## API Design + +### Routing recommendation + +To stay aligned with the existing `FieldExtractionService` tag and current Permit resource naming, use the `/field-extractions/...` prefix for the new endpoints. + +Recommended routes: + +| Method | Path | Purpose | +| --- | --- | --- | +| `POST` | `/field-extractions/schemas` | Create schema revision `v1` | +| `GET` | `/field-extractions/schemas` | List client schemas | +| `GET` | `/field-extractions/schemas/{schemaId}` | Get one schema revision | +| `POST` | `/field-extractions/schemas/{schemaId}/revisions` | Create new revision with new ID | +| `DELETE` | `/field-extractions/schemas/{schemaId}` | Delete unused schema revision | +| `PUT` | `/field-extractions/documents/{documentId}/schema` | Assign or clear a document schema before first extraction | +| `POST` | `/field-extractions/metadata` | Create custom metadata record version | +| `GET` | `/field-extractions/metadata` | Get current custom metadata record | +| `GET` | `/field-extractions/metadata/version` | Get custom metadata record by version | +| `GET` | `/field-extractions/metadata/history` | Get custom metadata version history | + +Cleaner but slightly broader routing alternative: + +- `/document/{id}/metadata-schema` instead of `/field-extractions/documents/{documentId}/schema` + +That route is more document-centric, but it will require matching Permit and middleware updates outside the current `field-extractions` path family. + +### Schema registry payloads + +Create request: + +```json +{ + "clientId": "acme", + "schemaKey": "aircraft-engineering", + "title": "Aircraft Engineering Document", + "description": "ACME aircraft engineering extraction schema", + "documentClass": "aircraft engineering", + "jsonSchema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "additionalProperties": false, + "properties": { + "manufacturer": { "type": "string", "maxLength": 200 }, + "engine_hours": { "type": "number", "minimum": 0 } + } + }, + "createdBy": "admin@acme.com" +} +``` + +Schema detail response: + +```json +{ + "id": "019....", + "clientId": "acme", + "schemaKey": "aircraft-engineering", + "version": 3, + "title": "Aircraft Engineering Document", + "description": "ACME aircraft engineering extraction schema", + "documentClass": "aircraft engineering", + "status": "active", + "supersedesSchemaId": "019....", + "createdAt": "2026-03-23T12:00:00Z", + "createdBy": "admin@acme.com", + "usage": { + "assignedDocumentCount": 42, + "metadataRecordCount": 37, + "canDelete": false + }, + "jsonSchema": { "...": "..." } +} +``` + +Revision create request: + +```json +{ + "title": "Aircraft Engineering Document", + "description": "Added section for turbine notes", + "jsonSchema": { "...": "..." }, + "createdBy": "admin@acme.com", + "changeSummary": "Added turbine_notes and tightened serial number validation" +} +``` + +Revision semantics: + +- creates a brand new row with a new `id` +- keeps the same `schemaKey` +- increments `version` +- marks the old schema row as `superseded` +- does not mutate any document already pinned to the older schema ID + +### Document schema assignment payload + +Request: + +```json +{ + "schemaId": "019....", + "assignedBy": "admin@acme.com" +} +``` + +To clear before first extraction: + +```json +{ + "schemaId": null, + "assignedBy": "admin@acme.com" +} +``` + +Response: + +```json +{ + "documentId": "019....", + "metadataSchemaId": "019....", + "locked": false +} +``` + +### Custom metadata record payloads + +Create request: + +```json +{ + "documentId": "019....", + "createdBy": "user@acme.com", + "metadata": { + "manufacturer": "GE", + "engine_hours": 481.25, + "inspection_notes": [ + { "section": "fuel", "status": "pass" } + ] + } +} +``` + +Current record response: + +```json +{ + "id": "019....", + "documentId": "019....", + "metadataSchemaId": "019....", + "schemaKey": "aircraft-engineering", + "schemaVersion": 3, + "version": 2, + "metadata": { + "manufacturer": "GE", + "engine_hours": 481.25 + }, + "createdAt": "2026-03-23T12:34:56Z", + "createdBy": "user@acme.com" +} +``` + +History response: + +```json +{ + "versions": [ + { + "id": "019....", + "documentId": "019....", + "metadataSchemaId": "019....", + "version": 2, + "createdAt": "2026-03-23T12:34:56Z", + "createdBy": "user@acme.com" + } + ] +} +``` + +## Document API Changes + +Extend the document responses so clients can see schema assignment without fetching the schema registry separately. + +Add to `DocumentEnriched` and `DocumentInFolder`: + +- `metadataSchemaId: uuid | null` +- `hasMetadataRecord: boolean` +- `metadataRecord` optional, only when requested + +Recommended query parameter: + +- `metadataRecord=true` + +Compatibility note: + +- keep the existing `hasTextRecord` / `textRecord` fields for legacy fixed-field records +- do not overload `textRecord` with custom JSON metadata +- treat `metadataRecord` as the new preferred field for schema-bound documents + +This avoids breaking current consumers that deserialize `FieldExtractionResponse`. + +## Validation and Invariants + +### JSON Schema rules + +The server should reject schema creation unless all of the following pass: + +- valid JSON object +- compiles successfully as JSON Schema +- root `type` is `object` +- root `additionalProperties` is explicitly present +- size stays under a practical guardrail, for example 256 KB + +Recommended draft: + +- Draft 2020-12 + +Recommended validation location: + +- application layer in the new schema service +- do not rely on database-side JSON Schema validation for the first implementation + +Reason: + +- the repo already uses OpenAPI middleware for static contracts, but dynamic schema compilation is not available there +- I did not find a live DB implementation of `json_matches_schema()` + +### Custom metadata rules + +When `POST /field-extractions/metadata` is called: + +1. load the document +2. confirm `metadataSchemaId` is non-null +3. load the exact schema row referenced by the document +4. confirm schema and document belong to the same client +5. validate `metadata` against that schema +6. lock the version rows for that document +7. insert immutable extraction row plus new version row + +Important: the request body should not carry `schemaId`. The server should derive it from the document assignment to avoid client/server drift. + +### Legacy/custom exclusivity rules + +- if `documents.metadataSchemaId IS NULL`, only the existing legacy `/field-extractions` endpoints are allowed +- if `documents.metadataSchemaId IS NOT NULL`, only the new custom `/field-extractions/metadata` endpoints are allowed +- assigning a schema to a document that already has a legacy extraction is rejected with `409 Conflict` +- changing or clearing a schema after the first custom metadata record exists is rejected with `409 Conflict` + +### Delete rules + +`DELETE /field-extractions/schemas/{schemaId}` should succeed only when all are true: + +- no document points to that schema in `documents.metadataSchemaId` +- no metadata extraction row references it +- no metadata extraction version row references it + +Otherwise return `409 Conflict`. + +## DB-enforced protections + +I recommend two database triggers in addition to service checks. + +### 1. Prevent schema reassignment after first extraction + +On `documents`, before update of `metadataSchemaId`: + +- if the value changes +- and the document has any row in `documentFieldExtractionVersions` +- or any row in `documentMetadataExtractionVersions` +- then raise an error + +This enforces the "locked after first extraction" rule even if another code path updates the row later. + +### 2. Validate client/schema match + +On `documents`, before insert/update of `metadataSchemaId`: + +- if non-null, ensure `documents.clientId = documentMetadataSchemas.clientId` + +This can also be enforced in the assignment service, but a trigger makes the invariant durable. + +## Why JSONB is the right tradeoff here + +This codebase currently pays a large change cost whenever the extraction shape changes: + +- OpenAPI changes +- generated Go type changes +- conversion helper changes +- SQLC query changes +- migration changes +- view changes + +That is acceptable for one fixed 19/112-field model. It is not acceptable for per-client schemas that can change frequently. + +JSONB plus JSON Schema avoids that churn: + +- per-client field definitions become data, not code +- extraction validation becomes runtime validation against the stored schema +- version history stays consistent with the current service style + +Tradeoff: + +- JSONB is not ideal for ad hoc SQL reporting across arbitrary custom fields + +Mitigation: + +- keep the initial implementation retrieval-focused +- if analytics/search later require it, add projected indexes or a derived flattened table keyed by JSON path + +## Auth and middleware impact + +The current Permit mapping only includes: + +- `/field-extractions` +- `/field-extractions/version` +- `/field-extractions/history` + +This design requires new entries for: + +- `/field-extractions/schemas` +- `/field-extractions/schemas/{schemaId}` +- `/field-extractions/schemas/{schemaId}/revisions` +- `/field-extractions/documents/{documentId}/schema` +- `/field-extractions/metadata` +- `/field-extractions/metadata/version` +- `/field-extractions/metadata/history` + +If the team chooses the cleaner `/document/{id}/metadata-schema` route instead, the `document` Permit resource mapping also needs to be extended. + +## Rollout Plan + +### Phase 1 + +- add `documents.metadataSchemaId` +- add `documentMetadataSchemas` +- add `documentMetadataExtractions` +- add `documentMetadataExtractionVersions` +- add current-view and usage queries + +### Phase 2 + +- add schema registry endpoints +- add assignment endpoint +- add custom metadata endpoints +- add JSON Schema validation service + +### Phase 3 + +- extend document responses with `metadataSchemaId`, `hasMetadataRecord`, and optional `metadataRecord` +- keep legacy `textRecord` behavior unchanged + +### Phase 4 + +- optionally add deprecation notices around the legacy fixed-field model +- evaluate whether new clients should be prohibited from using the legacy model at all + +## Recommended non-goals for this change + +Do not include these in the first pass: + +- migrating existing legacy field extraction rows into JSONB +- replacing the current legacy `/field-extractions` response shape +- building SQL search/filtering across arbitrary custom metadata fields +- building a flattened field catalog table unless a UI requirement forces it immediately + +## Final Recommendation + +Implement custom schemas as a new schema registry plus JSONB-backed metadata version store, while preserving the current fixed-column field extraction path for legacy documents. + +That approach fits the current repo architecture best: + +- it respects the existing version-per-document pattern +- it avoids forcing a breaking rewrite of the static `FieldExtractionResponse` +- it gives each client independent schema control +- it keeps future migration toward a fully generic metadata model open diff --git a/plans/mutable.metadata.plan.combo.md b/plans/mutable.metadata.plan.combo.md new file mode 100644 index 00000000..41597b8e --- /dev/null +++ b/plans/mutable.metadata.plan.combo.md @@ -0,0 +1,1364 @@ +# Mutable Metadata & Per-Client Custom Schemas - Combined Design Plan + +## Status: DRAFT - Combined Claude + Codex + Jay's recommendations (2026-03-24, rev 2026-03-31 post-review) + +**Lineage**: This plan was informed by two original detailed specs done adversarily. One by claude Opus 4.6 and one by Codex 5.4 (xhigh) based on human written requirements. Each was human reviewed for common items and conflicting items. Where there were conflicts the human was the final decision maker. This resulting hybrid document represents an improved design over each of the source documents. + +--- + +## 1. Problem Statement + +Today, all field extraction data is stored in a rigid 3-table schema with ~130 statically-defined columns (19 single-value fields + 112 array fields). Every client shares the same field definitions. This means: + +- Clients cannot define domain-specific fields (e.g., "aircraft engineering" vs. "auto engineering" document classes) +- Adding new fields requires database migrations and code changes across the full stack +- The system cannot accommodate varying data shapes per document class within a single client + +**Goal**: Allow each client to curate their own collections of document schemas, where each schema defines the custom fields (with types and constraints) that a document's metadata must conform to. + +--- + +## 2. Current Architecture Summary + +### 2.1 Existing Tables + +``` +documentFieldExtractions -- 19 single-value fields (1:1 per document) +documentFieldExtractionVersions -- Version tracking with row-level locking +documentFieldExtractionArrayFields -- 112 array fields (1:N per extraction) +``` + +### 2.2 Existing API Endpoints + +| Method | Path | Description | +|--------|------|-------------| +| POST | `/field-extractions` | Create versioned extraction (singleFields + arrayFields) | +| GET | `/field-extractions` | Get current (latest version) extraction for a document | +| GET | `/field-extractions/version` | Get extraction by specific version | +| GET | `/field-extractions/history` | List all versions for a document | + +### 2.3 Key Relationships + +- `documents.id` -> `documentFieldExtractions.documentId` (1:N via versions) +- `documentFieldExtractions.id` -> `documentFieldExtractionArrayFields.fieldExtractionId` (1:N) +- `documentFieldExtractions.id` -> `documentFieldExtractionVersions.fieldExtractionId` (1:N) +- Documents belong to clients via `documents.clientId` +- No per-client customization of field definitions exists today + +--- + +## 3. Design Principles + +1. **Additive, not destructive** - The existing static field extraction system continues to work unchanged for documents that do not opt into custom schemas. Custom schemas are an additive capability. +2. **Mutual exclusivity** - A document uses EITHER the legacy static field extraction system OR the custom schema system, never both. This prevents ambiguity about which system is authoritative for a document's metadata. Once a document is assigned a custom schema, the legacy field extraction endpoints reject writes for that document, and vice versa. +3. **Schema-as-data** - Schemas are stored as JSON Schema documents in the database, not as DDL. No migrations needed when clients add/modify schemas. +4. **Immutable schema versions** - When a schema is updated, a new version (with new ID) is created. Old versions remain for historical reference. No backward compatibility checking is required between versions since each version gets a distinct ID. +5. **Schema binding is permanent** - Once custom metadata is written to a document with a schema ID, that schema ID cannot be changed on the document. This guarantees data integrity. +6. **JSON blob storage** - Custom metadata is stored as JSONB in PostgreSQL, validated against the schema at write time. This avoids dynamic column creation. +7. **Client isolation** - Schemas are scoped to clients. A client cannot reference another client's schema. +8. **Size limits** - Schema definitions are capped at 64KB (`MaxSchemaDefinitionBytes = 65536`). Metadata payloads are capped at 1MB (`MaxMetadataPayloadBytes = 1048576`). Bulk folder assignments are capped at 10,000 documents (`MaxBulkAssignDocuments = 10000`). All are tunable constants. +9. **No JSONB search/filter** - The system does NOT support querying or filtering documents by custom metadata values. Custom metadata is opaque storage validated on write. If search is needed in the future, it would require GIN indexes on the JSONB columns and a dedicated query API (separate phase). +10. **Admin-scoped schema management** - Schema CRUD operations and folder-level schema assignment live under `/admin` and require the existing `user_admin` role. Custom metadata read/write on documents is available to regular `client_user` users. +11. **Defense-in-depth** - Critical invariants (schema lock after first extraction, client/schema ownership match, schema_id consistency across metadata versions) are enforced at BOTH the application layer AND the database layer via triggers. The app-layer checks provide better error messages; the DB triggers are safety nets against bypass. +12. **No migration from static fields** - There is no migration path from the existing static field extraction data into the custom schema system. They are mutually exclusive per document. Out of scope. + +--- + +## 4. Database Schema Design + +### 4.1 New Type: `schema_status_type` + +A three-state enum that distinguishes between different reasons a schema is no longer assignable: + +```sql +CREATE TYPE schema_status_type AS ENUM ('active', 'superseded', 'retired'); +``` + +- **`active`**: The schema version is current and can be assigned to new documents. +- **`superseded`**: A newer version of this schema (same `name`) was created. The schema is still valid for documents already using it, but should not be assigned to new documents. Set automatically when a new version is created via `PUT`. +- **`retired`**: An admin explicitly disabled this schema version. Functionally similar to `superseded` but indicates a deliberate administrative action rather than an automatic version succession. + +### 4.2 New Table: `client_metadata_schemas` + +Stores client-defined JSON Schema documents. Each row is an immutable version of a schema definition. + +```sql +CREATE TABLE client_metadata_schemas ( + id uuid PRIMARY KEY DEFAULT uuid_generate_v7(), + client_id varchar(255) NOT NULL REFERENCES clients(clientId), + name varchar(255) NOT NULL, + description text, + schema_def jsonb NOT NULL, -- The JSON Schema document (max 64KB enforced at app layer) + version int NOT NULL DEFAULT 1, -- Auto-incremented per (client_id, name) + status schema_status_type NOT NULL DEFAULT 'active', + created_at timestamptz NOT NULL DEFAULT NOW(), + created_by varchar(255) NOT NULL, + + CONSTRAINT uq_client_schema_name_version UNIQUE (client_id, name, version), + CONSTRAINT chk_schema_def_size CHECK (pg_column_size(schema_def) <= 70000) -- ~68KB to account for JSONB overhead; app layer enforces 64KB on raw JSON +); + +CREATE INDEX idx_cms_client_id ON client_metadata_schemas(client_id); +CREATE INDEX idx_cms_client_name ON client_metadata_schemas(client_id, name); +CREATE INDEX idx_cms_client_status ON client_metadata_schemas(client_id, status); +``` + +> **Note**: The 64KB limit is enforced at the application layer (Go constant `MaxSchemaDefinitionBytes = 65536`) by checking `len(rawJSON)`. The database CHECK constraint uses `pg_column_size()` which measures on-disk JSONB storage (includes header overhead), so it is set slightly higher (~68KB) as a safety net. The app-layer check provides the user-facing 64KB limit and a better error message. + +**Design decisions:** + +- **`name` + `version`** scoped to client: Allows human-friendly naming ("aircraft-engineering-v3") with automatic versioning. When a schema is "updated," a new row is inserted with `version = max(version) + 1` for that `(client_id, name)`. +- **`schema_def` is JSONB**: Stores the full JSON Schema document. PostgreSQL can index into it if needed later. +- **`status` enum**: Three-state lifecycle. `superseded` is set automatically when a newer version is created. `retired` is set explicitly by admin action (soft-delete). Both states prevent assignment to new documents but allow existing documents to continue using the schema for validation. This is richer than a boolean `is_active` because it distinguishes "replaced by v2" from "admin decided this is bad." +- **`id` is the schema version ID**: This is what documents reference. Each update creates a new `id`. The `(client_id, name, version)` tuple provides the human-readable lineage. + +### 4.3 New Table: `document_custom_metadata` + +Stores the actual custom metadata for documents, validated against the referenced schema. + +```sql +CREATE TABLE document_custom_metadata ( + id uuid PRIMARY KEY DEFAULT uuid_generate_v7(), + document_id uuid NOT NULL REFERENCES documents(id), + schema_id uuid NOT NULL REFERENCES client_metadata_schemas(id), + metadata jsonb NOT NULL, -- The actual custom field values + version int NOT NULL DEFAULT 1, -- Versioned like field extractions + created_at timestamptz NOT NULL DEFAULT NOW(), + created_by varchar(255) NOT NULL, + + CONSTRAINT uq_doc_custom_metadata_version UNIQUE (document_id, version) +); + +CREATE INDEX idx_dcm_document_id ON document_custom_metadata(document_id); +CREATE INDEX idx_dcm_schema_id ON document_custom_metadata(schema_id); +CREATE INDEX idx_dcm_doc_version_desc ON document_custom_metadata(document_id, version DESC); -- Enables efficient DISTINCT ON in current_document_custom_metadata view +``` + +**Design decisions:** + +- **Single table with version column**: We use one table (rather than separate extraction + version tables like the legacy pattern) because the simpler design is sufficient. The legacy two-table pattern exists to support row-level locking on the version counter independently of the data row. For custom metadata, the `UNIQUE (document_id, version)` constraint plus `SELECT ... FOR UPDATE` on the max-version row provides the same concurrency safety with less complexity. If this proves insufficient under load, splitting into two tables is a backward-compatible change. +- **`schema_id` is immutable per document**: Enforced at both the application layer and the database trigger (see Section 4.7). All versions of custom metadata for a document must reference the same schema ID. +- **`metadata` is JSONB**: Validated against `client_metadata_schemas.schema_def` at write time. Stored as a single blob for simplicity and query flexibility. + +### 4.4 Alteration: `documents` Table + +Add an optional schema binding column: + +```sql +ALTER TABLE documents + ADD COLUMN custom_schema_id uuid NULL REFERENCES client_metadata_schemas(id); + +CREATE INDEX idx_documents_custom_schema_id ON documents(custom_schema_id); +``` + +**Behavior:** + +- `NULL` = document uses the legacy static field extraction system (default, backward-compatible) +- `NOT NULL` = document is opted into the custom schema system; legacy field extraction endpoints will reject writes for this document +- Once set AND custom metadata has been written, this field becomes immutable (enforced at app layer AND DB trigger) +- Can be set before metadata is written (pre-binding) and changed freely until first metadata write +- Must reference a schema owned by the same client as the document (cross-client reference check enforced at app layer AND DB trigger) +- Cannot be set on a document that already has legacy field extractions (returns 409 Conflict) + +### 4.5 New View: `current_document_custom_metadata` + +```sql +CREATE VIEW current_document_custom_metadata AS +SELECT DISTINCT ON (document_id) + id, + document_id, + schema_id, + metadata, + version, + created_at, + created_by +FROM document_custom_metadata +ORDER BY document_id, version DESC; +``` + +### 4.6 Entity Relationship Diagram + +``` +clients + | + |-- 1:N --> client_metadata_schemas (per-client schema definitions) + | | + | |-- referenced by --> documents.custom_schema_id + | |-- referenced by --> document_custom_metadata.schema_id + | + |-- 1:N --> documents + | + |-- 1:N --> document_custom_metadata (versioned JSONB blobs) + | [MUTUALLY EXCLUSIVE with legacy extractions] + |-- 1:N --> documentFieldExtractions (existing static fields) + [MUTUALLY EXCLUSIVE with custom metadata] +``` + +### 4.7 Database Triggers (Defense-in-Depth) + +Three triggers enforce critical invariants at the database level, providing a safety net regardless of which code path modifies the data. + +#### Trigger 1: Prevent schema reassignment after first extraction + +Fires on `documents` BEFORE UPDATE of `custom_schema_id`. Prevents changes if the document has any extraction data (legacy or custom). + +```sql +CREATE OR REPLACE FUNCTION trg_prevent_schema_reassignment() +RETURNS TRIGGER AS $$ +BEGIN + -- Only fire if custom_schema_id is actually changing + IF OLD.custom_schema_id IS DISTINCT FROM NEW.custom_schema_id THEN + -- Check for existing custom metadata + IF EXISTS ( + SELECT 1 FROM document_custom_metadata + WHERE document_id = NEW.id + LIMIT 1 + ) THEN + RAISE EXCEPTION 'Cannot change custom_schema_id on document % because custom metadata already exists', NEW.id; + END IF; + + -- Check for existing legacy field extractions + IF EXISTS ( + SELECT 1 FROM "documentFieldExtractionVersions" + WHERE "documentId" = NEW.id + LIMIT 1 + ) THEN + RAISE EXCEPTION 'Cannot change custom_schema_id on document % because legacy field extractions already exist', NEW.id; + END IF; + END IF; + + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER trg_documents_prevent_schema_reassignment + BEFORE UPDATE OF custom_schema_id ON documents + FOR EACH ROW + EXECUTE FUNCTION trg_prevent_schema_reassignment(); +``` + +#### Trigger 2: Validate client/schema ownership match + +Fires on `documents` BEFORE INSERT OR UPDATE of `custom_schema_id`. Ensures the referenced schema belongs to the same client as the document. + +```sql +CREATE OR REPLACE FUNCTION trg_validate_schema_client_match() +RETURNS TRIGGER AS $$ +DECLARE + schema_client_id varchar(255); +BEGIN + -- Only check when custom_schema_id is non-null + IF NEW.custom_schema_id IS NOT NULL THEN + SELECT client_id INTO schema_client_id + FROM client_metadata_schemas + WHERE id = NEW.custom_schema_id; + + IF schema_client_id IS NULL THEN + RAISE EXCEPTION 'Schema % does not exist', NEW.custom_schema_id; + END IF; + + IF schema_client_id != NEW."clientId" THEN + RAISE EXCEPTION 'Schema % belongs to client %, but document % belongs to client %', + NEW.custom_schema_id, schema_client_id, NEW.id, NEW."clientId"; + END IF; + END IF; + + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER trg_documents_validate_schema_client + BEFORE INSERT OR UPDATE OF custom_schema_id ON documents + FOR EACH ROW + EXECUTE FUNCTION trg_validate_schema_client_match(); +``` + +#### Trigger 3: Enforce consistent schema_id across metadata versions + +Fires on `document_custom_metadata` BEFORE INSERT. Ensures all metadata versions for a document reference the same schema ID. This is the database-level safety net for Invariant 7. + +```sql +CREATE OR REPLACE FUNCTION trg_enforce_consistent_schema_id() +RETURNS TRIGGER AS $$ +DECLARE + existing_schema_id uuid; +BEGIN + SELECT schema_id INTO existing_schema_id + FROM document_custom_metadata + WHERE document_id = NEW.document_id + LIMIT 1; + + IF existing_schema_id IS NOT NULL AND existing_schema_id != NEW.schema_id THEN + RAISE EXCEPTION 'All custom metadata for document % must use schema %, but got %', + NEW.document_id, existing_schema_id, NEW.schema_id; + END IF; + + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER trg_dcm_enforce_consistent_schema + BEFORE INSERT ON document_custom_metadata + FOR EACH ROW + EXECUTE FUNCTION trg_enforce_consistent_schema_id(); +``` + +> **Note**: These triggers duplicate checks that also exist in the application service layer. This is intentional. The app-layer checks return structured HTTP error responses (400/409). The triggers are a last-resort safety net that fires even if a future code path, migration script, or admin query bypasses the service layer. + +--- + +## 5. API Design + +### 5.1 Schema Management Endpoints (Admin) + +All under the existing `AdminService` tag. These endpoints require the existing `user_admin` role (see Section 5.3 for auth details). Endpoints are prefixed with `/admin/custom-schemas`. + +#### `POST /admin/custom-schemas` + +Create a new schema for a client. + +**Request:** +```json +{ + "clientId": "acme-corp", + "name": "aircraft-engineering", + "description": "Custom fields for aircraft engineering documents", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "aircraft_model": { + "type": "string", + "maxLength": 100 + }, + "certification_date": { + "type": "string", + "format": "date" + }, + "max_altitude_ft": { + "type": "number", + "minimum": 0, + "maximum": 100000 + }, + "engine_count": { + "type": "integer", + "minimum": 1, + "maximum": 12 + } + }, + "required": ["aircraft_model"], + "additionalProperties": false + }, + "createdBy": "admin@acme.com" +} +``` + +**Response (201):** +```json +{ + "id": "019577a3-...", + "clientId": "acme-corp", + "name": "aircraft-engineering", + "description": "Custom fields for aircraft engineering documents", + "schema": { ... }, + "version": 1, + "status": "active", + "createdAt": "2026-03-23T12:00:00Z", + "createdBy": "admin@acme.com" +} +``` + +**Validation:** +- The `schema` field MUST be a valid JSON Schema document (validated server-side using a JSON Schema meta-validator) +- The `schema` field must be <= 64KB in size (returns 400 with message referencing the limit) +- Root `type` must be `"object"` +- Root `additionalProperties` MUST be explicitly present (either `true` or `false`). If omitted, the server returns 400 with a message explaining that `additionalProperties` must be explicitly declared. This prevents the common mistake of omitting it and inadvertently accepting arbitrary extra fields. +- `name` must be unique within the client (for active schemas at the same version) + +#### `PUT /admin/custom-schemas/{schemaId}` + +Update an existing schema. Creates a new version with a new ID. The old version remains intact and its status is set to `superseded`. No backward compatibility check is performed -- the new version is treated as an independent schema that happens to share a name lineage. + +**Request:** +```json +{ + "description": "Updated aircraft engineering fields (added wingspan)", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "aircraft_model": { "type": "string", "maxLength": 100 }, + "certification_date": { "type": "string", "format": "date" }, + "max_altitude_ft": { "type": "number", "minimum": 0, "maximum": 100000 }, + "engine_count": { "type": "integer", "minimum": 1, "maximum": 12 }, + "wingspan_meters": { "type": "number", "minimum": 0 } + }, + "required": ["aircraft_model"], + "additionalProperties": false + }, + "createdBy": "admin@acme.com" +} +``` + +**Response (201):** +```json +{ + "id": "019577b4-...", + "clientId": "acme-corp", + "name": "aircraft-engineering", + "version": 2, + "status": "active", + "previousVersionId": "019577a3-...", + ... +} +``` + +**Behavior:** +- The `schemaId` in the path identifies the schema to "update" (really: create a new version of) +- The new version inherits the `name` from the previous version +- The old version's status is set to `superseded` (it remains valid for existing documents but should not be assigned to new ones) +- Returns the newly created schema version +- The `schema` field in the request body follows the same validation rules as `POST` (including the `additionalProperties` requirement) +- **Concurrency**: Uses `SELECT ... FOR UPDATE` on `client_metadata_schemas WHERE client_id = $1 AND name = $2` to lock existing versions before reading `max(version)`. This prevents two concurrent PUTs from computing the same next version number. The unique constraint `uq_client_schema_name_version` is the final safety net. + +#### `GET /admin/custom-schemas` + +List all schemas for a client. + +**Query Parameters:** +- `clientId` (required): The client whose schemas to list +- `name` (optional): Filter by schema name +- `status` (optional): Filter by status (`active`, `superseded`, `retired`). Default: return only `active`. +- `includeAllVersions` (optional, default: false): Return all versions or just latest per name +- `limit` (optional, default: 50, max: 200): Maximum number of results to return +- `offset` (optional, default: 0): Number of results to skip for pagination + +**Response (200):** +```json +{ + "schemas": [ + { + "id": "019577b4-...", + "clientId": "acme-corp", + "name": "aircraft-engineering", + "description": "...", + "version": 2, + "status": "active", + "documentCount": 45, + "canDelete": false, + "createdAt": "...", + "createdBy": "..." + }, + { + "id": "019577c1-...", + "clientId": "acme-corp", + "name": "auto-engineering", + "description": "...", + "version": 1, + "status": "active", + "documentCount": 0, + "canDelete": true, + "createdAt": "...", + "createdBy": "..." + } + ] +} +``` + +**Notes:** +- `documentCount` shows how many documents reference each schema version. Computed via a subquery or join. +- `canDelete` is `true` when `documentCount == 0` AND no `document_custom_metadata` rows reference the schema. This saves the client a round-trip to check before attempting delete. + +#### `GET /admin/custom-schemas/{schemaId}` + +Get a specific schema by ID (returns full schema definition). + +**Response (200):** +```json +{ + "id": "019577a3-...", + "clientId": "acme-corp", + "name": "aircraft-engineering", + "description": "...", + "schema": { ... }, + "version": 1, + "status": "superseded", + "documentCount": 45, + "canDelete": false, + "createdAt": "...", + "createdBy": "..." +} +``` + +#### `DELETE /admin/custom-schemas/{schemaId}` + +Retire a schema. Only succeeds if no documents reference it. + +**Response (204):** No content on success. + +**Response (409 Conflict):** +```json +{ + "error": "Schema is in use by 45 documents and cannot be deleted" +} +``` + +**Behavior:** +- Sets `status = 'retired'` +- Fails with 409 if any documents reference this schema ID (via `documents.custom_schema_id` or `document_custom_metadata.schema_id`) +- Retired schemas still serve validation for existing documents but cannot be assigned to new documents + +--- + +### 5.2 Document Schema Assignment Endpoints (Admin) + +#### `PATCH /admin/document/{id}/schema` + +Assign or change a custom schema on a single document. This endpoint is under `/admin` so that the Permit.io middleware maps it to resource `admin` (first path segment), ensuring only `user_admin` and `super_admin` roles can assign schemas. A non-admin path (`/document/{id}/schema`) would map to resource `document`, which `client_user` has access to -- violating the authorization model. + +**Request:** +```json +{ + "customSchemaId": "019577a3-..." +} +``` + +**Response (200):** +```json +{ + "documentId": "...", + "customSchemaId": "019577a3-...", + "schemaName": "aircraft-engineering", + "schemaVersion": 1 +} +``` + +**Validation rules:** +- Schema must belong to the same client as the document (enforced at app layer + DB trigger) +- Schema must have status `active` +- If the document already has custom metadata written, the schema cannot be changed (returns 409, also enforced by DB trigger) +- If the document already has legacy field extractions, the schema cannot be assigned (returns 409 -- mutual exclusivity) +- Setting `customSchemaId` to `null` removes the binding (only if no custom metadata exists) + +#### `POST /admin/folders/{folderId}/assign-schema` (Bulk Folder Assignment) + +Assign a custom schema to ALL documents in a folder and all its subfolders (recursive). + +**Request:** +```json +{ + "customSchemaId": "019577a3-...", + "createdBy": "admin@acme.com" +} +``` + +**Response (200):** +```json +{ + "folderId": "...", + "customSchemaId": "019577a3-...", + "schemaName": "aircraft-engineering", + "documentsUpdated": 87, + "documentsSkipped": 5, + "skippedReasons": [ + {"documentId": "...", "reason": "Document already has custom metadata with a different schema"}, + {"documentId": "...", "reason": "Document already has custom metadata with a different schema"}, + {"documentId": "...", "reason": "Document already assigned to same schema (no-op)"}, + {"documentId": "...", "reason": "Document has legacy field extractions"}, + {"documentId": "...", "reason": "Document has legacy field extractions"} + ] +} +``` + +**Behavior:** +- Uses `WITH RECURSIVE` CTE to walk the entire folder subtree (same pattern as folder deletion) +- For each document in the tree: + - If the document has no schema, no custom metadata, and no legacy extractions -> assign the schema + - If the document already has the same schema -> skip (no-op, counted in `documentsSkipped`) + - If the document has a different schema but no custom metadata yet -> reassign to new schema + - If the document has a different schema AND has custom metadata -> skip with reason (cannot change) + - If the document has legacy field extractions -> skip with reason (mutual exclusivity) +- Runs in a single transaction +- **Document count limit**: If the folder subtree contains more than `MaxBulkAssignDocuments` (10,000) documents, returns `422 Unprocessable Entity` with a message explaining the limit. This prevents unbounded transaction duration, excessive lock contention, and replication lag. +- Returns summary of what was updated and what was skipped (with reasons) +- Requires `user_admin` role (admin endpoint) + +#### `GET /document/{id}` (Modified) + +Add `customSchemaId` and `customMetadata` fields to `DocumentEnriched`: + +```json +{ + "id": "...", + "clientId": "acme-corp", + "hash": "...", + "customSchemaId": "019577a3-...", + "customSchemaName": "aircraft-engineering", + "hasCustomMetadata": true, + "customMetadata": { ... }, + ...existing fields... +} +``` + +**Query parameter:** `customMetadata` (boolean, default: false) - when true, includes the full custom metadata in the response (like the existing `textRecord` parameter). + +--- + +### 5.3 Authorization & Permit.io Changes + +No new roles are required. The existing role hierarchy handles all custom schema operations: + +| Role | Schema CRUD | Schema Assignment | Metadata Read | Metadata Write | +|------|------------|-------------------|---------------|----------------| +| `super_admin` | Yes | Yes | Yes | Yes | +| `user_admin` | Yes | Yes | Yes | Yes | +| `auditor` | Read-only | No | Read-only | No | +| `client_user` | No | No | Yes | Yes | + +- **`user_admin`** (and `super_admin`): Full access to schema management (`/admin/custom-schemas`), folder-level schema assignment (`/admin/folders/{folderId}/assign-schema`), and single-document schema assignment (`/admin/document/{id}/schema`). These roles already have `/admin/*` permissions in Permit.io, so all admin endpoints are covered automatically. +- **`client_user`**: Can read and write custom metadata values on documents (`/custom-metadata` endpoints) and read schema assignments on documents. Cannot create, update, or delete schemas, and cannot assign schemas to documents or folders. +- **`auditor`**: Read-only access to custom metadata and schema details. Cannot write metadata or manage schemas. + +**Permit.io policy additions** (in `permit_policies.yaml`): + +```yaml +# Add custom-metadata resource for client_user access: +client_user: + custom-metadata: + - get + - post + +# user_admin already has admin:* which covers /admin/custom-schemas. +# Add custom-metadata access for user_admin: +user_admin: + custom-metadata: + - get + - post + +# auditor gets read-only on custom-metadata: +auditor: + custom-metadata: + - get +``` + +Since `user_admin` already maps to the `admin` resource with full permissions, the new `/admin/custom-schemas` and `/admin/folders/{folderId}/assign-schema` endpoints are covered without additional configuration. The only new resource mapping needed is `custom-metadata` for the user-facing metadata endpoints. + +--- + +### 5.4 Custom Metadata CRUD Endpoints (New) + +All under a new `CustomMetadataService` tag. + +#### `POST /custom-metadata` + +Create or update custom metadata for a document. Creates a new version. + +**Request:** +```json +{ + "documentId": "...", + "metadata": { + "aircraft_model": "Boeing 737-800", + "certification_date": "2024-06-15", + "max_altitude_ft": 41000, + "engine_count": 2 + }, + "createdBy": "analyst@acme.com" +} +``` + +**Response (201):** +```json +{ + "id": "...", + "documentId": "...", + "schemaId": "019577a3-...", + "metadata": { ... }, + "version": 1, + "createdAt": "...", + "createdBy": "analyst@acme.com" +} +``` + +**Validation:** +- Document must have a `custom_schema_id` assigned (400 if not) +- Document must NOT have any legacy field extractions (409 if it does -- mutual exclusivity, also enforced by DB trigger on schema assignment) +- The `metadata` payload must be <= 1MB in size (`MaxMetadataPayloadBytes = 1048576`). Returns 400 if exceeded. +- The `metadata` payload is validated against the JSON Schema stored in `client_metadata_schemas.schema_def` +- The request body does NOT include `schemaId` -- the server derives it from the document's `custom_schema_id`. This prevents client/server drift. +- If validation fails, returns 400 with detailed validation errors: + ```json + { + "error": "Metadata does not conform to schema", + "validationErrors": [ + {"field": "engine_count", "message": "must be >= 1, got 0"}, + {"field": "aircraft_model", "message": "required field missing"} + ] + } + ``` +- On first write, locks the document's `custom_schema_id` (makes it immutable via app-layer flag + DB trigger) +- Subsequent writes create new versions but must use the same schema + +#### `GET /custom-metadata` + +Get current (latest version) custom metadata for a document. + +**Query Parameters:** +- `documentId` (required) + +**Response (200):** +```json +{ + "id": "...", + "documentId": "...", + "schemaId": "019577a3-...", + "schemaName": "aircraft-engineering", + "metadata": { ... }, + "version": 3, + "createdAt": "...", + "createdBy": "..." +} +``` + +#### `GET /custom-metadata/version` + +Get a specific version of custom metadata. + +**Query Parameters:** +- `documentId` (required) +- `version` (required, >= 1) + +#### `GET /custom-metadata/history` + +Get version history for a document's custom metadata. + +**Query Parameters:** +- `documentId` (required) +- `limit` (optional, default: 50, max: 200): Maximum number of versions to return +- `offset` (optional, default: 0): Number of versions to skip for pagination + +**Response (200):** +```json +{ + "versions": [ + {"id": "...", "documentId": "...", "version": 3, "createdAt": "...", "createdBy": "..."}, + {"id": "...", "documentId": "...", "version": 2, "createdAt": "...", "createdBy": "..."}, + {"id": "...", "documentId": "...", "version": 1, "createdAt": "...", "createdBy": "..."} + ] +} +``` + +--- + +## 6. Validation Strategy + +### 6.1 JSON Schema Validation + +Use a Go JSON Schema validation library for both: + +1. **Schema validation** (meta-validation): When a client submits a new schema, validate it against the JSON Schema meta-schema to ensure it is a well-formed JSON Schema document. +2. **Metadata validation**: When custom metadata is submitted, validate the payload against the document's assigned schema. + +**Recommended library**: `github.com/santhosh-tekuri/jsonschema/v6` + +Reasons: +- Supports JSON Schema draft 2020-12 +- Active maintenance +- Good error reporting +- Supports custom formats + +### 6.2 Schema Definition Requirements + +When a schema definition is submitted (via `POST` or `PUT`), the server validates: + +1. It is a valid JSON object +2. It compiles successfully as a JSON Schema document (meta-validation) +3. Root `type` is `"object"` +4. Root `additionalProperties` is explicitly present (either `true` or `false`). This is required because JSON Schema defaults `additionalProperties` to `true` when omitted, which silently accepts any extra fields. Requiring explicit declaration forces a conscious choice. +5. Size is <= 64KB + +### 6.3 Supported JSON Schema Features + +The system will support the following JSON Schema keywords for custom field definitions: + +| Feature | JSON Schema Keyword | Example | +|---------|-------------------|---------| +| Data type | `type` | `"string"`, `"number"`, `"integer"`, `"boolean"`, `"array"`, `"object"` | +| Required fields | `required` | `["field1", "field2"]` | +| String length | `minLength`, `maxLength` | `"maxLength": 100` | +| Numeric range | `minimum`, `maximum` | `"minimum": 0` | +| Pattern matching | `pattern` | `"pattern": "^[A-Z]{2}$"` | +| Enum values | `enum` | `"enum": ["active", "inactive"]` | +| Date/time format | `format` | `"format": "date"` | +| Array items | `items`, `minItems`, `maxItems` | Nested array support | +| Nested objects | `properties` | Hierarchical field groups | +| No extra fields | `additionalProperties` | `false` to enforce strict schemas | + +### 6.4 Validation Error Reporting + +Validation errors will be returned as structured JSON with: +- The JSON pointer to the failing field (`/aircraft_model`) +- The constraint that was violated (`maxLength`) +- A human-readable message + +--- + +## 7. Service Layer Design + +### 7.1 New Service: `internal/customschema/` + +``` +internal/customschema/ + service.go -- CustomSchemaService (CRUD + validation) + models.go -- Input/output types + validator.go -- JSON Schema meta-validation + payload validation + constants.go -- MaxSchemaDefinitionBytes and other limits + service_test.go -- Integration tests +``` + +**Constants (`constants.go`):** + +```go +const ( + // MaxSchemaDefinitionBytes is the maximum size of a JSON Schema definition. + // This limit can be increased if clients need larger schemas. + MaxSchemaDefinitionBytes = 65536 // 64KB + + // MaxMetadataPayloadBytes is the maximum size of a custom metadata JSON payload. + // Prevents unbounded JSONB blobs when schemas use additionalProperties: true. + MaxMetadataPayloadBytes = 1048576 // 1MB + + // MaxBulkAssignDocuments is the maximum number of documents that can be updated + // in a single bulk folder schema assignment. Prevents unbounded transactions. + MaxBulkAssignDocuments = 10000 +) +``` + +**Key methods:** + +```go +type Service struct { + cfg serviceconfig.ConfigProvider + validator *SchemaValidator +} + +// Schema management (admin operations) +func (s *Service) CreateSchema(ctx, input CreateSchemaInput) (*Schema, error) +func (s *Service) UpdateSchema(ctx, schemaID uuid.UUID, input UpdateSchemaInput) (*Schema, error) +func (s *Service) GetSchema(ctx, schemaID uuid.UUID) (*Schema, error) +func (s *Service) ListSchemas(ctx, clientID string, filters ListFilters) ([]SchemaSummary, error) +func (s *Service) DeleteSchema(ctx, schemaID uuid.UUID) error + +// Metadata operations (regular user operations) +func (s *Service) SetDocumentMetadata(ctx, input SetMetadataInput) (*CustomMetadata, error) +func (s *Service) GetCurrentMetadata(ctx, documentID uuid.UUID) (*CustomMetadata, error) +func (s *Service) GetMetadataByVersion(ctx, documentID uuid.UUID, version int) (*CustomMetadata, error) +func (s *Service) GetMetadataHistory(ctx, documentID uuid.UUID) ([]MetadataVersion, error) + +// Document schema binding +func (s *Service) AssignSchema(ctx, documentID uuid.UUID, schemaID uuid.UUID) error +func (s *Service) AssignSchemaToFolder(ctx, folderID uuid.UUID, schemaID uuid.UUID, createdBy string) (*BulkAssignResult, error) +``` + +**BulkAssignResult:** + +```go +type BulkAssignResult struct { + FolderID uuid.UUID + SchemaID uuid.UUID + DocumentsUpdated int + DocumentsSkipped int + SkippedReasons []SkippedDocument +} + +type SkippedDocument struct { + DocumentID uuid.UUID + Reason string +} +``` + +### 7.2 Validator Component + +```go +type SchemaValidator struct{} + +// ValidateSchemaDefinition checks that a schema_def is valid JSON Schema. +// Returns error if: not a valid JSON object, fails meta-validation, +// root type is not "object", or additionalProperties is not explicitly present. +func (v *SchemaValidator) ValidateSchemaDefinition(schemaDef json.RawMessage) error + +// ValidateMetadata checks that metadata conforms to a compiled schema +func (v *SchemaValidator) ValidateMetadata(schemaDef json.RawMessage, metadata json.RawMessage) ([]ValidationError, error) +``` + +### 7.3 Mutual Exclusivity Guard + +The service layer enforces mutual exclusivity at two points: + +1. **Schema assignment** (`AssignSchema`): Before setting `custom_schema_id` on a document, check that no rows exist in `documentFieldExtractionVersions` for that document. If they do, return `409 Conflict`. + +2. **Legacy field extraction write** (modification to existing `FieldExtractionService`): Before creating a new field extraction, check that `documents.custom_schema_id IS NULL` for the target document. If it is not null, return `409 Conflict` with a message explaining that the document uses the custom schema system. + +> **Note**: The DB trigger on `documents.custom_schema_id` (Section 4.7, Trigger 1) also prevents schema assignment if legacy extractions exist. This is the defense-in-depth pattern: the service layer returns a clean 409; the trigger catches anything that slips through. + +### 7.4 API Controller: `api/queryAPI/customschemas.go` + +Follows the same pattern as `fieldextractions.go`: +- Parse and validate request +- Call service layer +- Convert between API and service types +- Return appropriate HTTP status codes + +--- + +## 8. Migration Plan + +### 8.1 Migration Files + +Four new migrations: + +**Migration 120: Create `schema_status_type` enum + `client_metadata_schemas` table** +``` +00000000000120_create_client_metadata_schemas.up.sql +00000000000120_create_client_metadata_schemas.down.sql +``` + +**Migration 121: Create `document_custom_metadata` table + view** +``` +00000000000121_create_document_custom_metadata.up.sql +00000000000121_create_document_custom_metadata.down.sql +``` + +**Migration 122: Add `custom_schema_id` to `documents` table** +``` +00000000000122_add_custom_schema_id_to_documents.up.sql +00000000000122_add_custom_schema_id_to_documents.down.sql +``` + +**Migration 123: Add database triggers for invariant enforcement** +``` +00000000000123_add_schema_invariant_triggers.up.sql +00000000000123_add_schema_invariant_triggers.down.sql +``` + +Contents: +- `trg_prevent_schema_reassignment` function + trigger (Section 4.7, Trigger 1) +- `trg_validate_schema_client_match` function + trigger (Section 4.7, Trigger 2) +- `trg_enforce_consistent_schema_id` function + trigger (Section 4.7, Trigger 3) +- Down migration drops triggers and functions + +### 8.2 SQLC Queries + +New query file: `internal/database/queries/customschemas.sql` + +**Schema operations:** +- `CreateClientMetadataSchema` - Insert new schema +- `GetClientMetadataSchema` - Get by ID +- `ListClientMetadataSchemas` - List by client with filters (status filter) +- `GetLatestSchemaByName` - Get latest version of a named schema +- `GetSchemaDocumentCount` - Count documents using a schema +- `GetSchemaMetadataRecordCount` - Count metadata records referencing a schema (for `canDelete`) +- `SetSchemaStatus` - Update status (for supersede/retire) +- `GetMaxSchemaVersion` - For auto-incrementing version +- `LockSchemaVersionsForName` - `SELECT ... FOR UPDATE` on all versions of a (client_id, name) pair to prevent concurrent version creation race + +**Metadata operations:** +- `CreateDocumentCustomMetadata` - Insert new metadata version +- `GetCurrentDocumentCustomMetadata` - Latest version (via view) +- `GetDocumentCustomMetadataByVersion` - Specific version +- `GetDocumentCustomMetadataHistory` - All versions +- `LockDocumentCustomMetadataForVersion` - Row-level locking (SELECT FOR UPDATE on max version) + +**Document modifications:** +- `SetDocumentCustomSchemaId` - Update documents.custom_schema_id +- `GetDocumentCustomSchemaId` - Read documents.custom_schema_id +- `BulkSetDocumentCustomSchemaIdInFolderTree` - Set schema for all docs in folder subtree (WITH RECURSIVE) +- `GetDocumentsWithMetadataInFolderTree` - Find docs that already have metadata (for skip reporting) +- `GetDocumentsWithLegacyExtractionsInFolderTree` - Find docs that have legacy extractions (for skip reporting) +- `DocumentHasLegacyExtractions` - Check if a single document has any legacy field extraction versions +- `CountDocumentsInFolderTree` - Count total documents in a folder subtree (for `MaxBulkAssignDocuments` limit check) + +**Delete cascade operations (additions to `document.sql` and `client.sql`):** +- `DeleteDocumentCustomMetadata` - Delete all custom metadata versions for a document +- `NullifyDocumentCustomSchemaId` - Set `custom_schema_id = NULL` on a document (before hard delete) +- `DeleteClientMetadataSchemas` - Delete all schemas for a client (after all documents deleted) + +### 8.3 OpenAPI Spec Changes + +Add to `serviceAPIs/queryAPI.yaml`: +- New admin paths: `/admin/custom-schemas`, `/admin/custom-schemas/{schemaId}`, `/admin/folders/{folderId}/assign-schema` +- New admin paths (additional): `/admin/document/{id}/schema` +- New user paths: `/custom-metadata`, `/custom-metadata/version`, `/custom-metadata/history` +- New schemas: `CustomSchemaRequest`, `CustomSchemaResponse`, `CustomSchemaListResponse`, `CustomMetadataRequest`, `CustomMetadataResponse`, `CustomMetadataHistoryResponse`, `ValidationErrorResponse`, `BulkSchemaAssignRequest`, `BulkSchemaAssignResponse` +- New enum: `SchemaStatus` with values `active`, `superseded`, `retired` +- Modified schemas: `DocumentEnriched` (add `customSchemaId`, `customSchemaName`, `hasCustomMetadata`, `customMetadata` fields) + +### 8.4 Legacy Field Extraction Guard + +Add a check to the existing field extraction service (`internal/fieldextraction/service.go`): + +Before creating a new field extraction, verify that `documents.custom_schema_id IS NULL` for the target document. If the document has a custom schema assigned, return a `409 Conflict` error explaining that the document uses the custom schema system and legacy field extractions are not allowed. + +This is a small modification to the existing code path, not a new endpoint. + +### 8.5 Delete Path Updates (Cascade Cleanup) + +The new tables and FKs require updates to existing delete paths. Without these changes, hard-deleting a document or client will fail with FK violations. + +#### Document Delete Cascade + +The existing `DeleteDocumentCascade` function (in `internal/document/`) and the SQLC queries in `internal/database/queries/document.sql` must be updated to delete custom metadata before deleting the document: + +**New SQLC queries to add to `document.sql`:** +- `DeleteDocumentCustomMetadata` - `DELETE FROM document_custom_metadata WHERE document_id = @document_id` +- `NullifyDocumentCustomSchemaId` - `UPDATE documents SET custom_schema_id = NULL WHERE id = @document_id` + +**Updated delete order in `DeleteDocumentCascade`:** +1. Delete `documentFieldExtractionArrayFields` (existing) +2. Delete `documentFieldExtractionVersions` (existing) +3. Delete `documentFieldExtractions` (existing) +4. **Delete `document_custom_metadata`** (new -- must come before document row delete) +5. **Nullify `documents.custom_schema_id`** (new -- removes FK to `client_metadata_schemas` before document delete) +6. Delete `documentCleanEntries` / `documentCleans` / `documentEntries` (existing) +7. Delete `documents` row (existing) + +> **Note**: Step 5 (nullify `custom_schema_id`) must happen before step 7 because the FK from `documents.custom_schema_id` to `client_metadata_schemas` would block deletion otherwise. The nullify is safe because step 4 already removed all custom metadata, and Trigger 1 only fires on changes when metadata exists. + +#### Client Delete Cascade + +The existing `client.HardDelete` and `deleteClientDependencies` functions must be updated to clean up schema data after all documents are deleted: + +**New SQLC queries:** +- `DeleteClientMetadataSchemas` - `DELETE FROM client_metadata_schemas WHERE client_id = @client_id` + +**Updated delete order in `client.HardDelete`:** +1. Delete all documents with full cascade (existing, now includes custom metadata cleanup per above) +2. Delete `documentUploads` (existing) +3. Delete all folders (existing) +4. **Delete `client_metadata_schemas`** (new -- safe after all documents are gone since no FKs reference these rows) +5. Delete collector data, batch uploads, sync records (existing) +6. Delete client row (existing) + +--- + +## 9. Invariants & Business Rules + +| # | Rule | Enforcement | +|---|------|-------------| +| 1 | Schema must be valid JSON Schema with explicit `additionalProperties` | Application: meta-validation on create/update | +| 2 | Schema `name` + `version` unique per client | Database: unique constraint | +| 3 | Updated schema gets new ID and version; old version becomes `superseded` | Application: INSERT new row, UPDATE old status | +| 4 | Schema deletion blocked if documents reference it | Application: count check before retirement | +| 5 | Document's `custom_schema_id` is immutable once any extraction exists | Application: check in AssignSchema + DB trigger: `trg_prevent_schema_reassignment` | +| 6 | Custom metadata must conform to assigned schema | Application: JSON Schema validation on write | +| 7 | All custom metadata versions for a document use same schema ID | Application: consistency check on write + DB trigger: `trg_enforce_consistent_schema_id` | +| 8 | Schema must belong to same client as document | Application: cross-reference check + DB trigger: `trg_validate_schema_client_match` | +| 9 | Only `active` schemas can be assigned to new documents | Application: status check | +| 10 | Custom metadata is versioned (no in-place updates) | Database: version column + unique constraint | +| 11 | Schema definition must be <= 64KB | Application: size check + Database: CHECK constraint | +| 12 | Schema management requires `user_admin` role | Permit.io: role-based access on `/admin` resource (existing permission) | +| 13 | No query/filter on custom metadata values | By design: not implemented (see Principle 9) | +| 14 | A document uses EITHER legacy extractions OR custom metadata, never both | Application: mutual exclusivity checks in both services + DB trigger: `trg_prevent_schema_reassignment` | +| 15 | Legacy field extraction writes rejected if document has `custom_schema_id` set | Application: guard in `FieldExtractionService` | +| 16 | Custom schema assignment rejected if document has legacy extractions | Application: guard in `AssignSchema` + DB trigger | +| 17 | Metadata payload must be <= 1MB | Application: size check (`MaxMetadataPayloadBytes`) | +| 18 | Bulk folder assignment capped at 10,000 documents | Application: count check (`MaxBulkAssignDocuments`), returns 422 if exceeded | +| 19 | Schema assignment requires `user_admin` role | Permit.io: endpoint under `/admin` prefix maps to `admin` resource | +| 20 | Delete cascade must clean up custom metadata and schema bindings | Application: updated `DeleteDocumentCascade` + `deleteClientDependencies` (Section 8.5) | + +--- + +## 10. Relationship to Existing Field Extraction System + +The existing static field extraction system (`/field-extractions`) and the new custom schema system (`/admin/custom-schemas` + `/custom-metadata`) are **mutually exclusive per document**: + +- A document uses EITHER the legacy static field extraction system OR the custom schema system +- A document CANNOT have both legacy field extractions AND custom metadata +- Assigning a custom schema to a document that already has legacy extractions returns `409 Conflict` +- Writing a legacy field extraction to a document that has a custom schema assigned returns `409 Conflict` +- Documents without a custom schema (the default) continue to use the legacy system unchanged +- There is no migration path from legacy to custom for individual documents. If a document needs custom metadata, it should not have legacy extractions written to it first. + +**Rationale**: This prevents ambiguity about which system is "authoritative" for a document's metadata. With mutual exclusivity, there is exactly one source of truth per document. This is simpler to reason about, simpler to query, and avoids the data governance problem of conflicting data in two stores. + +**No breaking changes** to the existing API or database schema for documents that do not opt into custom schemas. + +--- + +## 11. Implementation Order + +| Phase | Work | Dependencies | +|-------|------|--------------| +| 1 | Database migrations (enum, tables, view, document column, triggers) | None | +| 2 | SQLC queries + `task generate` | Phase 1 | +| 3 | Schema validator component (`internal/customschema/validator.go`) | JSON Schema library added to go.mod | +| 4 | Service layer (`internal/customschema/service.go`) incl. bulk folder assignment + mutual exclusivity guards | Phase 2, 3 | +| 5a | Legacy field extraction guard (add check to existing `FieldExtractionService`) | Phase 2 | +| 5b | Delete cascade updates (`DeleteDocumentCascade`, `deleteClientDependencies`, new SQLC queries) | Phase 2 | +| 6 | OpenAPI spec updates (admin + user endpoints) | None (can parallel with 1-3) | +| 7 | Permit.io: Add `custom-metadata` resource mapping to existing roles | None (can parallel with 1-6) | +| 8 | API controllers + code generation (admin schema CRUD, metadata CRUD) | Phase 4, 6 | +| 9 | Document endpoint modifications (PATCH schema, GET enrichment) | Phase 4, 8 | +| 10 | Bulk folder schema assignment endpoint | Phase 4, 8 | +| 11 | Integration tests | Phase 10 | +| 12 | Documentation updates (`docs/ai.generated/`) incl. sample schemas | Phase 11 | + +--- + +## 12. Testing Strategy + +- **Schema validation tests**: Valid and invalid JSON Schema documents, edge cases (empty schema, deeply nested, self-referential, oversized >64KB, missing `additionalProperties`) +- **Metadata validation tests**: Conforming and non-conforming payloads, all supported JSON Schema keywords +- **Service integration tests**: Full CRUD lifecycle using testcontainers (real PostgreSQL) +- **API integration tests**: HTTP-level tests for all new endpoints (admin + user) +- **Invariant tests**: Schema immutability, cross-client isolation, deletion guards, size limits +- **DB trigger tests**: Verify triggers fire correctly when bypassing the service layer (direct SQL inserts/updates via testcontainers) +- **Schema status lifecycle tests**: `active` -> `superseded` on version create, `active` -> `retired` on delete, assignment rejected for non-`active` schemas +- **Mutual exclusivity tests**: Legacy extraction blocked when schema assigned, schema assignment blocked when legacy extractions exist, bulk folder assignment skips documents with legacy extractions +- **Bulk folder assignment tests**: Recursive assignment, skip behavior for locked docs and legacy docs, empty folders, deeply nested folder trees +- **Authorization tests**: `user_admin` can manage schemas, `client_user` cannot manage schemas but can read/write metadata, `auditor` is read-only +- **Backward compatibility tests**: Existing field extraction endpoints continue to work unchanged for documents without custom schemas +- **Delete cascade tests**: Document hard-delete succeeds when document has custom metadata, client hard-delete succeeds when client has schemas and metadata, verify no orphaned rows remain +- **Metadata size limit tests**: Payloads at/above 1MB boundary, verify 400 response with clear message +- **Bulk assignment limit tests**: Folder trees exceeding `MaxBulkAssignDocuments`, verify 422 response +- **Schema version concurrency tests**: Concurrent PUT requests for same schema name produce distinct version numbers + +--- + +## 13. Resolved Design Decisions + +| # | Question | Decision | +|---|----------|----------| +| 1 | Schema size limits | 64KB cap via `MaxSchemaDefinitionBytes` constant (tunable) | +| 2 | Schema backward compatibility | Not required. New version = new ID. Old docs keep old schema. | +| 3 | Bulk operations | Yes. `POST /admin/folders/{folderId}/assign-schema` assigns recursively through folder tree. | +| 4 | Custom metadata search/filter | **Not supported.** No GIN indexes, no query API. Metadata is opaque storage. | +| 5 | Schema templates | No system templates. Documentation will include sample schemas covering all supported types. | +| 6 | Existing field migration path | **Out of scope.** Static fields and custom schemas are mutually exclusive per document. | +| 7 | Endpoint naming/placement | Schema CRUD under `/admin/custom-schemas` (requires existing `user_admin` role). Metadata CRUD under `/custom-metadata` (regular `client_user` access). | +| 8 | Legacy/custom coexistence | **Mutually exclusive.** A document uses EITHER legacy OR custom, never both. Simplifies data governance and prevents source-of-truth ambiguity. | +| 9 | Schema status model | Three-state enum (`active`/`superseded`/`retired`) instead of boolean. Distinguishes automatic version succession from explicit admin retirement. | +| 10 | DB-level invariant enforcement | Yes. Two database triggers enforce schema lock and client ownership match as defense-in-depth alongside application-layer checks. | +| 11 | `additionalProperties` in schemas | Required to be explicitly present (not necessarily `false`). Prevents the common mistake of omitting it and silently accepting arbitrary extra fields. | +| 12 | Metadata table design | Single table with version column (simpler than the legacy two-table pattern). Sufficient for our concurrency needs. Can split later if needed. | +| 13 | Schema assignment endpoint path | Under `/admin/document/{id}/schema` (not `/document/{id}/schema`) to ensure Permit.io maps resource to `admin`. Prevents `client_user` from assigning schemas. | +| 14 | Metadata payload size limit | 1MB cap via `MaxMetadataPayloadBytes` constant. Prevents unbounded JSONB blobs when schemas allow `additionalProperties: true`. | +| 15 | Bulk assignment document limit | 10,000 documents via `MaxBulkAssignDocuments` constant. Prevents unbounded transactions and lock contention. | +| 16 | Schema_id consistency trigger | DB trigger `trg_enforce_consistent_schema_id` on `document_custom_metadata` INSERT enforces same schema across all metadata versions for a document (defense-in-depth for Invariant 7). | + +--- + +## Appendix A: Sample JSON Schemas + +These examples demonstrate all supported JSON Schema features and data types. They should be included in the API documentation. + +### A.1 Comprehensive Schema (All Supported Types) + +```json +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "document_title": { + "type": "string", + "minLength": 1, + "maxLength": 500, + "description": "Title of the document" + }, + "document_code": { + "type": "string", + "pattern": "^[A-Z]{3}-[0-9]{4}$", + "description": "Document code in format XXX-0000" + }, + "status": { + "type": "string", + "enum": ["draft", "review", "approved", "rejected", "archived"], + "description": "Current document status" + }, + "review_date": { + "type": "string", + "format": "date", + "description": "Date of last review (YYYY-MM-DD)" + }, + "created_timestamp": { + "type": "string", + "format": "date-time", + "description": "Creation timestamp (ISO 8601)" + }, + "page_count": { + "type": "integer", + "minimum": 1, + "maximum": 10000, + "description": "Total number of pages" + }, + "confidence_score": { + "type": "number", + "minimum": 0.0, + "maximum": 1.0, + "description": "Extraction confidence (0.0 to 1.0)" + }, + "is_verified": { + "type": "boolean", + "description": "Whether the extraction has been human-verified" + }, + "tags": { + "type": "array", + "items": { "type": "string", "maxLength": 50 }, + "minItems": 0, + "maxItems": 20, + "description": "Classification tags" + }, + "numeric_values": { + "type": "array", + "items": { "type": "number" }, + "maxItems": 100, + "description": "Extracted numeric values" + }, + "contact_info": { + "type": "object", + "properties": { + "name": { "type": "string", "maxLength": 200 }, + "email": { "type": "string", "format": "email" }, + "phone": { "type": "string", "pattern": "^\\+?[0-9\\-\\s()]+$" } + }, + "required": ["name"], + "additionalProperties": false, + "description": "Primary contact information" + } + }, + "required": ["document_title", "status"], + "additionalProperties": false +} +``` + +### A.2 Aircraft Engineering Schema (Domain Example) + +```json +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "aircraft_model": { + "type": "string", + "maxLength": 100 + }, + "manufacturer": { + "type": "string", + "enum": ["Boeing", "Airbus", "Embraer", "Bombardier", "Other"] + }, + "certification_date": { + "type": "string", + "format": "date" + }, + "max_altitude_ft": { + "type": "integer", + "minimum": 0, + "maximum": 100000 + }, + "max_range_nm": { + "type": "number", + "minimum": 0 + }, + "engine_count": { + "type": "integer", + "minimum": 1, + "maximum": 12 + }, + "engine_type": { + "type": "string", + "enum": ["turbofan", "turboprop", "turbojet", "piston", "electric"] + }, + "wingspan_meters": { + "type": "number", + "minimum": 0, + "maximum": 100 + }, + "is_pressurized": { + "type": "boolean" + }, + "applicable_regulations": { + "type": "array", + "items": { + "type": "string", + "pattern": "^(FAR|EASA|CAAC)-[0-9A-Z\\.]+$" + }, + "maxItems": 50 + } + }, + "required": ["aircraft_model", "manufacturer"], + "additionalProperties": false +} +``` + +### A.3 Auto Engineering Schema (Domain Example) + +```json +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "vehicle_make": { + "type": "string", + "maxLength": 100 + }, + "vehicle_model": { + "type": "string", + "maxLength": 100 + }, + "model_year": { + "type": "integer", + "minimum": 1900, + "maximum": 2100 + }, + "vin": { + "type": "string", + "pattern": "^[A-HJ-NPR-Z0-9]{17}$", + "description": "17-character Vehicle Identification Number" + }, + "engine_displacement_liters": { + "type": "number", + "minimum": 0, + "maximum": 20 + }, + "horsepower": { + "type": "integer", + "minimum": 0 + }, + "transmission_type": { + "type": "string", + "enum": ["manual", "automatic", "cvt", "dct", "ev"] + }, + "is_electric": { + "type": "boolean" + }, + "safety_ratings": { + "type": "object", + "properties": { + "nhtsa_overall": { "type": "integer", "minimum": 1, "maximum": 5 }, + "iihs_rating": { "type": "string", "enum": ["Poor", "Marginal", "Acceptable", "Good", "Good+"] } + }, + "additionalProperties": false + }, + "recall_ids": { + "type": "array", + "items": { "type": "string", "maxLength": 20 }, + "maxItems": 100 + } + }, + "required": ["vehicle_make", "vehicle_model", "model_year"], + "additionalProperties": false +} +``` + +### Supported JSON Schema Data Types Summary + +| JSON Schema Type | Go Equivalent | Example Constraint Keywords | +|-----------------|---------------|---------------------------| +| `string` | `string` | `minLength`, `maxLength`, `pattern`, `format`, `enum` | +| `integer` | `int64` | `minimum`, `maximum` | +| `number` | `float64` | `minimum`, `maximum` | +| `boolean` | `bool` | (none) | +| `array` | `[]T` | `items`, `minItems`, `maxItems` | +| `object` | `map/struct` | `properties`, `required`, `additionalProperties` | + +**Supported `format` values**: `date`, `date-time`, `email`, `uri`, `uuid` diff --git a/plans/mutable.metadata.plan.combo.v2.md b/plans/mutable.metadata.plan.combo.v2.md new file mode 100644 index 00000000..072f74e4 --- /dev/null +++ b/plans/mutable.metadata.plan.combo.v2.md @@ -0,0 +1,1566 @@ +# Mutable Metadata & Per-Client Custom Schemas - Combined Design Plan (v2) + +## Status: DRAFT - Combined Claude + Codex + Jay's recommendations (2026-03-24, rev 2026-03-31 post-review, rev 2026-04-10: schema administration restricted to `super_admin` role only) + +**Lineage**: This plan was informed by two original detailed specs done adversarily. One by claude Opus 4.6 and one by Codex 5.4 (xhigh) based on human written requirements. Each was human reviewed for common items and conflicting items. Where there were conflicts the human was the final decision maker. This resulting hybrid document represents an improved design over each of the source documents. + +**v2 change summary (2026-04-10)**: In v1 all mutable-metadata administrative endpoints lived under `/admin/*`. Because the Permit.io middleware maps resources from the first path segment and both `user_admin` and `super_admin` hold full permissions on the `admin` resource, v1 exposed schema CRUD and schema assignment to the lesser (client-scoped) `user_admin` role. v2 moves every schema administration endpoint onto a new `/super-admin/*` path prefix backed by a new Permit.io resource named `super-admin` that only the `super_admin` role can access. User-facing custom metadata read/write endpoints (`/custom-metadata/*`) are unchanged. The underlying service layer, database schema, triggers, and validation strategy are unchanged -- this is strictly an authorization and routing change. + +--- + +## 1. Problem Statement + +Today, all field extraction data is stored in a rigid 3-table schema with ~130 statically-defined columns (19 single-value fields + 112 array fields). Every client shares the same field definitions. This means: + +- Clients cannot define domain-specific fields (e.g., "aircraft engineering" vs. "auto engineering" document classes) +- Adding new fields requires database migrations and code changes across the full stack +- The system cannot accommodate varying data shapes per document class within a single client + +**Goal**: Allow each client to curate their own collections of document schemas, where each schema defines the custom fields (with types and constraints) that a document's metadata must conform to. Administration of these schemas (create, update, retire, list, assign to documents/folders) is a platform-level responsibility and must be restricted to the `super_admin` role, not delegated to the lower-privileged `user_admin` role. + +--- + +## 2. Current Architecture Summary + +### 2.1 Existing Tables + +``` +documentFieldExtractions -- 19 single-value fields (1:1 per document) +documentFieldExtractionVersions -- Version tracking with row-level locking +documentFieldExtractionArrayFields -- 112 array fields (1:N per extraction) +``` + +### 2.2 Existing API Endpoints + +| Method | Path | Description | +|--------|------|-------------| +| POST | `/field-extractions` | Create versioned extraction (singleFields + arrayFields) | +| GET | `/field-extractions` | Get current (latest version) extraction for a document | +| GET | `/field-extractions/version` | Get extraction by specific version | +| GET | `/field-extractions/history` | List all versions for a document | + +### 2.3 Key Relationships + +- `documents.id` -> `documentFieldExtractions.documentId` (1:N via versions) +- `documentFieldExtractions.id` -> `documentFieldExtractionArrayFields.fieldExtractionId` (1:N) +- `documentFieldExtractions.id` -> `documentFieldExtractionVersions.fieldExtractionId` (1:N) +- Documents belong to clients via `documents.clientId` +- No per-client customization of field definitions exists today + +### 2.4 Current Role Model (Relevant to This Plan) + +Two admin-class roles exist today, both configured in `cmd/auth_related/permit.setup/permit_policies.yaml`: + +- **`super_admin`**: Platform administrator. Holds full permissions across every resource (`admin`, `client`, `clients`, `document`, `documents`, `folders`, etc.). Intended for AArete-internal operators. +- **`user_admin`**: Client-scoped administrator. Primarily used for creating new client users via the admin user tool. Holds full permissions on the `admin` resource but intentionally does **not** hold permissions on `client`, `clients`, or `document` at the resource level. + +Because the middleware extracts the resource from the first path segment (`/admin/foo` -> resource `admin`), any endpoint placed under `/admin/*` is accessible to both `super_admin` and `user_admin`. v1 of this plan placed schema administration under `/admin/*` and therefore exposed schema CRUD and assignment to `user_admin`. v2 corrects this. + +--- + +## 3. Design Principles + +1. **Additive, not destructive** - The existing static field extraction system continues to work unchanged for documents that do not opt into custom schemas. Custom schemas are an additive capability. +2. **Mutual exclusivity** - A document uses EITHER the legacy static field extraction system OR the custom schema system, never both. This prevents ambiguity about which system is authoritative for a document's metadata. Once a document is assigned a custom schema, the legacy field extraction endpoints reject writes for that document, and vice versa. +3. **Schema-as-data** - Schemas are stored as JSON Schema documents in the database, not as DDL. No migrations needed when clients add/modify schemas. +4. **Immutable schema versions** - When a schema is updated, a new version (with new ID) is created. Old versions remain for historical reference. No backward compatibility checking is required between versions since each version gets a distinct ID. +5. **Schema binding is permanent** - Once custom metadata is written to a document with a schema ID, that schema ID cannot be changed on the document. This guarantees data integrity. +6. **JSON blob storage** - Custom metadata is stored as JSONB in PostgreSQL, validated against the schema at write time. This avoids dynamic column creation. +7. **Client isolation** - Schemas are scoped to clients. A client cannot reference another client's schema. +8. **Size limits** - Schema definitions are capped at 64KB (`MaxSchemaDefinitionBytes = 65536`). Metadata payloads are capped at 1MB (`MaxMetadataPayloadBytes = 1048576`). Bulk folder assignments are capped at 10,000 documents (`MaxBulkAssignDocuments = 10000`). All are tunable constants. +9. **No JSONB search/filter** - The system does NOT support querying or filtering documents by custom metadata values. Custom metadata is opaque storage validated on write. If search is needed in the future, it would require GIN indexes on the JSONB columns and a dedicated query API (separate phase). +10. **`super_admin`-scoped schema management** - Schema CRUD operations, single-document schema assignment, and bulk folder-level schema assignment live under `/super-admin/*` and require the `super_admin` role. The lesser `user_admin` role does NOT have access to schema administration. Custom metadata read/write on documents (`/custom-metadata/*`) is available to regular `client_user` users and to both admin roles. +11. **Defense-in-depth** - Critical invariants (schema lock after first extraction, client/schema ownership match, schema_id consistency across metadata versions) are enforced at BOTH the application layer AND the database layer via triggers. The app-layer checks provide better error messages; the DB triggers are safety nets against bypass. +12. **No migration from static fields** - There is no migration path from the existing static field extraction data into the custom schema system. They are mutually exclusive per document. Out of scope. + +--- + +## 4. Database Schema Design + +### 4.1 New Type: `schema_status_type` + +A three-state enum that distinguishes between different reasons a schema is no longer assignable: + +```sql +CREATE TYPE schema_status_type AS ENUM ('active', 'superseded', 'retired'); +``` + +- **`active`**: The schema version is current and can be assigned to new documents. +- **`superseded`**: A newer version of this schema (same `name`) was created. The schema is still valid for documents already using it, but should not be assigned to new documents. Set automatically when a new version is created via `PUT`. +- **`retired`**: An admin explicitly disabled this schema version. Functionally similar to `superseded` but indicates a deliberate administrative action rather than an automatic version succession. + +### 4.2 New Table: `client_metadata_schemas` + +Stores client-defined JSON Schema documents. Each row is an immutable version of a schema definition. + +```sql +CREATE TABLE client_metadata_schemas ( + id uuid PRIMARY KEY DEFAULT uuid_generate_v7(), + client_id varchar(255) NOT NULL REFERENCES clients(clientId), + name varchar(255) NOT NULL, + description text, + schema_def jsonb NOT NULL, -- The JSON Schema document (max 64KB enforced at app layer) + version int NOT NULL DEFAULT 1, -- Auto-incremented per (client_id, name) + status schema_status_type NOT NULL DEFAULT 'active', + created_at timestamptz NOT NULL DEFAULT NOW(), + created_by varchar(255) NOT NULL, + + CONSTRAINT uq_client_schema_name_version UNIQUE (client_id, name, version), + CONSTRAINT chk_schema_def_size CHECK (pg_column_size(schema_def) <= 70000) -- ~68KB to account for JSONB overhead; app layer enforces 64KB on raw JSON +); + +CREATE INDEX idx_cms_client_id ON client_metadata_schemas(client_id); +CREATE INDEX idx_cms_client_name ON client_metadata_schemas(client_id, name); +CREATE INDEX idx_cms_client_status ON client_metadata_schemas(client_id, status); +``` + +> **Note**: The 64KB limit is enforced at the application layer (Go constant `MaxSchemaDefinitionBytes = 65536`) by checking `len(rawJSON)`. The database CHECK constraint uses `pg_column_size()` which measures on-disk JSONB storage (includes header overhead), so it is set slightly higher (~68KB) as a safety net. The app-layer check provides the user-facing 64KB limit and a better error message. + +**Design decisions:** + +- **`name` + `version`** scoped to client: Allows human-friendly naming ("aircraft-engineering-v3") with automatic versioning. When a schema is "updated," a new row is inserted with `version = max(version) + 1` for that `(client_id, name)`. +- **`schema_def` is JSONB**: Stores the full JSON Schema document. PostgreSQL can index into it if needed later. +- **`status` enum**: Three-state lifecycle. `superseded` is set automatically when a newer version is created. `retired` is set explicitly by admin action (soft-delete). Both states prevent assignment to new documents but allow existing documents to continue using the schema for validation. This is richer than a boolean `is_active` because it distinguishes "replaced by v2" from "admin decided this is bad." +- **`id` is the schema version ID**: This is what documents reference. Each update creates a new `id`. The `(client_id, name, version)` tuple provides the human-readable lineage. + +### 4.3 New Table: `document_custom_metadata` + +Stores the actual custom metadata for documents, validated against the referenced schema. + +```sql +CREATE TABLE document_custom_metadata ( + id uuid PRIMARY KEY DEFAULT uuid_generate_v7(), + document_id uuid NOT NULL REFERENCES documents(id), + schema_id uuid NOT NULL REFERENCES client_metadata_schemas(id), + metadata jsonb NOT NULL, -- The actual custom field values + version int NOT NULL DEFAULT 1, -- Versioned like field extractions + created_at timestamptz NOT NULL DEFAULT NOW(), + created_by varchar(255) NOT NULL, + + CONSTRAINT uq_doc_custom_metadata_version UNIQUE (document_id, version) +); + +CREATE INDEX idx_dcm_document_id ON document_custom_metadata(document_id); +CREATE INDEX idx_dcm_schema_id ON document_custom_metadata(schema_id); +CREATE INDEX idx_dcm_doc_version_desc ON document_custom_metadata(document_id, version DESC); -- Enables efficient DISTINCT ON in current_document_custom_metadata view +``` + +**Design decisions:** + +- **Single table with version column**: We use one table (rather than separate extraction + version tables like the legacy pattern) because the simpler design is sufficient. The legacy two-table pattern exists to support row-level locking on the version counter independently of the data row. For custom metadata, the `UNIQUE (document_id, version)` constraint plus `SELECT ... FOR UPDATE` on the max-version row provides the same concurrency safety with less complexity. If this proves insufficient under load, splitting into two tables is a backward-compatible change. +- **`schema_id` is immutable per document**: Enforced at both the application layer and the database trigger (see Section 4.7). All versions of custom metadata for a document must reference the same schema ID. +- **`metadata` is JSONB**: Validated against `client_metadata_schemas.schema_def` at write time. Stored as a single blob for simplicity and query flexibility. + +### 4.4 Alteration: `documents` Table + +Add an optional schema binding column: + +```sql +ALTER TABLE documents + ADD COLUMN custom_schema_id uuid NULL REFERENCES client_metadata_schemas(id); + +CREATE INDEX idx_documents_custom_schema_id ON documents(custom_schema_id); +``` + +**Behavior:** + +- `NULL` = document uses the legacy static field extraction system (default, backward-compatible) +- `NOT NULL` = document is opted into the custom schema system; legacy field extraction endpoints will reject writes for this document +- Once set AND custom metadata has been written, this field becomes immutable (enforced at app layer AND DB trigger) +- Can be set before metadata is written (pre-binding) and changed freely until first metadata write +- Must reference a schema owned by the same client as the document (cross-client reference check enforced at app layer AND DB trigger) +- Cannot be set on a document that already has legacy field extractions (returns 409 Conflict) + +### 4.5 New View: `current_document_custom_metadata` + +```sql +CREATE VIEW current_document_custom_metadata AS +SELECT DISTINCT ON (document_id) + id, + document_id, + schema_id, + metadata, + version, + created_at, + created_by +FROM document_custom_metadata +ORDER BY document_id, version DESC; +``` + +### 4.6 Entity Relationship Diagram + +``` +clients + | + |-- 1:N --> client_metadata_schemas (per-client schema definitions) + | | + | |-- referenced by --> documents.custom_schema_id + | |-- referenced by --> document_custom_metadata.schema_id + | + |-- 1:N --> documents + | + |-- 1:N --> document_custom_metadata (versioned JSONB blobs) + | [MUTUALLY EXCLUSIVE with legacy extractions] + |-- 1:N --> documentFieldExtractions (existing static fields) + [MUTUALLY EXCLUSIVE with custom metadata] +``` + +### 4.7 Database Triggers (Defense-in-Depth) + +Three triggers enforce critical invariants at the database level, providing a safety net regardless of which code path modifies the data. + +#### Trigger 1: Prevent schema reassignment after first extraction + +Fires on `documents` BEFORE UPDATE of `custom_schema_id`. Prevents changes if the document has any extraction data (legacy or custom). + +```sql +CREATE OR REPLACE FUNCTION trg_prevent_schema_reassignment() +RETURNS TRIGGER AS $$ +BEGIN + -- Only fire if custom_schema_id is actually changing + IF OLD.custom_schema_id IS DISTINCT FROM NEW.custom_schema_id THEN + -- Check for existing custom metadata + IF EXISTS ( + SELECT 1 FROM document_custom_metadata + WHERE document_id = NEW.id + LIMIT 1 + ) THEN + RAISE EXCEPTION 'Cannot change custom_schema_id on document % because custom metadata already exists', NEW.id; + END IF; + + -- Check for existing legacy field extractions + IF EXISTS ( + SELECT 1 FROM "documentFieldExtractionVersions" + WHERE "documentId" = NEW.id + LIMIT 1 + ) THEN + RAISE EXCEPTION 'Cannot change custom_schema_id on document % because legacy field extractions already exist', NEW.id; + END IF; + END IF; + + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER trg_documents_prevent_schema_reassignment + BEFORE UPDATE OF custom_schema_id ON documents + FOR EACH ROW + EXECUTE FUNCTION trg_prevent_schema_reassignment(); +``` + +#### Trigger 2: Validate client/schema ownership match + +Fires on `documents` BEFORE INSERT OR UPDATE of `custom_schema_id`. Ensures the referenced schema belongs to the same client as the document. + +```sql +CREATE OR REPLACE FUNCTION trg_validate_schema_client_match() +RETURNS TRIGGER AS $$ +DECLARE + schema_client_id varchar(255); +BEGIN + -- Only check when custom_schema_id is non-null + IF NEW.custom_schema_id IS NOT NULL THEN + SELECT client_id INTO schema_client_id + FROM client_metadata_schemas + WHERE id = NEW.custom_schema_id; + + IF schema_client_id IS NULL THEN + RAISE EXCEPTION 'Schema % does not exist', NEW.custom_schema_id; + END IF; + + IF schema_client_id != NEW."clientId" THEN + RAISE EXCEPTION 'Schema % belongs to client %, but document % belongs to client %', + NEW.custom_schema_id, schema_client_id, NEW.id, NEW."clientId"; + END IF; + END IF; + + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER trg_documents_validate_schema_client + BEFORE INSERT OR UPDATE OF custom_schema_id ON documents + FOR EACH ROW + EXECUTE FUNCTION trg_validate_schema_client_match(); +``` + +#### Trigger 3: Enforce consistent schema_id across metadata versions + +Fires on `document_custom_metadata` BEFORE INSERT. Ensures all metadata versions for a document reference the same schema ID. This is the database-level safety net for Invariant 7. + +```sql +CREATE OR REPLACE FUNCTION trg_enforce_consistent_schema_id() +RETURNS TRIGGER AS $$ +DECLARE + existing_schema_id uuid; +BEGIN + SELECT schema_id INTO existing_schema_id + FROM document_custom_metadata + WHERE document_id = NEW.document_id + LIMIT 1; + + IF existing_schema_id IS NOT NULL AND existing_schema_id != NEW.schema_id THEN + RAISE EXCEPTION 'All custom metadata for document % must use schema %, but got %', + NEW.document_id, existing_schema_id, NEW.schema_id; + END IF; + + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER trg_dcm_enforce_consistent_schema + BEFORE INSERT ON document_custom_metadata + FOR EACH ROW + EXECUTE FUNCTION trg_enforce_consistent_schema_id(); +``` + +> **Note**: These triggers duplicate checks that also exist in the application service layer. This is intentional. The app-layer checks return structured HTTP error responses (400/409). The triggers are a last-resort safety net that fires even if a future code path, migration script, or admin query bypasses the service layer. + +--- + +## 5. API Design + +### 5.1 Schema Management Endpoints (`super_admin` only) + +All schema CRUD endpoints live under the `/super-admin/custom-schemas` path prefix. The first path segment `super-admin` maps (via the existing first-segment resource extraction rule in the Permit.io middleware) to a new Permit.io resource named `super-admin`. Only the `super_admin` role holds permissions on this resource. The `user_admin` role does NOT have access. + +These endpoints are grouped under a new OpenAPI tag `SuperAdminSchemaService` (distinct from the existing `AdminService`) to make the authorization boundary visible in the generated client and in Swagger. + +#### `POST /super-admin/custom-schemas` + +Create a new schema for a client. + +**Request:** +```json +{ + "clientId": "acme-corp", + "name": "aircraft-engineering", + "description": "Custom fields for aircraft engineering documents", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "aircraft_model": { + "type": "string", + "maxLength": 100 + }, + "certification_date": { + "type": "string", + "format": "date" + }, + "max_altitude_ft": { + "type": "number", + "minimum": 0, + "maximum": 100000 + }, + "engine_count": { + "type": "integer", + "minimum": 1, + "maximum": 12 + } + }, + "required": ["aircraft_model"], + "additionalProperties": false + }, + "createdBy": "admin@acme.com" +} +``` + +**Response (201):** +```json +{ + "id": "019577a3-...", + "clientId": "acme-corp", + "name": "aircraft-engineering", + "description": "Custom fields for aircraft engineering documents", + "schema": { ... }, + "version": 1, + "status": "active", + "createdAt": "2026-03-23T12:00:00Z", + "createdBy": "admin@acme.com" +} +``` + +**Validation:** +- The `schema` field MUST be a valid JSON Schema document (validated server-side using a JSON Schema meta-validator) +- The `schema` field must be <= 64KB in size (returns 400 with message referencing the limit) +- Root `type` must be `"object"` +- Root `additionalProperties` MUST be explicitly present (either `true` or `false`). If omitted, the server returns 400 with a message explaining that `additionalProperties` must be explicitly declared. This prevents the common mistake of omitting it and inadvertently accepting arbitrary extra fields. +- `name` must be unique within the client (for active schemas at the same version) + +#### `PUT /super-admin/custom-schemas/{schemaId}` + +Update an existing schema. Creates a new version with a new ID. The old version remains intact and its status is set to `superseded`. No backward compatibility check is performed -- the new version is treated as an independent schema that happens to share a name lineage. + +**Request:** +```json +{ + "description": "Updated aircraft engineering fields (added wingspan)", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "aircraft_model": { "type": "string", "maxLength": 100 }, + "certification_date": { "type": "string", "format": "date" }, + "max_altitude_ft": { "type": "number", "minimum": 0, "maximum": 100000 }, + "engine_count": { "type": "integer", "minimum": 1, "maximum": 12 }, + "wingspan_meters": { "type": "number", "minimum": 0 } + }, + "required": ["aircraft_model"], + "additionalProperties": false + }, + "createdBy": "admin@acme.com" +} +``` + +**Response (201):** +```json +{ + "id": "019577b4-...", + "clientId": "acme-corp", + "name": "aircraft-engineering", + "version": 2, + "status": "active", + "previousVersionId": "019577a3-...", + ... +} +``` + +**Behavior:** +- The `schemaId` in the path identifies the schema to "update" (really: create a new version of) +- The new version inherits the `name` from the previous version +- The old version's status is set to `superseded` (it remains valid for existing documents but should not be assigned to new ones) +- Returns the newly created schema version +- The `schema` field in the request body follows the same validation rules as `POST` (including the `additionalProperties` requirement) +- **Concurrency**: Uses `SELECT ... FOR UPDATE` on `client_metadata_schemas WHERE client_id = $1 AND name = $2` to lock existing versions before reading `max(version)`. This prevents two concurrent PUTs from computing the same next version number. The unique constraint `uq_client_schema_name_version` is the final safety net. + +#### `GET /super-admin/custom-schemas` + +List all schemas for a client. + +**Query Parameters:** +- `clientId` (required): The client whose schemas to list +- `name` (optional): Filter by schema name +- `status` (optional): Filter by status (`active`, `superseded`, `retired`). Default: return only `active`. +- `includeAllVersions` (optional, default: false): Return all versions or just latest per name +- `limit` (optional, default: 50, max: 200): Maximum number of results to return +- `offset` (optional, default: 0): Number of results to skip for pagination + +**Response (200):** +```json +{ + "schemas": [ + { + "id": "019577b4-...", + "clientId": "acme-corp", + "name": "aircraft-engineering", + "description": "...", + "version": 2, + "status": "active", + "documentCount": 45, + "canDelete": false, + "createdAt": "...", + "createdBy": "..." + }, + { + "id": "019577c1-...", + "clientId": "acme-corp", + "name": "auto-engineering", + "description": "...", + "version": 1, + "status": "active", + "documentCount": 0, + "canDelete": true, + "createdAt": "...", + "createdBy": "..." + } + ] +} +``` + +**Notes:** +- `documentCount` shows how many documents reference each schema version. Computed via a subquery or join. +- `canDelete` is `true` when `documentCount == 0` AND no `document_custom_metadata` rows reference the schema. This saves the client a round-trip to check before attempting delete. + +#### `GET /super-admin/custom-schemas/{schemaId}` + +Get a specific schema by ID (returns full schema definition). + +**Response (200):** +```json +{ + "id": "019577a3-...", + "clientId": "acme-corp", + "name": "aircraft-engineering", + "description": "...", + "schema": { ... }, + "version": 1, + "status": "superseded", + "documentCount": 45, + "canDelete": false, + "createdAt": "...", + "createdBy": "..." +} +``` + +#### `DELETE /super-admin/custom-schemas/{schemaId}` + +Retire a schema. Only succeeds if no documents reference it. + +**Response (204):** No content on success. + +**Response (409 Conflict):** +```json +{ + "error": "Schema is in use by 45 documents and cannot be deleted" +} +``` + +**Behavior:** +- Sets `status = 'retired'` +- Fails with 409 if any documents reference this schema ID (via `documents.custom_schema_id` or `document_custom_metadata.schema_id`) +- Retired schemas still serve validation for existing documents but cannot be assigned to new documents + +--- + +### 5.2 Document Schema Assignment Endpoints (`super_admin` only) + +Schema assignment endpoints also live under `/super-admin/*` so that the same first-segment resource extraction rule confines them to the `super-admin` Permit.io resource. Placing them under `/admin/*` (v1) or under `/document/*` would expose them to `user_admin` or `client_user` respectively, which we explicitly do not want. + +#### `PATCH /super-admin/documents/{id}/schema` + +Assign or change a custom schema on a single document. + +> **Path note**: The sub-segment uses plural `documents/{id}` (not `document/{id}` as in v1) to avoid any possible collision with the existing top-level `/document/{id}` resource path during code review or manual URL inspection. This has no authorization impact because the first segment (`super-admin`) is what the middleware uses to resolve the resource, but it makes the namespacing visually unambiguous. + +**Request:** +```json +{ + "customSchemaId": "019577a3-..." +} +``` + +**Response (200):** +```json +{ + "documentId": "...", + "customSchemaId": "019577a3-...", + "schemaName": "aircraft-engineering", + "schemaVersion": 1 +} +``` + +**Validation rules:** +- Schema must belong to the same client as the document (enforced at app layer + DB trigger) +- Schema must have status `active` +- If the document already has custom metadata written, the schema cannot be changed (returns 409, also enforced by DB trigger) +- If the document already has legacy field extractions, the schema cannot be assigned (returns 409 -- mutual exclusivity) +- Setting `customSchemaId` to `null` removes the binding (only if no custom metadata exists) + +#### `POST /super-admin/folders/{folderId}/assign-schema` (Bulk Folder Assignment) + +Assign a custom schema to ALL documents in a folder and all its subfolders (recursive). + +**Request:** +```json +{ + "customSchemaId": "019577a3-...", + "createdBy": "admin@acme.com" +} +``` + +**Response (200):** +```json +{ + "folderId": "...", + "customSchemaId": "019577a3-...", + "schemaName": "aircraft-engineering", + "documentsUpdated": 87, + "documentsSkipped": 5, + "skippedReasons": [ + {"documentId": "...", "reason": "Document already has custom metadata with a different schema"}, + {"documentId": "...", "reason": "Document already has custom metadata with a different schema"}, + {"documentId": "...", "reason": "Document already assigned to same schema (no-op)"}, + {"documentId": "...", "reason": "Document has legacy field extractions"}, + {"documentId": "...", "reason": "Document has legacy field extractions"} + ] +} +``` + +**Behavior:** +- Uses `WITH RECURSIVE` CTE to walk the entire folder subtree (same pattern as folder deletion) +- For each document in the tree: + - If the document has no schema, no custom metadata, and no legacy extractions -> assign the schema + - If the document already has the same schema -> skip (no-op, counted in `documentsSkipped`) + - If the document has a different schema but no custom metadata yet -> reassign to new schema + - If the document has a different schema AND has custom metadata -> skip with reason (cannot change) + - If the document has legacy field extractions -> skip with reason (mutual exclusivity) +- Runs in a single transaction +- **Document count limit**: If the folder subtree contains more than `MaxBulkAssignDocuments` (10,000) documents, returns `422 Unprocessable Entity` with a message explaining the limit. This prevents unbounded transaction duration, excessive lock contention, and replication lag. +- Returns summary of what was updated and what was skipped (with reasons) +- Requires `super_admin` role (the `super-admin` resource grants neither `user_admin` nor `client_user` access) + +#### `GET /document/{id}` (Modified) + +Add `customSchemaId` and `customMetadata` fields to `DocumentEnriched`: + +```json +{ + "id": "...", + "clientId": "acme-corp", + "hash": "...", + "customSchemaId": "019577a3-...", + "customSchemaName": "aircraft-engineering", + "hasCustomMetadata": true, + "customMetadata": { ... }, + ...existing fields... +} +``` + +**Query parameter:** `customMetadata` (boolean, default: false) - when true, includes the full custom metadata in the response (like the existing `textRecord` parameter). + +> **Authorization note**: The `GET /document/{id}` endpoint stays on the existing `document` resource. Both admin roles, `client_user`, and `auditor` can read it today. Returning `customSchemaId` / `customSchemaName` / `customMetadata` on this endpoint is read-only and does not grant any administrative capability. The `super-admin` gating applies only to the write/assign/manage paths. + +--- + +### 5.3 Authorization & Permit.io Changes + +v2 splits admin-class authorization for mutable metadata into two tiers: + +| Role | Schema CRUD (`/super-admin/custom-schemas/*`) | Schema Assignment (`/super-admin/documents/{id}/schema`, `/super-admin/folders/{folderId}/assign-schema`) | Metadata Read (`GET /custom-metadata/*`, `GET /document/{id}` enrichment) | Metadata Write (`POST /custom-metadata`) | +|------|-----------------------------------------------|-----------------------------------------------------------------------------------------------------------|----------------------------------------------------------------------------|-------------------------------------------| +| `super_admin` | Yes | Yes | Yes | Yes | +| `user_admin` | **No** | **No** | Yes | Yes | +| `auditor` | Read-only (`GET` only) | No | Read-only | No | +| `client_user` | No | No | Yes | Yes | + +Key change from v1: `user_admin` has NO schema CRUD and NO schema assignment rights. The role continues to function exactly as it does today for everything else (user creation, labels, folders, etc.). + +#### New Permit.io resource: `super-admin` + +Add a new resource to `cmd/auth_related/permit.setup/permit_policies.yaml`: + +```yaml +resources: + # ...existing resources unchanged... + + - name: super-admin + description: Platform-level administrative functions restricted to super_admin role (custom metadata schema management, schema assignment) + actions: + - get + - post + - patch + - delete +``` + +#### Role permission updates + +```yaml +roles: + - name: super_admin + description: Full system access for platform administrators + permissions: + # ...existing permissions unchanged... + super-admin: + - get + - post + - patch + - delete + custom-metadata: + - get + - post + + - name: user_admin + description: User management - primarily for creating new client users + permissions: + # ...existing permissions unchanged (admin, documents, field-extractions, folders)... + # NOTE: user_admin is intentionally NOT granted super-admin:* permissions. + custom-metadata: + - get + - post + + - name: auditor + description: Full read-only access across the entire system for auditing and compliance + permissions: + # ...existing permissions unchanged... + super-admin: + - get + custom-metadata: + - get + + - name: client_user + description: Client-specific access for external users (requires tenant filtering) + permissions: + # ...existing permissions unchanged... + custom-metadata: + - get + - post +``` + +#### New custom-metadata resource + +The user-facing custom metadata endpoints (`/custom-metadata/*`) use a separate `custom-metadata` resource (first path segment). This is unchanged from v1 except that it now also needs to be granted to `super_admin` explicitly (v1 relied on `admin:*` coverage which no longer covers the metadata path). + +```yaml +resources: + - name: custom-metadata + description: Per-document custom metadata read/write (validated against client schemas) + actions: + - get + - post +``` + +#### Policy mapping documentation additions + +Add to the documentation-only `policy_mappings` section at the bottom of `permit_policies.yaml`: + +```yaml +super_admin_endpoints: + - path: /super-admin/custom-schemas + methods: + GET: super-admin:get + POST: super-admin:post + - path: /super-admin/custom-schemas/{schemaId} + methods: + GET: super-admin:get + PUT: super-admin:patch # PUT maps to patch action (create-new-version semantics) + DELETE: super-admin:delete + - path: /super-admin/documents/{id}/schema + methods: + PATCH: super-admin:patch + - path: /super-admin/folders/{folderId}/assign-schema + methods: + POST: super-admin:post + +custom_metadata_endpoints: + - path: /custom-metadata + methods: + GET: custom-metadata:get + POST: custom-metadata:post + - path: /custom-metadata/version + methods: + GET: custom-metadata:get + - path: /custom-metadata/history + methods: + GET: custom-metadata:get +``` + +> **PUT-to-patch action note**: The Permit.io middleware lowercases the HTTP method. Permit.io's default action vocabulary does not include `put`; the existing middleware either maps `PUT` to `patch` or requires the action to exist. Confirm during implementation whether the middleware needs a `put` action added to each resource or whether mapping `PUT` -> `patch` is acceptable. If a new action is needed, add `put` to both `super-admin.actions` and the `super_admin` role permissions. The same consideration applies to any other resources that currently have no `put` action. + +#### Middleware impact + +The Permit.io resource-extraction middleware uses the first path segment. Introducing `super-admin` as a resource requires no middleware code changes -- it is purely a configuration addition via the permit setup tool. The setup tool (`cmd/auth_related/permit.setup/`) must be re-run in each environment (dev, uat, prod) to provision the new resource and role permissions before these endpoints can pass authorization. + +#### Deployment ordering + +Because the authorization changes must land in Permit.io before the API routes are live, the deployment sequence is: + +1. Update `permit_policies.yaml` with the new resources and role grants. +2. Run the permit setup tool against dev/uat/prod to provision the new resource and permissions. +3. Deploy the API code containing the new endpoints. + +Step 2 must complete before step 3 in each environment, otherwise the new endpoints would either be rejected by Permit.io (if fail-closed) or accidentally allowed (if fail-open) during the window between code deploy and permit sync. + +--- + +### 5.4 Custom Metadata CRUD Endpoints (User-Facing) + +All under a new `CustomMetadataService` tag. These endpoints are NOT moved under `/super-admin/*` because they are intended for regular users (including `client_user`) to read and write the custom metadata values on documents. Schema administration and schema assignment are separated from value read/write precisely so the day-to-day data entry workload does not require super_admin. + +#### `POST /custom-metadata` + +Create or update custom metadata for a document. Creates a new version. + +**Request:** +```json +{ + "documentId": "...", + "metadata": { + "aircraft_model": "Boeing 737-800", + "certification_date": "2024-06-15", + "max_altitude_ft": 41000, + "engine_count": 2 + }, + "createdBy": "analyst@acme.com" +} +``` + +**Response (201):** +```json +{ + "id": "...", + "documentId": "...", + "schemaId": "019577a3-...", + "metadata": { ... }, + "version": 1, + "createdAt": "...", + "createdBy": "analyst@acme.com" +} +``` + +**Validation:** +- Document must have a `custom_schema_id` assigned (400 if not -- a super_admin must have assigned the schema first) +- Document must NOT have any legacy field extractions (409 if it does -- mutual exclusivity, also enforced by DB trigger on schema assignment) +- The `metadata` payload must be <= 1MB in size (`MaxMetadataPayloadBytes = 1048576`). Returns 400 if exceeded. +- The `metadata` payload is validated against the JSON Schema stored in `client_metadata_schemas.schema_def` +- The request body does NOT include `schemaId` -- the server derives it from the document's `custom_schema_id`. This prevents client/server drift. +- If validation fails, returns 400 with detailed validation errors: + ```json + { + "error": "Metadata does not conform to schema", + "validationErrors": [ + {"field": "engine_count", "message": "must be >= 1, got 0"}, + {"field": "aircraft_model", "message": "required field missing"} + ] + } + ``` +- On first write, locks the document's `custom_schema_id` (makes it immutable via app-layer flag + DB trigger) +- Subsequent writes create new versions but must use the same schema + +#### `GET /custom-metadata` + +Get current (latest version) custom metadata for a document. + +**Query Parameters:** +- `documentId` (required) + +**Response (200):** +```json +{ + "id": "...", + "documentId": "...", + "schemaId": "019577a3-...", + "schemaName": "aircraft-engineering", + "metadata": { ... }, + "version": 3, + "createdAt": "...", + "createdBy": "..." +} +``` + +#### `GET /custom-metadata/version` + +Get a specific version of custom metadata. + +**Query Parameters:** +- `documentId` (required) +- `version` (required, >= 1) + +#### `GET /custom-metadata/history` + +Get version history for a document's custom metadata. + +**Query Parameters:** +- `documentId` (required) +- `limit` (optional, default: 50, max: 200): Maximum number of versions to return +- `offset` (optional, default: 0): Number of versions to skip for pagination + +**Response (200):** +```json +{ + "versions": [ + {"id": "...", "documentId": "...", "version": 3, "createdAt": "...", "createdBy": "..."}, + {"id": "...", "documentId": "...", "version": 2, "createdAt": "...", "createdBy": "..."}, + {"id": "...", "documentId": "...", "version": 1, "createdAt": "...", "createdBy": "..."} + ] +} +``` + +--- + +## 6. Validation Strategy + +### 6.1 JSON Schema Validation + +Use a Go JSON Schema validation library for both: + +1. **Schema validation** (meta-validation): When a super_admin submits a new schema, validate it against the JSON Schema meta-schema to ensure it is a well-formed JSON Schema document. +2. **Metadata validation**: When custom metadata is submitted, validate the payload against the document's assigned schema. + +**Recommended library**: `github.com/santhosh-tekuri/jsonschema/v6` + +Reasons: +- Supports JSON Schema draft 2020-12 +- Active maintenance +- Good error reporting +- Supports custom formats + +### 6.2 Schema Definition Requirements + +When a schema definition is submitted (via `POST` or `PUT`), the server validates: + +1. It is a valid JSON object +2. It compiles successfully as a JSON Schema document (meta-validation) +3. Root `type` is `"object"` +4. Root `additionalProperties` is explicitly present (either `true` or `false`). This is required because JSON Schema defaults `additionalProperties` to `true` when omitted, which silently accepts any extra fields. Requiring explicit declaration forces a conscious choice. +5. Size is <= 64KB + +### 6.3 Supported JSON Schema Features + +The system will support the following JSON Schema keywords for custom field definitions: + +| Feature | JSON Schema Keyword | Example | +|---------|-------------------|---------| +| Data type | `type` | `"string"`, `"number"`, `"integer"`, `"boolean"`, `"array"`, `"object"` | +| Required fields | `required` | `["field1", "field2"]` | +| String length | `minLength`, `maxLength` | `"maxLength": 100` | +| Numeric range | `minimum`, `maximum` | `"minimum": 0` | +| Pattern matching | `pattern` | `"pattern": "^[A-Z]{2}$"` | +| Enum values | `enum` | `"enum": ["active", "inactive"]` | +| Date/time format | `format` | `"format": "date"` | +| Array items | `items`, `minItems`, `maxItems` | Nested array support | +| Nested objects | `properties` | Hierarchical field groups | +| No extra fields | `additionalProperties` | `false` to enforce strict schemas | + +### 6.4 Validation Error Reporting + +Validation errors will be returned as structured JSON with: +- The JSON pointer to the failing field (`/aircraft_model`) +- The constraint that was violated (`maxLength`) +- A human-readable message + +--- + +## 7. Service Layer Design + +### 7.1 New Service: `internal/customschema/` + +``` +internal/customschema/ + service.go -- CustomSchemaService (CRUD + validation) + models.go -- Input/output types + validator.go -- JSON Schema meta-validation + payload validation + constants.go -- MaxSchemaDefinitionBytes and other limits + service_test.go -- Integration tests +``` + +**Constants (`constants.go`):** + +```go +const ( + // MaxSchemaDefinitionBytes is the maximum size of a JSON Schema definition. + // This limit can be increased if clients need larger schemas. + MaxSchemaDefinitionBytes = 65536 // 64KB + + // MaxMetadataPayloadBytes is the maximum size of a custom metadata JSON payload. + // Prevents unbounded JSONB blobs when schemas use additionalProperties: true. + MaxMetadataPayloadBytes = 1048576 // 1MB + + // MaxBulkAssignDocuments is the maximum number of documents that can be updated + // in a single bulk folder schema assignment. Prevents unbounded transactions. + MaxBulkAssignDocuments = 10000 +) +``` + +**Key methods:** + +```go +type Service struct { + cfg serviceconfig.ConfigProvider + validator *SchemaValidator +} + +// Schema management (super_admin operations) +func (s *Service) CreateSchema(ctx, input CreateSchemaInput) (*Schema, error) +func (s *Service) UpdateSchema(ctx, schemaID uuid.UUID, input UpdateSchemaInput) (*Schema, error) +func (s *Service) GetSchema(ctx, schemaID uuid.UUID) (*Schema, error) +func (s *Service) ListSchemas(ctx, clientID string, filters ListFilters) ([]SchemaSummary, error) +func (s *Service) DeleteSchema(ctx, schemaID uuid.UUID) error + +// Metadata operations (client_user / admin operations) +func (s *Service) SetDocumentMetadata(ctx, input SetMetadataInput) (*CustomMetadata, error) +func (s *Service) GetCurrentMetadata(ctx, documentID uuid.UUID) (*CustomMetadata, error) +func (s *Service) GetMetadataByVersion(ctx, documentID uuid.UUID, version int) (*CustomMetadata, error) +func (s *Service) GetMetadataHistory(ctx, documentID uuid.UUID) ([]MetadataVersion, error) + +// Document schema binding (super_admin operations) +func (s *Service) AssignSchema(ctx, documentID uuid.UUID, schemaID uuid.UUID) error +func (s *Service) AssignSchemaToFolder(ctx, folderID uuid.UUID, schemaID uuid.UUID, createdBy string) (*BulkAssignResult, error) +``` + +**BulkAssignResult:** + +```go +type BulkAssignResult struct { + FolderID uuid.UUID + SchemaID uuid.UUID + DocumentsUpdated int + DocumentsSkipped int + SkippedReasons []SkippedDocument +} + +type SkippedDocument struct { + DocumentID uuid.UUID + Reason string +} +``` + +> **Authorization is enforced at the HTTP middleware layer via Permit.io**, not inside these service methods. The service methods are agnostic to the caller's role. The routing layer for `/super-admin/*` endpoints is what ensures only `super_admin` can invoke `CreateSchema`, `UpdateSchema`, `DeleteSchema`, `AssignSchema`, and `AssignSchemaToFolder`. Keeping the service layer role-unaware preserves testability and makes integration tests simpler. + +### 7.2 Validator Component + +```go +type SchemaValidator struct{} + +// ValidateSchemaDefinition checks that a schema_def is valid JSON Schema. +// Returns error if: not a valid JSON object, fails meta-validation, +// root type is not "object", or additionalProperties is not explicitly present. +func (v *SchemaValidator) ValidateSchemaDefinition(schemaDef json.RawMessage) error + +// ValidateMetadata checks that metadata conforms to a compiled schema +func (v *SchemaValidator) ValidateMetadata(schemaDef json.RawMessage, metadata json.RawMessage) ([]ValidationError, error) +``` + +### 7.3 Mutual Exclusivity Guard + +The service layer enforces mutual exclusivity at two points: + +1. **Schema assignment** (`AssignSchema`): Before setting `custom_schema_id` on a document, check that no rows exist in `documentFieldExtractionVersions` for that document. If they do, return `409 Conflict`. +2. **Legacy field extraction write** (modification to existing `FieldExtractionService`): Before creating a new field extraction, check that `documents.custom_schema_id IS NULL` for the target document. If it is not null, return `409 Conflict` with a message explaining that the document uses the custom schema system. + +> **Note**: The DB trigger on `documents.custom_schema_id` (Section 4.7, Trigger 1) also prevents schema assignment if legacy extractions exist. This is the defense-in-depth pattern: the service layer returns a clean 409; the trigger catches anything that slips through. + +### 7.4 API Controller: `api/queryAPI/customschemas.go` + +Follows the same pattern as `fieldextractions.go`: +- Parse and validate request +- Call service layer +- Convert between API and service types +- Return appropriate HTTP status codes + +The controller file holds all `/super-admin/custom-schemas/*`, `/super-admin/documents/{id}/schema`, and `/super-admin/folders/{folderId}/assign-schema` handlers. A separate controller file `api/queryAPI/custommetadata.go` holds the user-facing `/custom-metadata/*` handlers. Separating them by file mirrors the separation of authorization tiers and makes it obvious at code-review time which handlers are super_admin-gated. + +--- + +## 8. Migration Plan + +### 8.1 Migration Files + +Four new migrations: + +**Migration 120: Create `schema_status_type` enum + `client_metadata_schemas` table** +``` +00000000000120_create_client_metadata_schemas.up.sql +00000000000120_create_client_metadata_schemas.down.sql +``` + +**Migration 121: Create `document_custom_metadata` table + view** +``` +00000000000121_create_document_custom_metadata.up.sql +00000000000121_create_document_custom_metadata.down.sql +``` + +**Migration 122: Add `custom_schema_id` to `documents` table** +``` +00000000000122_add_custom_schema_id_to_documents.up.sql +00000000000122_add_custom_schema_id_to_documents.down.sql +``` + +**Migration 123: Add database triggers for invariant enforcement** +``` +00000000000123_add_schema_invariant_triggers.up.sql +00000000000123_add_schema_invariant_triggers.down.sql +``` + +Contents: +- `trg_prevent_schema_reassignment` function + trigger (Section 4.7, Trigger 1) +- `trg_validate_schema_client_match` function + trigger (Section 4.7, Trigger 2) +- `trg_enforce_consistent_schema_id` function + trigger (Section 4.7, Trigger 3) +- Down migration drops triggers and functions + +### 8.2 SQLC Queries + +New query file: `internal/database/queries/customschemas.sql` + +**Schema operations:** +- `CreateClientMetadataSchema` - Insert new schema +- `GetClientMetadataSchema` - Get by ID +- `ListClientMetadataSchemas` - List by client with filters (status filter) +- `GetLatestSchemaByName` - Get latest version of a named schema +- `GetSchemaDocumentCount` - Count documents using a schema +- `GetSchemaMetadataRecordCount` - Count metadata records referencing a schema (for `canDelete`) +- `SetSchemaStatus` - Update status (for supersede/retire) +- `GetMaxSchemaVersion` - For auto-incrementing version +- `LockSchemaVersionsForName` - `SELECT ... FOR UPDATE` on all versions of a (client_id, name) pair to prevent concurrent version creation race + +**Metadata operations:** +- `CreateDocumentCustomMetadata` - Insert new metadata version +- `GetCurrentDocumentCustomMetadata` - Latest version (via view) +- `GetDocumentCustomMetadataByVersion` - Specific version +- `GetDocumentCustomMetadataHistory` - All versions +- `LockDocumentCustomMetadataForVersion` - Row-level locking (SELECT FOR UPDATE on max version) + +**Document modifications:** +- `SetDocumentCustomSchemaId` - Update documents.custom_schema_id +- `GetDocumentCustomSchemaId` - Read documents.custom_schema_id +- `BulkSetDocumentCustomSchemaIdInFolderTree` - Set schema for all docs in folder subtree (WITH RECURSIVE) +- `GetDocumentsWithMetadataInFolderTree` - Find docs that already have metadata (for skip reporting) +- `GetDocumentsWithLegacyExtractionsInFolderTree` - Find docs that have legacy extractions (for skip reporting) +- `DocumentHasLegacyExtractions` - Check if a single document has any legacy field extraction versions +- `CountDocumentsInFolderTree` - Count total documents in a folder subtree (for `MaxBulkAssignDocuments` limit check) + +**Delete cascade operations (additions to `document.sql` and `client.sql`):** +- `DeleteDocumentCustomMetadata` - Delete all custom metadata versions for a document +- `NullifyDocumentCustomSchemaId` - Set `custom_schema_id = NULL` on a document (before hard delete) +- `DeleteClientMetadataSchemas` - Delete all schemas for a client (after all documents deleted) + +### 8.3 OpenAPI Spec Changes + +Add to `serviceAPIs/queryAPI.yaml`: + +**New `SuperAdminSchemaService` tag** for super_admin-only endpoints: +- `POST /super-admin/custom-schemas` +- `GET /super-admin/custom-schemas` +- `GET /super-admin/custom-schemas/{schemaId}` +- `PUT /super-admin/custom-schemas/{schemaId}` +- `DELETE /super-admin/custom-schemas/{schemaId}` +- `PATCH /super-admin/documents/{id}/schema` +- `POST /super-admin/folders/{folderId}/assign-schema` + +**New `CustomMetadataService` tag** for end-user endpoints: +- `GET /custom-metadata` +- `POST /custom-metadata` +- `GET /custom-metadata/version` +- `GET /custom-metadata/history` + +**New schemas**: `CustomSchemaRequest`, `CustomSchemaResponse`, `CustomSchemaListResponse`, `CustomMetadataRequest`, `CustomMetadataResponse`, `CustomMetadataHistoryResponse`, `ValidationErrorResponse`, `BulkSchemaAssignRequest`, `BulkSchemaAssignResponse`, `DocumentSchemaAssignRequest`, `DocumentSchemaAssignResponse` + +**New enum**: `SchemaStatus` with values `active`, `superseded`, `retired` + +**Modified schemas**: `DocumentEnriched` (add `customSchemaId`, `customSchemaName`, `hasCustomMetadata`, `customMetadata` fields) + +**Tag descriptions should explicitly call out the authorization boundary:** +- `SuperAdminSchemaService` description: "Custom metadata schema management and assignment. Restricted to the `super_admin` role. Not accessible to `user_admin`." +- `CustomMetadataService` description: "Per-document custom metadata read/write. Accessible to `client_user`, `user_admin`, and `super_admin`." + +### 8.4 Legacy Field Extraction Guard + +Add a check to the existing field extraction service (`internal/fieldextraction/service.go`): + +Before creating a new field extraction, verify that `documents.custom_schema_id IS NULL` for the target document. If the document has a custom schema assigned, return a `409 Conflict` error explaining that the document uses the custom schema system and legacy field extractions are not allowed. + +This is a small modification to the existing code path, not a new endpoint. + +### 8.5 Delete Path Updates (Cascade Cleanup) + +The new tables and FKs require updates to existing delete paths. Without these changes, hard-deleting a document or client will fail with FK violations. + +#### Document Delete Cascade + +The existing `DeleteDocumentCascade` function (in `internal/document/`) and the SQLC queries in `internal/database/queries/document.sql` must be updated to delete custom metadata before deleting the document: + +**New SQLC queries to add to `document.sql`:** +- `DeleteDocumentCustomMetadata` - `DELETE FROM document_custom_metadata WHERE document_id = @document_id` +- `NullifyDocumentCustomSchemaId` - `UPDATE documents SET custom_schema_id = NULL WHERE id = @document_id` + +**Updated delete order in `DeleteDocumentCascade`:** +1. Delete `documentFieldExtractionArrayFields` (existing) +2. Delete `documentFieldExtractionVersions` (existing) +3. Delete `documentFieldExtractions` (existing) +4. **Delete `document_custom_metadata`** (new -- must come before document row delete) +5. **Nullify `documents.custom_schema_id`** (new -- removes FK to `client_metadata_schemas` before document delete) +6. Delete `documentCleanEntries` / `documentCleans` / `documentEntries` (existing) +7. Delete `documents` row (existing) + +> **Note**: Step 5 (nullify `custom_schema_id`) must happen before step 7 because the FK from `documents.custom_schema_id` to `client_metadata_schemas` would block deletion otherwise. The nullify is safe because step 4 already removed all custom metadata, and Trigger 1 only fires on changes when metadata exists. + +#### Client Delete Cascade + +The existing `client.HardDelete` and `deleteClientDependencies` functions must be updated to clean up schema data after all documents are deleted: + +**New SQLC queries:** +- `DeleteClientMetadataSchemas` - `DELETE FROM client_metadata_schemas WHERE client_id = @client_id` + +**Updated delete order in `client.HardDelete`:** +1. Delete all documents with full cascade (existing, now includes custom metadata cleanup per above) +2. Delete `documentUploads` (existing) +3. Delete all folders (existing) +4. **Delete `client_metadata_schemas`** (new -- safe after all documents are gone since no FKs reference these rows) +5. Delete collector data, batch uploads, sync records (existing) +6. Delete client row (existing) + +### 8.6 Permit.io Setup Tool Run + +After updating `permit_policies.yaml` per Section 5.3, run the permit setup tool in each environment: + +``` +cmd/auth_related/permit.setup/run.tool.dev.sh +cmd/auth_related/permit.setup/run.tool.uat.sh +cmd/auth_related/permit.setup/run.tool.prod.sh +``` + +The tool is idempotent and will provision the new `super-admin` and `custom-metadata` resources and update role grants without disturbing existing resources. Run this **before** deploying the API code so the new endpoints are authorized the moment they go live. + +--- + +## 9. Invariants & Business Rules + +| # | Rule | Enforcement | +|---|------|-------------| +| 1 | Schema must be valid JSON Schema with explicit `additionalProperties` | Application: meta-validation on create/update | +| 2 | Schema `name` + `version` unique per client | Database: unique constraint | +| 3 | Updated schema gets new ID and version; old version becomes `superseded` | Application: INSERT new row, UPDATE old status | +| 4 | Schema deletion blocked if documents reference it | Application: count check before retirement | +| 5 | Document's `custom_schema_id` is immutable once any extraction exists | Application: check in AssignSchema + DB trigger: `trg_prevent_schema_reassignment` | +| 6 | Custom metadata must conform to assigned schema | Application: JSON Schema validation on write | +| 7 | All custom metadata versions for a document use same schema ID | Application: consistency check on write + DB trigger: `trg_enforce_consistent_schema_id` | +| 8 | Schema must belong to same client as document | Application: cross-reference check + DB trigger: `trg_validate_schema_client_match` | +| 9 | Only `active` schemas can be assigned to new documents | Application: status check | +| 10 | Custom metadata is versioned (no in-place updates) | Database: version column + unique constraint | +| 11 | Schema definition must be <= 64KB | Application: size check + Database: CHECK constraint | +| 12 | Schema management requires `super_admin` role (NOT `user_admin`) | Permit.io: role-based access on new `super-admin` resource; `user_admin` is explicitly NOT granted this resource | +| 13 | No query/filter on custom metadata values | By design: not implemented (see Principle 9) | +| 14 | A document uses EITHER legacy extractions OR custom metadata, never both | Application: mutual exclusivity checks in both services + DB trigger: `trg_prevent_schema_reassignment` | +| 15 | Legacy field extraction writes rejected if document has `custom_schema_id` set | Application: guard in `FieldExtractionService` | +| 16 | Custom schema assignment rejected if document has legacy extractions | Application: guard in `AssignSchema` + DB trigger | +| 17 | Metadata payload must be <= 1MB | Application: size check (`MaxMetadataPayloadBytes`) | +| 18 | Bulk folder assignment capped at 10,000 documents | Application: count check (`MaxBulkAssignDocuments`), returns 422 if exceeded | +| 19 | Schema assignment (single doc + bulk folder) requires `super_admin` role | Permit.io: endpoints under `/super-admin/` prefix map to `super-admin` resource; `user_admin` is NOT granted | +| 20 | Delete cascade must clean up custom metadata and schema bindings | Application: updated `DeleteDocumentCascade` + `deleteClientDependencies` (Section 8.5) | +| 21 | Metadata read/write is available to `client_user`, `user_admin`, `super_admin` (not `auditor` for writes) | Permit.io: `custom-metadata` resource grants on each role per Section 5.3 | + +--- + +## 10. Relationship to Existing Field Extraction System + +The existing static field extraction system (`/field-extractions`) and the new custom schema system (`/super-admin/custom-schemas` + `/custom-metadata`) are **mutually exclusive per document**: + +- A document uses EITHER the legacy static field extraction system OR the custom schema system +- A document CANNOT have both legacy field extractions AND custom metadata +- Assigning a custom schema to a document that already has legacy extractions returns `409 Conflict` +- Writing a legacy field extraction to a document that has a custom schema assigned returns `409 Conflict` +- Documents without a custom schema (the default) continue to use the legacy system unchanged +- There is no migration path from legacy to custom for individual documents. If a document needs custom metadata, it should not have legacy extractions written to it first. + +**Rationale**: This prevents ambiguity about which system is "authoritative" for a document's metadata. With mutual exclusivity, there is exactly one source of truth per document. This is simpler to reason about, simpler to query, and avoids the data governance problem of conflicting data in two stores. + +**No breaking changes** to the existing API or database schema for documents that do not opt into custom schemas. The existing `/field-extractions` endpoints remain on the `field-extractions` resource and are unaffected by the new `super-admin` resource. + +--- + +## 11. Implementation Order + +| Phase | Work | Dependencies | +|-------|------|--------------| +| 1 | Database migrations (enum, tables, view, document column, triggers) | None | +| 2 | SQLC queries + `task generate` | Phase 1 | +| 3 | Schema validator component (`internal/customschema/validator.go`) | JSON Schema library added to go.mod | +| 4 | Service layer (`internal/customschema/service.go`) incl. bulk folder assignment + mutual exclusivity guards | Phase 2, 3 | +| 5a | Legacy field extraction guard (add check to existing `FieldExtractionService`) | Phase 2 | +| 5b | Delete cascade updates (`DeleteDocumentCascade`, `deleteClientDependencies`, new SQLC queries) | Phase 2 | +| 6 | OpenAPI spec updates (`SuperAdminSchemaService` + `CustomMetadataService` tags) | None (can parallel with 1-3) | +| 7a | Permit.io policy file updates (`permit_policies.yaml`: new `super-admin` + `custom-metadata` resources, role grants) | None (can parallel with 1-6) | +| 7b | Run permit setup tool against dev/uat/prod to provision new resources and role permissions | Phase 7a | +| 8 | API controllers + code generation (super_admin schema CRUD in `customschemas.go`, metadata CRUD in `custommetadata.go`) | Phase 4, 6 | +| 9 | Document endpoint modifications (`PATCH /super-admin/documents/{id}/schema`, `GET /document/{id}` enrichment) | Phase 4, 8 | +| 10 | Bulk folder schema assignment endpoint (`POST /super-admin/folders/{folderId}/assign-schema`) | Phase 4, 8 | +| 11 | Integration tests (including explicit role-based authorization tests per Section 12) | Phase 10, 7b | +| 12 | Documentation updates (`docs/ai.generated/`) incl. sample schemas, authorization matrix | Phase 11 | + +> **Ordering constraint**: Phase 7b (permit setup run) must complete in each environment before Phase 8 code is deployed to that environment. Otherwise the new endpoints will be rejected by Permit.io (fail-closed) until the setup runs. + +--- + +## 12. Testing Strategy + +- **Schema validation tests**: Valid and invalid JSON Schema documents, edge cases (empty schema, deeply nested, self-referential, oversized >64KB, missing `additionalProperties`) +- **Metadata validation tests**: Conforming and non-conforming payloads, all supported JSON Schema keywords +- **Service integration tests**: Full CRUD lifecycle using testcontainers (real PostgreSQL) +- **API integration tests**: HTTP-level tests for all new endpoints under both `/super-admin/*` and `/custom-metadata/*` +- **Invariant tests**: Schema immutability, cross-client isolation, deletion guards, size limits +- **DB trigger tests**: Verify triggers fire correctly when bypassing the service layer (direct SQL inserts/updates via testcontainers) +- **Schema status lifecycle tests**: `active` -> `superseded` on version create, `active` -> `retired` on delete, assignment rejected for non-`active` schemas +- **Mutual exclusivity tests**: Legacy extraction blocked when schema assigned, schema assignment blocked when legacy extractions exist, bulk folder assignment skips documents with legacy extractions +- **Bulk folder assignment tests**: Recursive assignment, skip behavior for locked docs and legacy docs, empty folders, deeply nested folder trees +- **Authorization tests (expanded for v2)**: + - `super_admin` CAN create, list, get, update, delete schemas, assign schemas to documents, assign schemas to folders + - `user_admin` CANNOT access any `/super-admin/*` endpoint (expect 403 on every method + path combination) + - `user_admin` CAN read and write custom metadata via `/custom-metadata` (no regression) + - `client_user` CANNOT access any `/super-admin/*` endpoint (expect 403) + - `client_user` CAN read and write custom metadata via `/custom-metadata` + - `auditor` CAN read (`GET`) `/super-admin/custom-schemas` and `/super-admin/custom-schemas/{schemaId}` but CANNOT POST/PUT/PATCH/DELETE + - `auditor` CAN read but CANNOT write `/custom-metadata` + - Each test should assert both the HTTP status code AND that the database state is unchanged on denial +- **Backward compatibility tests**: Existing field extraction endpoints continue to work unchanged for documents without custom schemas; existing `/admin/*` endpoints are unaffected by the new `super-admin` resource +- **Delete cascade tests**: Document hard-delete succeeds when document has custom metadata, client hard-delete succeeds when client has schemas and metadata, verify no orphaned rows remain +- **Metadata size limit tests**: Payloads at/above 1MB boundary, verify 400 response with clear message +- **Bulk assignment limit tests**: Folder trees exceeding `MaxBulkAssignDocuments`, verify 422 response +- **Schema version concurrency tests**: Concurrent PUT requests for same schema name produce distinct version numbers +- **Permit.io setup tool tests**: After running the setup tool against a disposable dev tenant, verify that the `super-admin` and `custom-metadata` resources exist with the expected actions and that each role has exactly the grants defined in Section 5.3 + +--- + +## 13. Resolved Design Decisions + +| # | Question | Decision | +|---|----------|----------| +| 1 | Schema size limits | 64KB cap via `MaxSchemaDefinitionBytes` constant (tunable) | +| 2 | Schema backward compatibility | Not required. New version = new ID. Old docs keep old schema. | +| 3 | Bulk operations | Yes. `POST /super-admin/folders/{folderId}/assign-schema` assigns recursively through folder tree. | +| 4 | Custom metadata search/filter | **Not supported.** No GIN indexes, no query API. Metadata is opaque storage. | +| 5 | Schema templates | No system templates. Documentation will include sample schemas covering all supported types. | +| 6 | Existing field migration path | **Out of scope.** Static fields and custom schemas are mutually exclusive per document. | +| 7 | Endpoint naming/placement | Schema CRUD under `/super-admin/custom-schemas` (requires the `super_admin` role on the new `super-admin` Permit.io resource). Metadata CRUD under `/custom-metadata` (regular `client_user` access; also granted to `user_admin` and `super_admin`). | +| 8 | Legacy/custom coexistence | **Mutually exclusive.** A document uses EITHER legacy OR custom, never both. Simplifies data governance and prevents source-of-truth ambiguity. | +| 9 | Schema status model | Three-state enum (`active`/`superseded`/`retired`) instead of boolean. Distinguishes automatic version succession from explicit admin retirement. | +| 10 | DB-level invariant enforcement | Yes. Three database triggers enforce schema lock, client ownership match, and consistent schema_id as defense-in-depth alongside application-layer checks. | +| 11 | `additionalProperties` in schemas | Required to be explicitly present (not necessarily `false`). Prevents the common mistake of omitting it and silently accepting arbitrary extra fields. | +| 12 | Metadata table design | Single table with version column (simpler than the legacy two-table pattern). Sufficient for our concurrency needs. Can split later if needed. | +| 13 | Schema assignment endpoint path | Under `/super-admin/documents/{id}/schema` (NOT `/admin/document/{id}/schema` as in v1 and NOT `/document/{id}/schema`). The `/super-admin` first segment maps to the new `super-admin` Permit.io resource which only `super_admin` holds. Placing it under `/admin` would also grant `user_admin`; placing it under `/document` would also grant `client_user`. | +| 14 | Metadata payload size limit | 1MB cap via `MaxMetadataPayloadBytes` constant. Prevents unbounded JSONB blobs when schemas allow `additionalProperties: true`. | +| 15 | Bulk assignment document limit | 10,000 documents via `MaxBulkAssignDocuments` constant. Prevents unbounded transactions and lock contention. | +| 16 | Schema_id consistency trigger | DB trigger `trg_enforce_consistent_schema_id` on `document_custom_metadata` INSERT enforces same schema across all metadata versions for a document (defense-in-depth for Invariant 7). | +| 17 | **v2**: Who can administer schemas? | **Only `super_admin`.** v1 unintentionally granted `user_admin` access by placing endpoints under `/admin/*`. v2 moves them to `/super-admin/*` and introduces a new `super-admin` Permit.io resource that only `super_admin` holds. `user_admin` retains its existing capabilities (user creation, field extractions, documents/folders, admin endpoints) but has no schema administration rights. | +| 18 | **v2**: Why a new resource instead of path-specific rules? | The Permit.io middleware resolves resources from the first path segment. Adding path-specific overrides would require middleware code changes. Adding a new first-segment resource (`super-admin`) is a pure configuration change and keeps the middleware simple. | +| 19 | **v2**: Does the service layer enforce role checks? | No. Authorization lives in the HTTP/Permit.io layer. The service layer (`internal/customschema/`) is role-agnostic and remains easy to test without fake identity contexts. | + +--- + +## 14. v1 -> v2 Change Log + +For reviewers comparing to `plans/mutable.metadata.plan.combo.md`: + +| Area | v1 | v2 | +|------|----|----| +| Schema CRUD path | `/admin/custom-schemas` | `/super-admin/custom-schemas` | +| Single-doc schema assignment path | `/admin/document/{id}/schema` | `/super-admin/documents/{id}/schema` | +| Bulk folder assignment path | `/admin/folders/{folderId}/assign-schema` | `/super-admin/folders/{folderId}/assign-schema` | +| Permit.io resource for schema admin | `admin` (shared by `user_admin` and `super_admin`) | NEW `super-admin` (held only by `super_admin`) | +| `user_admin` schema CRUD | Allowed (unintentional) | Denied | +| `user_admin` schema assignment | Allowed (unintentional) | Denied | +| `user_admin` custom metadata read/write | Allowed | Allowed (unchanged) | +| `client_user` custom metadata read/write | Allowed | Allowed (unchanged) | +| `auditor` read schemas / metadata | Allowed (read-only) | Allowed (read-only, now via `super-admin:get` grant) | +| OpenAPI tag for schema admin | `AdminService` | NEW `SuperAdminSchemaService` | +| Service layer | Role-agnostic | Role-agnostic (unchanged) | +| Database schema, triggers, migrations | Unchanged | Unchanged | +| Validation strategy, size limits, constants | Unchanged | Unchanged | + +No database migration or data change is required to move from v1 to v2 -- this is strictly a routing and authorization change. If v1 were already deployed, the migration would be: (1) add the new Permit.io resource and grants, (2) add the new endpoints alongside the old ones, (3) point clients at the new paths, (4) remove the old paths once all clients have migrated. Since v1 is not deployed, v2 simply supersedes it. + +--- + +## Appendix A: Sample JSON Schemas + +These examples demonstrate all supported JSON Schema features and data types. They should be included in the API documentation. + +### A.1 Comprehensive Schema (All Supported Types) + +```json +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "document_title": { + "type": "string", + "minLength": 1, + "maxLength": 500, + "description": "Title of the document" + }, + "document_code": { + "type": "string", + "pattern": "^[A-Z]{3}-[0-9]{4}$", + "description": "Document code in format XXX-0000" + }, + "status": { + "type": "string", + "enum": ["draft", "review", "approved", "rejected", "archived"], + "description": "Current document status" + }, + "review_date": { + "type": "string", + "format": "date", + "description": "Date of last review (YYYY-MM-DD)" + }, + "created_timestamp": { + "type": "string", + "format": "date-time", + "description": "Creation timestamp (ISO 8601)" + }, + "page_count": { + "type": "integer", + "minimum": 1, + "maximum": 10000, + "description": "Total number of pages" + }, + "confidence_score": { + "type": "number", + "minimum": 0.0, + "maximum": 1.0, + "description": "Extraction confidence (0.0 to 1.0)" + }, + "is_verified": { + "type": "boolean", + "description": "Whether the extraction has been human-verified" + }, + "tags": { + "type": "array", + "items": { "type": "string", "maxLength": 50 }, + "minItems": 0, + "maxItems": 20, + "description": "Classification tags" + }, + "numeric_values": { + "type": "array", + "items": { "type": "number" }, + "maxItems": 100, + "description": "Extracted numeric values" + }, + "contact_info": { + "type": "object", + "properties": { + "name": { "type": "string", "maxLength": 200 }, + "email": { "type": "string", "format": "email" }, + "phone": { "type": "string", "pattern": "^\\+?[0-9\\-\\s()]+$" } + }, + "required": ["name"], + "additionalProperties": false, + "description": "Primary contact information" + } + }, + "required": ["document_title", "status"], + "additionalProperties": false +} +``` + +### A.2 Aircraft Engineering Schema (Domain Example) + +```json +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "aircraft_model": { + "type": "string", + "maxLength": 100 + }, + "manufacturer": { + "type": "string", + "enum": ["Boeing", "Airbus", "Embraer", "Bombardier", "Other"] + }, + "certification_date": { + "type": "string", + "format": "date" + }, + "max_altitude_ft": { + "type": "integer", + "minimum": 0, + "maximum": 100000 + }, + "max_range_nm": { + "type": "number", + "minimum": 0 + }, + "engine_count": { + "type": "integer", + "minimum": 1, + "maximum": 12 + }, + "engine_type": { + "type": "string", + "enum": ["turbofan", "turboprop", "turbojet", "piston", "electric"] + }, + "wingspan_meters": { + "type": "number", + "minimum": 0, + "maximum": 100 + }, + "is_pressurized": { + "type": "boolean" + }, + "applicable_regulations": { + "type": "array", + "items": { + "type": "string", + "pattern": "^(FAR|EASA|CAAC)-[0-9A-Z\\.]+$" + }, + "maxItems": 50 + } + }, + "required": ["aircraft_model", "manufacturer"], + "additionalProperties": false +} +``` + +### A.3 Auto Engineering Schema (Domain Example) + +```json +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "vehicle_make": { + "type": "string", + "maxLength": 100 + }, + "vehicle_model": { + "type": "string", + "maxLength": 100 + }, + "model_year": { + "type": "integer", + "minimum": 1900, + "maximum": 2100 + }, + "vin": { + "type": "string", + "pattern": "^[A-HJ-NPR-Z0-9]{17}$", + "description": "17-character Vehicle Identification Number" + }, + "engine_displacement_liters": { + "type": "number", + "minimum": 0, + "maximum": 20 + }, + "horsepower": { + "type": "integer", + "minimum": 0 + }, + "transmission_type": { + "type": "string", + "enum": ["manual", "automatic", "cvt", "dct", "ev"] + }, + "is_electric": { + "type": "boolean" + }, + "safety_ratings": { + "type": "object", + "properties": { + "nhtsa_overall": { "type": "integer", "minimum": 1, "maximum": 5 }, + "iihs_rating": { "type": "string", "enum": ["Poor", "Marginal", "Acceptable", "Good", "Good+"] } + }, + "additionalProperties": false + }, + "recall_ids": { + "type": "array", + "items": { "type": "string", "maxLength": 20 }, + "maxItems": 100 + } + }, + "required": ["vehicle_make", "vehicle_model", "model_year"], + "additionalProperties": false +} +``` + +### Supported JSON Schema Data Types Summary + +| JSON Schema Type | Go Equivalent | Example Constraint Keywords | +|-----------------|---------------|---------------------------| +| `string` | `string` | `minLength`, `maxLength`, `pattern`, `format`, `enum` | +| `integer` | `int64` | `minimum`, `maximum` | +| `number` | `float64` | `minimum`, `maximum` | +| `boolean` | `bool` | (none) | +| `array` | `[]T` | `items`, `minItems`, `maxItems` | +| `object` | `map/struct` | `properties`, `required`, `additionalProperties` | + +**Supported `format` values**: `date`, `date-time`, `email`, `uri`, `uuid` + + +## Implemnentation journal +In order to be able to have another agent pick up the work in progress, all completed work and milestones must be journaled to the ./journals/implement_mutableMetadata.md + +Each time a milestone or signficant part of a milestone is completed there needs to be a date/time stamped journal entry showing the item is done. So that a new agent taking over will know exactly where we are in the process and can resume the work. + +At any give time I should be able to clear your context and have you resume your work based on this document, the code (git diffs) and the journal of what is completed. + +If we run into any issues where Q has to be consulted about an issue that needs a decision , then Qs answer should also be written to the journal along with the question. diff --git a/plans/mutable.metadata.plan.combo.v3.md b/plans/mutable.metadata.plan.combo.v3.md new file mode 100644 index 00000000..4048ec5f --- /dev/null +++ b/plans/mutable.metadata.plan.combo.v3.md @@ -0,0 +1,1729 @@ +# Mutable Metadata & Per-Client Custom Schemas - Combined Design Plan (v3) + +## Status: DRAFT - Combined Claude + Codex + Jay's recommendations (2026-03-24, rev 2026-03-31 post-review, rev 2026-04-10: schema administration restricted to `super_admin` role only, rev 2026-04-13: add reset-metadata escape hatch) + +**Lineage**: This plan was informed by two original detailed specs done adversarily. One by claude Opus 4.6 and one by Codex 5.4 (xhigh) based on human written requirements. Each was human reviewed for common items and conflicting items. Where there were conflicts the human was the final decision maker. This resulting hybrid document represents an improved design over each of the source documents. + +**v2 change summary (2026-04-10)**: In v1 all mutable-metadata administrative endpoints lived under `/admin/*`. Because the Permit.io middleware maps resources from the first path segment and both `user_admin` and `super_admin` hold full permissions on the `admin` resource, v1 exposed schema CRUD and schema assignment to the lesser (client-scoped) `user_admin` role. v2 moves every schema administration endpoint onto a new `/super-admin/*` path prefix backed by a new Permit.io resource named `super-admin` that only the `super_admin` role can access. User-facing custom metadata read/write endpoints (`/custom-metadata/*`) are unchanged. The underlying service layer, database schema, triggers, and validation strategy are unchanged -- this is strictly an authorization and routing change. + +**v3 change summary (2026-04-13)**: Adds a single new super_admin endpoint, `POST /super-admin/documents/{id}/reset-metadata`, to support upgrading a document to a newer schema version when the document already has custom metadata written. Today, once custom metadata exists, Trigger 1 (`trg_prevent_schema_reassignment`) freezes `documents.custom_schema_id` permanently, and Trigger 3 (`trg_enforce_consistent_schema_id`) forbids new metadata versions from referencing a different `schema_id`. The practical effect is that documents cannot move to a revised schema even when the new schema is a strict additive superset (e.g., one new optional field). v3 adds an explicit opt-in path: reset-metadata wipes all `document_custom_metadata` rows for the document and nulls `documents.custom_schema_id` in a single transaction, after which the existing `PATCH /super-admin/documents/{id}/schema` and `POST /custom-metadata` endpoints can bind a new schema and re-enter values. **No new triggers, no changes to any existing trigger, no changes to existing schemas or endpoints.** The escape hatch is deliberately destructive (metadata version history for the document is lost) so the invariant "schema is frozen once metadata exists" remains true -- you are explicitly opting out of "metadata exists" first. Everything else in this plan is unchanged from v2. + +--- + +## 1. Problem Statement + +Today, all field extraction data is stored in a rigid 3-table schema with ~130 statically-defined columns (19 single-value fields + 112 array fields). Every client shares the same field definitions. This means: + +- Clients cannot define domain-specific fields (e.g., "aircraft engineering" vs. "auto engineering" document classes) +- Adding new fields requires database migrations and code changes across the full stack +- The system cannot accommodate varying data shapes per document class within a single client + +**Goal**: Allow each client to curate their own collections of document schemas, where each schema defines the custom fields (with types and constraints) that a document's metadata must conform to. Administration of these schemas (create, update, retire, list, assign to documents/folders) is a platform-level responsibility and must be restricted to the `super_admin` role, not delegated to the lower-privileged `user_admin` role. + +--- + +## 2. Current Architecture Summary + +### 2.1 Existing Tables + +``` +documentFieldExtractions -- 19 single-value fields (1:1 per document) +documentFieldExtractionVersions -- Version tracking with row-level locking +documentFieldExtractionArrayFields -- 112 array fields (1:N per extraction) +``` + +### 2.2 Existing API Endpoints + +| Method | Path | Description | +|--------|------|-------------| +| POST | `/field-extractions` | Create versioned extraction (singleFields + arrayFields) | +| GET | `/field-extractions` | Get current (latest version) extraction for a document | +| GET | `/field-extractions/version` | Get extraction by specific version | +| GET | `/field-extractions/history` | List all versions for a document | + +### 2.3 Key Relationships + +- `documents.id` -> `documentFieldExtractions.documentId` (1:N via versions) +- `documentFieldExtractions.id` -> `documentFieldExtractionArrayFields.fieldExtractionId` (1:N) +- `documentFieldExtractions.id` -> `documentFieldExtractionVersions.fieldExtractionId` (1:N) +- Documents belong to clients via `documents.clientId` +- No per-client customization of field definitions exists today + +### 2.4 Current Role Model (Relevant to This Plan) + +Two admin-class roles exist today, both configured in `cmd/auth_related/permit.setup/permit_policies.yaml`: + +- **`super_admin`**: Platform administrator. Holds full permissions across every resource (`admin`, `client`, `clients`, `document`, `documents`, `folders`, etc.). Intended for AArete-internal operators. +- **`user_admin`**: Client-scoped administrator. Primarily used for creating new client users via the admin user tool. Holds full permissions on the `admin` resource but intentionally does **not** hold permissions on `client`, `clients`, or `document` at the resource level. + +Because the middleware extracts the resource from the first path segment (`/admin/foo` -> resource `admin`), any endpoint placed under `/admin/*` is accessible to both `super_admin` and `user_admin`. v1 of this plan placed schema administration under `/admin/*` and therefore exposed schema CRUD and assignment to `user_admin`. v2 corrects this. + +--- + +## 3. Design Principles + +1. **Additive, not destructive** - The existing static field extraction system continues to work unchanged for documents that do not opt into custom schemas. Custom schemas are an additive capability. +2. **Mutual exclusivity** - A document uses EITHER the legacy static field extraction system OR the custom schema system, never both. This prevents ambiguity about which system is authoritative for a document's metadata. Once a document is assigned a custom schema, the legacy field extraction endpoints reject writes for that document, and vice versa. +3. **Schema-as-data** - Schemas are stored as JSON Schema documents in the database, not as DDL. No migrations needed when clients add/modify schemas. +4. **Immutable schema versions** - When a schema is updated, a new version (with new ID) is created. Old versions remain for historical reference. No backward compatibility checking is required between versions since each version gets a distinct ID. +5. **Schema binding is permanent** - Once custom metadata is written to a document with a schema ID, that schema ID cannot be changed on the document. This guarantees data integrity. +6. **JSON blob storage** - Custom metadata is stored as JSONB in PostgreSQL, validated against the schema at write time. This avoids dynamic column creation. +7. **Client isolation** - Schemas are scoped to clients. A client cannot reference another client's schema. +8. **Size limits** - Schema definitions are capped at 64KB (`MaxSchemaDefinitionBytes = 65536`). Metadata payloads are capped at 1MB (`MaxMetadataPayloadBytes = 1048576`). Bulk folder assignments are capped at 10,000 documents (`MaxBulkAssignDocuments = 10000`). All are tunable constants. +9. **No JSONB search/filter** - The system does NOT support querying or filtering documents by custom metadata values. Custom metadata is opaque storage validated on write. If search is needed in the future, it would require GIN indexes on the JSONB columns and a dedicated query API (separate phase). +10. **`super_admin`-scoped schema management** - Schema CRUD operations, single-document schema assignment, and bulk folder-level schema assignment live under `/super-admin/*` and require the `super_admin` role. The lesser `user_admin` role does NOT have access to schema administration. Custom metadata read/write on documents (`/custom-metadata/*`) is available to regular `client_user` users and to both admin roles. +11. **Defense-in-depth** - Critical invariants (schema lock after first extraction, client/schema ownership match, schema_id consistency across metadata versions) are enforced at BOTH the application layer AND the database layer via triggers. The app-layer checks provide better error messages; the DB triggers are safety nets against bypass. +12. **No migration from static fields** - There is no migration path from the existing static field extraction data into the custom schema system. They are mutually exclusive per document. Out of scope. + +--- + +## 4. Database Schema Design + +### 4.1 New Type: `schema_status_type` + +A three-state enum that distinguishes between different reasons a schema is no longer assignable: + +```sql +CREATE TYPE schema_status_type AS ENUM ('active', 'superseded', 'retired'); +``` + +- **`active`**: The schema version is current and can be assigned to new documents. +- **`superseded`**: A newer version of this schema (same `name`) was created. The schema is still valid for documents already using it, but should not be assigned to new documents. Set automatically when a new version is created via `PUT`. +- **`retired`**: An admin explicitly disabled this schema version. Functionally similar to `superseded` but indicates a deliberate administrative action rather than an automatic version succession. + +### 4.2 New Table: `client_metadata_schemas` + +Stores client-defined JSON Schema documents. Each row is an immutable version of a schema definition. + +```sql +CREATE TABLE client_metadata_schemas ( + id uuid PRIMARY KEY DEFAULT uuid_generate_v7(), + client_id varchar(255) NOT NULL REFERENCES clients(clientId), + name varchar(255) NOT NULL, + description text, + schema_def jsonb NOT NULL, -- The JSON Schema document (max 64KB enforced at app layer) + version int NOT NULL DEFAULT 1, -- Auto-incremented per (client_id, name) + status schema_status_type NOT NULL DEFAULT 'active', + created_at timestamptz NOT NULL DEFAULT NOW(), + created_by varchar(255) NOT NULL, + + CONSTRAINT uq_client_schema_name_version UNIQUE (client_id, name, version), + CONSTRAINT chk_schema_def_size CHECK (pg_column_size(schema_def) <= 70000) -- ~68KB to account for JSONB overhead; app layer enforces 64KB on raw JSON +); + +CREATE INDEX idx_cms_client_id ON client_metadata_schemas(client_id); +CREATE INDEX idx_cms_client_name ON client_metadata_schemas(client_id, name); +CREATE INDEX idx_cms_client_status ON client_metadata_schemas(client_id, status); +``` + +> **Note**: The 64KB limit is enforced at the application layer (Go constant `MaxSchemaDefinitionBytes = 65536`) by checking `len(rawJSON)`. The database CHECK constraint uses `pg_column_size()` which measures on-disk JSONB storage (includes header overhead), so it is set slightly higher (~68KB) as a safety net. The app-layer check provides the user-facing 64KB limit and a better error message. + +**Design decisions:** + +- **`name` + `version`** scoped to client: Allows human-friendly naming ("aircraft-engineering-v3") with automatic versioning. When a schema is "updated," a new row is inserted with `version = max(version) + 1` for that `(client_id, name)`. +- **`schema_def` is JSONB**: Stores the full JSON Schema document. PostgreSQL can index into it if needed later. +- **`status` enum**: Three-state lifecycle. `superseded` is set automatically when a newer version is created. `retired` is set explicitly by admin action (soft-delete). Both states prevent assignment to new documents but allow existing documents to continue using the schema for validation. This is richer than a boolean `is_active` because it distinguishes "replaced by v2" from "admin decided this is bad." +- **`id` is the schema version ID**: This is what documents reference. Each update creates a new `id`. The `(client_id, name, version)` tuple provides the human-readable lineage. + +### 4.3 New Table: `document_custom_metadata` + +Stores the actual custom metadata for documents, validated against the referenced schema. + +```sql +CREATE TABLE document_custom_metadata ( + id uuid PRIMARY KEY DEFAULT uuid_generate_v7(), + document_id uuid NOT NULL REFERENCES documents(id), + schema_id uuid NOT NULL REFERENCES client_metadata_schemas(id), + metadata jsonb NOT NULL, -- The actual custom field values + version int NOT NULL DEFAULT 1, -- Versioned like field extractions + created_at timestamptz NOT NULL DEFAULT NOW(), + created_by varchar(255) NOT NULL, + + CONSTRAINT uq_doc_custom_metadata_version UNIQUE (document_id, version) +); + +CREATE INDEX idx_dcm_document_id ON document_custom_metadata(document_id); +CREATE INDEX idx_dcm_schema_id ON document_custom_metadata(schema_id); +CREATE INDEX idx_dcm_doc_version_desc ON document_custom_metadata(document_id, version DESC); -- Enables efficient DISTINCT ON in current_document_custom_metadata view +``` + +**Design decisions:** + +- **Single table with version column**: We use one table (rather than separate extraction + version tables like the legacy pattern) because the simpler design is sufficient. The legacy two-table pattern exists to support row-level locking on the version counter independently of the data row. +- **Concurrency strategy for version assignment**: `SELECT ... FOR UPDATE` on the max-version row correctly serializes the *second and subsequent* writes for a document, but it does **not** serialize the *first* write -- there is no row to lock yet, so two concurrent first-writers both see `max(version) = 0`, both try to INSERT `version = 1`, and one fails with a `UNIQUE (document_id, version)` violation. v3 handles this explicitly by locking the parent `documents` row with `SELECT id FROM documents WHERE id = $1 FOR UPDATE` *before* reading the max version. The document row is guaranteed to exist (enforced by the service layer's existence check and by the FK) so this lock serializes the first write as well as every subsequent write. As a belt-and-suspenders measure, the service layer also retries once on a `UNIQUE (document_id, version)` violation; if the retry still conflicts the request returns `500`. If this proves insufficient under load, splitting into two tables is a backward-compatible change. +- **`schema_id` is immutable per document**: Enforced at both the application layer and the database trigger (see Section 4.7). All versions of custom metadata for a document must reference the same schema ID. +- **`metadata` is JSONB**: Validated against `client_metadata_schemas.schema_def` at write time. Stored as a single blob for simplicity and query flexibility. + +### 4.4 Alteration: `documents` Table + +Add an optional schema binding column: + +```sql +ALTER TABLE documents + ADD COLUMN custom_schema_id uuid NULL REFERENCES client_metadata_schemas(id); + +CREATE INDEX idx_documents_custom_schema_id ON documents(custom_schema_id); +``` + +**Behavior:** + +- `NULL` = document uses the legacy static field extraction system (default, backward-compatible) +- `NOT NULL` = document is opted into the custom schema system; legacy field extraction endpoints will reject writes for this document +- Once set AND custom metadata has been written, this field becomes immutable (enforced at app layer AND DB trigger) +- Can be set before metadata is written (pre-binding) and changed freely until first metadata write +- Must reference a schema owned by the same client as the document (cross-client reference check enforced at app layer AND DB trigger) +- Cannot be set on a document that already has legacy field extractions (returns 409 Conflict) + +### 4.5 New View: `current_document_custom_metadata` + +```sql +CREATE VIEW current_document_custom_metadata AS +SELECT DISTINCT ON (document_id) + id, + document_id, + schema_id, + metadata, + version, + created_at, + created_by +FROM document_custom_metadata +ORDER BY document_id, version DESC; +``` + +### 4.6 Entity Relationship Diagram + +``` +clients + | + |-- 1:N --> client_metadata_schemas (per-client schema definitions) + | | + | |-- referenced by --> documents.custom_schema_id + | |-- referenced by --> document_custom_metadata.schema_id + | + |-- 1:N --> documents + | + |-- 1:N --> document_custom_metadata (versioned JSONB blobs) + | [MUTUALLY EXCLUSIVE with legacy extractions] + |-- 1:N --> documentFieldExtractions (existing static fields) + [MUTUALLY EXCLUSIVE with custom metadata] +``` + +### 4.7 Database Triggers (Defense-in-Depth) + +Three triggers enforce critical invariants at the database level, providing a safety net regardless of which code path modifies the data. + +#### Trigger 1: Prevent schema reassignment after first extraction + +Fires on `documents` BEFORE UPDATE of `custom_schema_id`. Prevents changes if the document has any extraction data (legacy or custom). + +```sql +CREATE OR REPLACE FUNCTION trg_prevent_schema_reassignment() +RETURNS TRIGGER AS $$ +BEGIN + -- Only fire if custom_schema_id is actually changing + IF OLD.custom_schema_id IS DISTINCT FROM NEW.custom_schema_id THEN + -- Check for existing custom metadata + IF EXISTS ( + SELECT 1 FROM document_custom_metadata + WHERE document_id = NEW.id + LIMIT 1 + ) THEN + RAISE EXCEPTION 'Cannot change custom_schema_id on document % because custom metadata already exists', NEW.id; + END IF; + + -- Check for existing legacy field extractions + IF EXISTS ( + SELECT 1 FROM "documentFieldExtractionVersions" + WHERE "documentId" = NEW.id + LIMIT 1 + ) THEN + RAISE EXCEPTION 'Cannot change custom_schema_id on document % because legacy field extractions already exist', NEW.id; + END IF; + END IF; + + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER trg_documents_prevent_schema_reassignment + BEFORE UPDATE OF custom_schema_id ON documents + FOR EACH ROW + EXECUTE FUNCTION trg_prevent_schema_reassignment(); +``` + +#### Trigger 2: Validate client/schema ownership match + +Fires on `documents` BEFORE INSERT OR UPDATE of `custom_schema_id`. Ensures the referenced schema belongs to the same client as the document. + +```sql +CREATE OR REPLACE FUNCTION trg_validate_schema_client_match() +RETURNS TRIGGER AS $$ +DECLARE + schema_client_id varchar(255); +BEGIN + -- Only check when custom_schema_id is non-null + IF NEW.custom_schema_id IS NOT NULL THEN + SELECT client_id INTO schema_client_id + FROM client_metadata_schemas + WHERE id = NEW.custom_schema_id; + + IF schema_client_id IS NULL THEN + RAISE EXCEPTION 'Schema % does not exist', NEW.custom_schema_id; + END IF; + + IF schema_client_id != NEW."clientId" THEN + RAISE EXCEPTION 'Schema % belongs to client %, but document % belongs to client %', + NEW.custom_schema_id, schema_client_id, NEW.id, NEW."clientId"; + END IF; + END IF; + + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER trg_documents_validate_schema_client + BEFORE INSERT OR UPDATE OF custom_schema_id ON documents + FOR EACH ROW + EXECUTE FUNCTION trg_validate_schema_client_match(); +``` + +#### Trigger 3: Enforce consistent schema_id across metadata versions + +Fires on `document_custom_metadata` BEFORE INSERT. Ensures all metadata versions for a document reference the same schema ID. This is the database-level safety net for Invariant 7. + +```sql +CREATE OR REPLACE FUNCTION trg_enforce_consistent_schema_id() +RETURNS TRIGGER AS $$ +DECLARE + existing_schema_id uuid; +BEGIN + SELECT schema_id INTO existing_schema_id + FROM document_custom_metadata + WHERE document_id = NEW.document_id + LIMIT 1; + + IF existing_schema_id IS NOT NULL AND existing_schema_id != NEW.schema_id THEN + RAISE EXCEPTION 'All custom metadata for document % must use schema %, but got %', + NEW.document_id, existing_schema_id, NEW.schema_id; + END IF; + + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER trg_dcm_enforce_consistent_schema + BEFORE INSERT ON document_custom_metadata + FOR EACH ROW + EXECUTE FUNCTION trg_enforce_consistent_schema_id(); +``` + +> **Note**: These triggers duplicate checks that also exist in the application service layer. This is intentional. The app-layer checks return structured HTTP error responses (400/409). The triggers are a last-resort safety net that fires even if a future code path, migration script, or admin query bypasses the service layer. + +--- + +## 5. API Design + +### 5.1 Schema Management Endpoints (`super_admin` only) + +All schema CRUD endpoints live under the `/super-admin/custom-schemas` path prefix. The first path segment `super-admin` maps (via the existing first-segment resource extraction rule in the Permit.io middleware) to a new Permit.io resource named `super-admin`. Only the `super_admin` role holds permissions on this resource. The `user_admin` role does NOT have access. + +These endpoints are grouped under a new OpenAPI tag `SuperAdminSchemaService` (distinct from the existing `AdminService`) to make the authorization boundary visible in the generated client and in Swagger. + +#### `POST /super-admin/custom-schemas` + +Create a new schema for a client. + +**Request:** +```json +{ + "clientId": "acme-corp", + "name": "aircraft-engineering", + "description": "Custom fields for aircraft engineering documents", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "aircraft_model": { + "type": "string", + "maxLength": 100 + }, + "certification_date": { + "type": "string", + "format": "date" + }, + "max_altitude_ft": { + "type": "number", + "minimum": 0, + "maximum": 100000 + }, + "engine_count": { + "type": "integer", + "minimum": 1, + "maximum": 12 + } + }, + "required": ["aircraft_model"], + "additionalProperties": false + }, + "createdBy": "admin@acme.com" +} +``` + +**Response (201):** +```json +{ + "id": "019577a3-...", + "clientId": "acme-corp", + "name": "aircraft-engineering", + "description": "Custom fields for aircraft engineering documents", + "schema": { ... }, + "version": 1, + "status": "active", + "createdAt": "2026-03-23T12:00:00Z", + "createdBy": "admin@acme.com" +} +``` + +**Validation:** +- The `schema` field MUST be a valid JSON Schema document (validated server-side using a JSON Schema meta-validator) +- The `schema` field must be <= 64KB in size (returns 400 with message referencing the limit) +- Root `type` must be `"object"` +- Root `additionalProperties` MUST be explicitly present (either `true` or `false`). If omitted, the server returns 400 with a message explaining that `additionalProperties` must be explicitly declared. This prevents the common mistake of omitting it and inadvertently accepting arbitrary extra fields. +- `name` must be unique within the client (for active schemas at the same version) + +#### `PUT /super-admin/custom-schemas/{schemaId}` + +Update an existing schema. Creates a new version with a new ID. The old version remains intact and its status is set to `superseded`. No backward compatibility check is performed -- the new version is treated as an independent schema that happens to share a name lineage. + +**Request:** +```json +{ + "description": "Updated aircraft engineering fields (added wingspan)", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "aircraft_model": { "type": "string", "maxLength": 100 }, + "certification_date": { "type": "string", "format": "date" }, + "max_altitude_ft": { "type": "number", "minimum": 0, "maximum": 100000 }, + "engine_count": { "type": "integer", "minimum": 1, "maximum": 12 }, + "wingspan_meters": { "type": "number", "minimum": 0 } + }, + "required": ["aircraft_model"], + "additionalProperties": false + }, + "createdBy": "admin@acme.com" +} +``` + +**Response (201):** +```json +{ + "id": "019577b4-...", + "clientId": "acme-corp", + "name": "aircraft-engineering", + "version": 2, + "status": "active", + "previousVersionId": "019577a3-...", + ... +} +``` + +**Behavior:** +- The `schemaId` in the path identifies the schema to "update" (really: create a new version of) +- The new version inherits the `name` from the previous version +- The old version's status is set to `superseded` (it remains valid for existing documents but should not be assigned to new ones) +- Returns the newly created schema version +- The `schema` field in the request body follows the same validation rules as `POST` (including the `additionalProperties` requirement) +- **Concurrency**: Uses `SELECT ... FOR UPDATE` on `client_metadata_schemas WHERE client_id = $1 AND name = $2` to lock existing versions before reading `max(version)`. This prevents two concurrent PUTs from computing the same next version number. The unique constraint `uq_client_schema_name_version` is the final safety net. + +#### `GET /super-admin/custom-schemas` + +List all schemas for a client. + +**Query Parameters:** +- `clientId` (required): The client whose schemas to list +- `name` (optional): Filter by schema name +- `status` (optional): Filter by status (`active`, `superseded`, `retired`). Default: return only `active`. +- `includeAllVersions` (optional, default: false): Return all versions or just latest per name +- `limit` (optional, default: 50, max: 200): Maximum number of results to return +- `offset` (optional, default: 0): Number of results to skip for pagination + +**Response (200):** +```json +{ + "schemas": [ + { + "id": "019577b4-...", + "clientId": "acme-corp", + "name": "aircraft-engineering", + "description": "...", + "version": 2, + "status": "active", + "documentCount": 45, + "canDelete": false, + "createdAt": "...", + "createdBy": "..." + }, + { + "id": "019577c1-...", + "clientId": "acme-corp", + "name": "auto-engineering", + "description": "...", + "version": 1, + "status": "active", + "documentCount": 0, + "canDelete": true, + "createdAt": "...", + "createdBy": "..." + } + ] +} +``` + +**Notes:** +- `documentCount` shows how many documents reference each schema version. Computed via a subquery or join. +- `canDelete` is `true` when `documentCount == 0` AND no `document_custom_metadata` rows reference the schema. This saves the client a round-trip to check before attempting delete. + +#### `GET /super-admin/custom-schemas/{schemaId}` + +Get a specific schema by ID (returns full schema definition). + +**Response (200):** +```json +{ + "id": "019577a3-...", + "clientId": "acme-corp", + "name": "aircraft-engineering", + "description": "...", + "schema": { ... }, + "version": 1, + "status": "superseded", + "documentCount": 45, + "canDelete": false, + "createdAt": "...", + "createdBy": "..." +} +``` + +#### `DELETE /super-admin/custom-schemas/{schemaId}` + +Retire a schema. Only succeeds if no documents reference it. + +**Response (204):** No content on success. + +**Response (409 Conflict):** +```json +{ + "error": "Schema is in use by 45 documents and cannot be deleted" +} +``` + +**Behavior:** +- Sets `status = 'retired'` +- Fails with 409 if any documents reference this schema ID (via `documents.custom_schema_id` or `document_custom_metadata.schema_id`) +- Retired schemas still serve validation for existing documents but cannot be assigned to new documents + +--- + +### 5.2 Document Schema Assignment Endpoints (`super_admin` only) + +Schema assignment endpoints also live under `/super-admin/*` so that the same first-segment resource extraction rule confines them to the `super-admin` Permit.io resource. Placing them under `/admin/*` (v1) or under `/document/*` would expose them to `user_admin` or `client_user` respectively, which we explicitly do not want. + +#### `PATCH /super-admin/documents/{id}/schema` + +Assign or change a custom schema on a single document. + +> **Path note**: The sub-segment uses plural `documents/{id}` (not `document/{id}` as in v1) to avoid any possible collision with the existing top-level `/document/{id}` resource path during code review or manual URL inspection. This has no authorization impact because the first segment (`super-admin`) is what the middleware uses to resolve the resource, but it makes the namespacing visually unambiguous. + +**Request:** +```json +{ + "customSchemaId": "019577a3-..." +} +``` + +**Response (200):** +```json +{ + "documentId": "...", + "customSchemaId": "019577a3-...", + "schemaName": "aircraft-engineering", + "schemaVersion": 1 +} +``` + +**Validation rules:** +- Schema must belong to the same client as the document (enforced at app layer + DB trigger) +- Schema must have status `active` +- If the document already has custom metadata written, the schema cannot be changed (returns 409, also enforced by DB trigger) +- If the document already has legacy field extractions, the schema cannot be assigned (returns 409 -- mutual exclusivity) +- Setting `customSchemaId` to `null` removes the binding (only if no custom metadata exists) + +#### `POST /super-admin/folders/{folderId}/assign-schema` (Bulk Folder Assignment) + +Assign a custom schema to ALL documents in a folder and all its subfolders (recursive). + +**Request:** +```json +{ + "customSchemaId": "019577a3-...", + "createdBy": "admin@acme.com" +} +``` + +**Response (200):** +```json +{ + "folderId": "...", + "customSchemaId": "019577a3-...", + "schemaName": "aircraft-engineering", + "documentsUpdated": 87, + "documentsSkipped": 5, + "skippedReasons": [ + {"documentId": "...", "reason": "Document already has custom metadata with a different schema"}, + {"documentId": "...", "reason": "Document already has custom metadata with a different schema"}, + {"documentId": "...", "reason": "Document already assigned to same schema (no-op)"}, + {"documentId": "...", "reason": "Document has legacy field extractions"}, + {"documentId": "...", "reason": "Document has legacy field extractions"} + ] +} +``` + +**Behavior:** +- Uses `WITH RECURSIVE` CTE to walk the entire folder subtree (same pattern as folder deletion) +- For each document in the tree: + - If the document has no schema, no custom metadata, and no legacy extractions -> assign the schema + - If the document already has the same schema -> skip (no-op, counted in `documentsSkipped`) + - If the document has a different schema but no custom metadata yet -> reassign to new schema + - If the document has a different schema AND has custom metadata -> skip with reason (cannot change) + - If the document has legacy field extractions -> skip with reason (mutual exclusivity) +- Runs in a single transaction +- **Document count limit**: If the folder subtree contains more than `MaxBulkAssignDocuments` (10,000) documents, returns `422 Unprocessable Entity` with a message explaining the limit. This prevents unbounded transaction duration, excessive lock contention, and replication lag. +- Returns summary of what was updated and what was skipped (with reasons) +- Requires `super_admin` role (the `super-admin` resource grants neither `user_admin` nor `client_user` access) + +#### `POST /super-admin/documents/{id}/reset-metadata` + +Wipe all custom metadata and schema binding from a single document so the document can be re-bound to a different schema (typically a newer version of the same schema, e.g., after an additive field was added via `PUT /super-admin/custom-schemas/{schemaId}`). + +This is the explicit, audited escape hatch for the "schema is frozen once metadata exists" invariant. Under normal circumstances, once `document_custom_metadata` has any rows for a document, Trigger 1 (`trg_prevent_schema_reassignment`) blocks `documents.custom_schema_id` from ever changing and Trigger 3 (`trg_enforce_consistent_schema_id`) prevents writing new metadata rows under a different schema. Reset removes the blocking state (the metadata rows themselves and the schema binding) in a single transaction so the existing assign-schema + write-metadata endpoints can operate from a clean slate. **No triggers are added, modified, or bypassed** -- after the metadata rows are deleted inside the transaction, the existing triggers simply no longer have anything to object to. + +**Request:** empty body (path parameter `id` is the document ID). + +**Response (200):** +```json +{ + "documentId": "019577de-...", + "previousSchemaId": "019577a3-...", + "previousSchemaName": "aircraft-engineering", + "previousSchemaVersion": 1, + "metadataVersionsDeleted": 3, + "customSchemaId": null, + "hasCustomMetadata": false, + "resetAt": "2026-04-13T12:00:00Z", + "resetBy": "admin@acme.com" +} +``` + +The `previous*` fields describe the state *before* the reset so the caller can log or display what was wiped. `customSchemaId: null` and `hasCustomMetadata: false` confirm the post-reset state (same values the next `GET /document/{id}` would return). + +**Transaction sequence** (single transaction, serializable-acceptable at read committed with the row locks below): + +1. `SELECT id, "clientId", custom_schema_id FROM documents WHERE id = $1 FOR UPDATE` -- lock the document row so concurrent assigns/writes serialize behind the reset. +2. If the document does not exist, return `404 Not Found`. +3. Capture `previousSchemaId` (may be `NULL`) and join to `client_metadata_schemas` to capture `previousSchemaName` / `previousSchemaVersion` for the response (null if no schema was bound). +4. `SELECT COUNT(*) FROM document_custom_metadata WHERE document_id = $1` -- captured as `metadataVersionsDeleted` for the response. +5. `DELETE FROM document_custom_metadata WHERE document_id = $1`. +6. `UPDATE documents SET custom_schema_id = NULL WHERE id = $1`. Trigger 1 fires on this UPDATE; because step 5 already removed all metadata rows and the document is guaranteed (by Invariant 14 and the existing mutual-exclusivity guards) to have no legacy field extractions, the trigger's `EXISTS` checks both return false and the UPDATE succeeds. +7. Insert an audit log entry (same log sink used by `AssignSchema` / `DeleteSchema`) recording `actor`, `documentId`, `previousSchemaId`, `metadataVersionsDeleted`, and `resetAt`. +8. Commit. + +**Behavior and guarantees:** + +- **Atomic**: If any step fails, the transaction rolls back and the document is unchanged. +- **Idempotent on already-clean documents**: If the document has no custom metadata and `custom_schema_id IS NULL`, the endpoint succeeds and returns `metadataVersionsDeleted: 0`, `previousSchemaId: null`. This keeps retries safe. +- **Does NOT touch legacy field extractions**: The mutual-exclusivity invariant (Invariant 14) still holds. A document that has any legacy field extraction rows is **rejected with `409 Conflict` before any DELETE runs** -- see validation below. Reset-metadata is deliberately scoped to documents using the custom schema system; it is not a "force wipe everything" endpoint and will not silently no-op on legacy documents. +- **Does NOT touch the schema definitions**: `client_metadata_schemas` rows are untouched. Retiring the document's binding does not affect other documents using the same schema. +- **Does NOT re-assign the new schema**: This endpoint is deliberately scoped to "wipe" only. The caller must follow up with `PATCH /super-admin/documents/{id}/schema` to bind the new schema and `POST /custom-metadata` to re-enter values. Keeping the two operations separate preserves the existing endpoints' semantics and makes the audit trail explicit (one log entry for the reset, one for the re-assignment, one per metadata write). +- **History is lost**: All prior metadata versions for this document are deleted. If the caller needs to preserve values across the reset, they must `GET /custom-metadata` beforehand and re-POST after the new schema is bound. Consider this the v3 "release valve," not the normal path. The `metadataVersionsDeleted` count in the response is a safety check for callers who want to confirm how much history they gave up. + +**Validation and error responses:** + +- `404 Not Found` if the document does not exist or the caller's client scope does not include it. +- `409 Conflict` if the document has legacy field extraction rows (`documentFieldExtractionVersions`). Message: "Document uses the legacy field extraction system; reset-metadata only applies to documents using the custom schema system." This mirrors the mutual-exclusivity guard used elsewhere. +- `200 OK` on successful wipe (even if the document was already clean). +- `500` on unexpected DB errors. The transaction ensures partial state is impossible. + +**Authorization:** + +- Path lives under `/super-admin/*`, so the first-segment resource extraction rule maps the request to the `super-admin` Permit.io resource. Only the `super_admin` role holds `super-admin:post`, so `user_admin`, `client_user`, and `auditor` all receive `403`. No new Permit.io resource or action is required -- the existing `super-admin:post` grant already covers this route. +- The endpoint is also reflected in the documentation-only `policy_mappings` block in `permit_policies.yaml` (see Section 5.3). + +**Why wipe-and-reassign instead of allowing a compatible schema swap in place?** + +An earlier design considered relaxing Trigger 1 to permit reassignment when the new schema is a strict superset of the old one (additional optional fields, no type changes, no newly-required fields). That approach was rejected because: + +1. **Invariant 5 stays clean.** "`custom_schema_id` is immutable once metadata exists" is a strong, trivially auditable rule. Introducing "immutable *except* when the new schema is compatible" replaces a one-line check with a structural JSON Schema comparator that must agree exactly across the app layer and the DB trigger. Keeping the trigger unchanged is worth the cost of a destructive reset for the edge case of in-place upgrades. +2. **No silent reinterpretation of stored data.** Deleting the metadata and requiring the caller to re-submit guarantees that the new schema is actually applied to the values being stored, with full validation. An in-place schema swap would leave the JSONB blobs untouched and implicitly promote them to the new schema -- which means a later schema tightening (e.g., a pattern constraint added to an existing field) would not catch violations in legacy rows. +3. **Audit clarity.** The reset leaves an explicit log entry indicating that a super_admin chose to discard prior metadata history for this document. An in-place swap would silently change the schema linkage with no indication that the on-disk data predates the new schema. +4. **Simpler to implement and test.** One new endpoint, zero migrations, zero trigger changes, reuses the SQLC queries already added for the delete cascade (`DeleteDocumentCustomMetadata`, `NullifyDocumentCustomSchemaId`, see Section 8.2). No JSON Schema compatibility comparator to build, maintain, or debug. + +If a non-destructive upgrade path is needed in the future, it can be added as a separate v4 endpoint (`POST /super-admin/documents/{id}/upgrade-schema`) that performs a compatibility check and a targeted UPDATE of `document_custom_metadata.schema_id` (still requiring a narrowly scoped relaxation of Trigger 3). v3 does not commit to that design. + +#### `GET /document/{id}` (Modified) + +Add `customSchemaId` and `customMetadata` fields to `DocumentEnriched`: + +```json +{ + "id": "...", + "clientId": "acme-corp", + "hash": "...", + "customSchemaId": "019577a3-...", + "customSchemaName": "aircraft-engineering", + "hasCustomMetadata": true, + "customMetadata": { ... }, + ...existing fields... +} +``` + +**Query parameter:** `customMetadata` (boolean, default: false) - when true, includes the full custom metadata in the response (like the existing `textRecord` parameter). + +> **Authorization note**: The `GET /document/{id}` endpoint stays on the existing `document` resource. Both admin roles, `client_user`, and `auditor` can read it today. Returning `customSchemaId` / `customSchemaName` / `customMetadata` on this endpoint is read-only and does not grant any administrative capability. The `super-admin` gating applies only to the write/assign/manage paths. + +--- + +### 5.3 Authorization & Permit.io Changes + +> **Security note -- accepted cross-tenant limitation.** The Permit.io check performed by `internal/cognitoauth/middleware.go` and `internal/cognitoauth/permitio.go` is `(user, action, first-path-segment resource)` -- it does not pass a document or client object. That means a caller with `custom-metadata:get`/`custom-metadata:post` (i.e. `client_user`, `user_admin`, `super_admin`, or read-only `auditor`) and knowledge of a document UUID could read or write that document's custom metadata regardless of which client owns the document. This is the same limitation already documented for the rest of the API in `cmd/auth_related/permit.setup/permit_policies.yaml` and is **NOT** addressed by v3. +> +> **Why this is acceptable for v3.** Production environments are deployed per-client: each client runs in its own isolated stack (separate database, separate services, separate Cognito pool), so there is no "other tenant" to cross into from inside a production environment. The limitation only materializes in shared non-production environments (dev/uat) where multiple clients' fixtures may co-exist in a single database. Treat those environments accordingly -- do not load customer-sensitive fixtures into a shared stack. +> +> **Out of scope for v3.** Adding application-level document -> client ownership enforcement to the custom-metadata service/controller is a separate initiative that also needs to retrofit the existing `documents`, `field-extractions`, and `folders` endpoints. If/when that work happens, the `custom-metadata` resource should be updated to receive the document and client objects in the Permit.io check, and the service should refuse cross-tenant reads/writes with `404 Not Found` (never `403`, to avoid leaking existence). Until then the table below describes only coarse role gating, not tenant scoping. + +v2 splits admin-class authorization for mutable metadata into two tiers; v3 keeps the same split and simply adds the reset-metadata endpoint to the `super_admin`-only column: + +| Role | Schema CRUD (`/super-admin/custom-schemas/*`) | Schema Assignment (`/super-admin/documents/{id}/schema`, `/super-admin/folders/{folderId}/assign-schema`) | Reset Metadata (`/super-admin/documents/{id}/reset-metadata`) | Metadata Read (`GET /custom-metadata/*`, `GET /document/{id}` enrichment) | Metadata Write (`POST /custom-metadata`) | +|------|-----------------------------------------------|-----------------------------------------------------------------------------------------------------------|----------------------------------------------------------------|----------------------------------------------------------------------------|-------------------------------------------| +| `super_admin` | Yes | Yes | Yes | Yes | Yes | +| `user_admin` | **No** | **No** | **No** | Yes | Yes | +| `auditor` | Read-only (`GET` only) | No | No | Read-only | No | +| `client_user` | No | No | No | Yes | Yes | + +Key change from v1: `user_admin` has NO schema CRUD and NO schema assignment rights. The role continues to function exactly as it does today for everything else (user creation, labels, folders, etc.). Reset-metadata (new in v3) follows the same confinement: only `super_admin` can call it, because it is destructive and structurally equivalent to a schema reassignment. + +#### New Permit.io resource: `super-admin` + +Add a new resource to `cmd/auth_related/permit.setup/permit_policies.yaml`: + +```yaml +resources: + # ...existing resources unchanged... + + - name: super-admin + description: Platform-level administrative functions restricted to super_admin role (custom metadata schema management, schema assignment) + actions: + - get + - post + - patch + - delete +``` + +#### Role permission updates + +```yaml +roles: + - name: super_admin + description: Full system access for platform administrators + permissions: + # ...existing permissions unchanged... + super-admin: + - get + - post + - patch + - delete + custom-metadata: + - get + - post + + - name: user_admin + description: User management - primarily for creating new client users + permissions: + # ...existing permissions unchanged (admin, documents, field-extractions, folders)... + # NOTE: user_admin is intentionally NOT granted super-admin:* permissions. + custom-metadata: + - get + - post + + - name: auditor + description: Full read-only access across the entire system for auditing and compliance + permissions: + # ...existing permissions unchanged... + super-admin: + - get + custom-metadata: + - get + + - name: client_user + description: Client-specific access for external users (requires tenant filtering) + permissions: + # ...existing permissions unchanged... + custom-metadata: + - get + - post +``` + +#### New custom-metadata resource + +The user-facing custom metadata endpoints (`/custom-metadata/*`) use a separate `custom-metadata` resource (first path segment). This is unchanged from v1 except that it now also needs to be granted to `super_admin` explicitly (v1 relied on `admin:*` coverage which no longer covers the metadata path). + +```yaml +resources: + - name: custom-metadata + description: Per-document custom metadata read/write (validated against client schemas) + actions: + - get + - post +``` + +#### Policy mapping documentation additions + +Add to the documentation-only `policy_mappings` section at the bottom of `permit_policies.yaml`: + +```yaml +super_admin_endpoints: + - path: /super-admin/custom-schemas + methods: + GET: super-admin:get + POST: super-admin:post + - path: /super-admin/custom-schemas/{schemaId} + methods: + GET: super-admin:get + PUT: super-admin:patch # PUT maps to patch action (create-new-version semantics) + DELETE: super-admin:delete + - path: /super-admin/documents/{id}/schema + methods: + PATCH: super-admin:patch + - path: /super-admin/folders/{folderId}/assign-schema + methods: + POST: super-admin:post + - path: /super-admin/documents/{id}/reset-metadata + methods: + POST: super-admin:post + +custom_metadata_endpoints: + - path: /custom-metadata + methods: + GET: custom-metadata:get + POST: custom-metadata:post + - path: /custom-metadata/version + methods: + GET: custom-metadata:get + - path: /custom-metadata/history + methods: + GET: custom-metadata:get +``` + +> **PUT-to-patch action note**: The Permit.io middleware lowercases the HTTP method. Permit.io's default action vocabulary does not include `put`; the existing middleware either maps `PUT` to `patch` or requires the action to exist. Confirm during implementation whether the middleware needs a `put` action added to each resource or whether mapping `PUT` -> `patch` is acceptable. If a new action is needed, add `put` to both `super-admin.actions` and the `super_admin` role permissions. The same consideration applies to any other resources that currently have no `put` action. + +#### Middleware impact + +The Permit.io resource-extraction middleware uses the first path segment. Introducing `super-admin` as a resource requires no middleware code changes -- it is purely a configuration addition via the permit setup tool. The setup tool (`cmd/auth_related/permit.setup/`) must be re-run in each environment (dev, uat, prod) to provision the new resource and role permissions before these endpoints can pass authorization. + +#### Deployment ordering + +Because the authorization changes must land in Permit.io before the API routes are live, the deployment sequence is: + +1. Update `permit_policies.yaml` with the new resources and role grants. +2. Run the permit setup tool against dev/uat/prod to provision the new resource and permissions. +3. Deploy the API code containing the new endpoints. + +Step 2 must complete before step 3 in each environment, otherwise the new endpoints would either be rejected by Permit.io (if fail-closed) or accidentally allowed (if fail-open) during the window between code deploy and permit sync. + +--- + +### 5.4 Custom Metadata CRUD Endpoints (User-Facing) + +All under a new `CustomMetadataService` tag. These endpoints are NOT moved under `/super-admin/*` because they are intended for regular users (including `client_user`) to read and write the custom metadata values on documents. Schema administration and schema assignment are separated from value read/write precisely so the day-to-day data entry workload does not require super_admin. + +> **Actor identity is authoritative, not client-supplied.** Several request bodies in this section and Section 5.2 show a `createdBy` field (and the reset-metadata response shows `resetBy`). These are shown in the examples for audit-log shape *only*. The handler MUST derive the actor identity from the authenticated JWT / Cognito subject (`cognitoauth.GetUserSubject`, or whichever helper the middleware exposes) and ignore any `createdBy` string supplied in the request body. Accepting a caller-supplied `createdBy` would let a compromised client write arbitrary audit trails, which defeats the audit goal. +> +> Applies to: `POST /super-admin/custom-schemas`, `PUT /super-admin/custom-schemas/{schemaId}`, `DELETE /super-admin/custom-schemas/{schemaId}`, `PATCH /super-admin/documents/{id}/schema`, `POST /super-admin/folders/{folderId}/assign-schema`, `POST /super-admin/documents/{id}/reset-metadata`, and `POST /custom-metadata`. In a future OpenAPI pass the `createdBy` request-body fields should be removed entirely; for v3 we keep the field in the schema for backward shape compatibility but drop its value on the server. +> +> **Audit sink**: Sections 5.2 and 8 reference "the same log sink used by `AssignSchema` / `DeleteSchema`." If that sink does not already exist as a first-class abstraction in `internal/customschema/` or an `audit` package, Phase 4 must define it (signature, where records are written, whether it is synchronous or queued) before Reset-metadata (Phase 4.5) and Schema CRUD (Phase 4.2) can log through it. The audit record must include at minimum: `actor` (derived from auth context), `action`, `resourceId`, `timestamp`, and an action-specific detail payload. + +#### `POST /custom-metadata` + +Create or update custom metadata for a document. Creates a new version. + +**Request:** +```json +{ + "documentId": "...", + "metadata": { + "aircraft_model": "Boeing 737-800", + "certification_date": "2024-06-15", + "max_altitude_ft": 41000, + "engine_count": 2 + }, + "createdBy": "analyst@acme.com" +} +``` + +**Response (201):** +```json +{ + "id": "...", + "documentId": "...", + "schemaId": "019577a3-...", + "metadata": { ... }, + "version": 1, + "createdAt": "...", + "createdBy": "analyst@acme.com" +} +``` + +**Validation:** +- Document must have a `custom_schema_id` assigned (400 if not -- a super_admin must have assigned the schema first) +- Document must NOT have any legacy field extractions (409 if it does -- mutual exclusivity, also enforced by DB trigger on schema assignment) +- The `metadata` payload must be <= 1MB in size (`MaxMetadataPayloadBytes = 1048576`). Returns 400 if exceeded. +- The `metadata` payload is validated against the JSON Schema stored in `client_metadata_schemas.schema_def` +- The request body does NOT include `schemaId` -- the server derives it from the document's `custom_schema_id`. This prevents client/server drift. +- If validation fails, returns 400 with detailed validation errors: + ```json + { + "error": "Metadata does not conform to schema", + "validationErrors": [ + {"field": "engine_count", "message": "must be >= 1, got 0"}, + {"field": "aircraft_model", "message": "required field missing"} + ] + } + ``` +- On first write, locks the document's `custom_schema_id` (makes it immutable via app-layer flag + DB trigger) +- Subsequent writes create new versions but must use the same schema + +#### `GET /custom-metadata` + +Get current (latest version) custom metadata for a document. + +**Query Parameters:** +- `documentId` (required) + +**Response (200):** +```json +{ + "id": "...", + "documentId": "...", + "schemaId": "019577a3-...", + "schemaName": "aircraft-engineering", + "metadata": { ... }, + "version": 3, + "createdAt": "...", + "createdBy": "..." +} +``` + +#### `GET /custom-metadata/version` + +Get a specific version of custom metadata. + +**Query Parameters:** +- `documentId` (required) +- `version` (required, >= 1) + +#### `GET /custom-metadata/history` + +Get version history for a document's custom metadata. + +**Query Parameters:** +- `documentId` (required) +- `limit` (optional, default: 50, max: 200): Maximum number of versions to return +- `offset` (optional, default: 0): Number of versions to skip for pagination + +**Response (200):** +```json +{ + "versions": [ + {"id": "...", "documentId": "...", "version": 3, "createdAt": "...", "createdBy": "..."}, + {"id": "...", "documentId": "...", "version": 2, "createdAt": "...", "createdBy": "..."}, + {"id": "...", "documentId": "...", "version": 1, "createdAt": "...", "createdBy": "..."} + ] +} +``` + +--- + +## 6. Validation Strategy + +### 6.1 JSON Schema Validation + +Use a Go JSON Schema validation library for both: + +1. **Schema validation** (meta-validation): When a super_admin submits a new schema, validate it against the JSON Schema meta-schema to ensure it is a well-formed JSON Schema document. +2. **Metadata validation**: When custom metadata is submitted, validate the payload against the document's assigned schema. + +**Recommended library**: `github.com/santhosh-tekuri/jsonschema/v6` + +Reasons: +- Supports JSON Schema draft 2020-12 +- Active maintenance +- Good error reporting +- Supports custom formats + +### 6.2 Schema Definition Requirements + +When a schema definition is submitted (via `POST` or `PUT`), the server validates: + +1. It is a valid JSON object +2. It compiles successfully as a JSON Schema document (meta-validation) +3. Root `type` is `"object"` +4. Root `additionalProperties` is explicitly present (either `true` or `false`). This is required because JSON Schema defaults `additionalProperties` to `true` when omitted, which silently accepts any extra fields. Requiring explicit declaration forces a conscious choice. +5. Size is <= 64KB + +### 6.3 Supported JSON Schema Features + +The system will support the following JSON Schema keywords for custom field definitions: + +| Feature | JSON Schema Keyword | Example | +|---------|-------------------|---------| +| Data type | `type` | `"string"`, `"number"`, `"integer"`, `"boolean"`, `"array"`, `"object"` | +| Required fields | `required` | `["field1", "field2"]` | +| String length | `minLength`, `maxLength` | `"maxLength": 100` | +| Numeric range | `minimum`, `maximum` | `"minimum": 0` | +| Pattern matching | `pattern` | `"pattern": "^[A-Z]{2}$"` | +| Enum values | `enum` | `"enum": ["active", "inactive"]` | +| Date/time format | `format` | `"format": "date"` | +| Array items | `items`, `minItems`, `maxItems` | Nested array support | +| Nested objects | `properties` | Hierarchical field groups | +| No extra fields | `additionalProperties` | `false` to enforce strict schemas | + +### 6.4 Validation Error Reporting + +Validation errors will be returned as structured JSON with: +- The JSON pointer to the failing field (`/aircraft_model`) +- The constraint that was violated (`maxLength`) +- A human-readable message + +--- + +## 7. Service Layer Design + +### 7.1 New Service: `internal/customschema/` + +``` +internal/customschema/ + service.go -- CustomSchemaService (CRUD + validation) + models.go -- Input/output types + validator.go -- JSON Schema meta-validation + payload validation + constants.go -- MaxSchemaDefinitionBytes and other limits + service_test.go -- Integration tests +``` + +**Constants (`constants.go`):** + +```go +const ( + // MaxSchemaDefinitionBytes is the maximum size of a JSON Schema definition. + // This limit can be increased if clients need larger schemas. + MaxSchemaDefinitionBytes = 65536 // 64KB + + // MaxMetadataPayloadBytes is the maximum size of a custom metadata JSON payload. + // Prevents unbounded JSONB blobs when schemas use additionalProperties: true. + MaxMetadataPayloadBytes = 1048576 // 1MB + + // MaxBulkAssignDocuments is the maximum number of documents that can be updated + // in a single bulk folder schema assignment. Prevents unbounded transactions. + MaxBulkAssignDocuments = 10000 +) +``` + +**Key methods:** + +```go +type Service struct { + cfg serviceconfig.ConfigProvider + validator *SchemaValidator +} + +// Schema management (super_admin operations) +func (s *Service) CreateSchema(ctx, input CreateSchemaInput) (*Schema, error) +func (s *Service) UpdateSchema(ctx, schemaID uuid.UUID, input UpdateSchemaInput) (*Schema, error) +func (s *Service) GetSchema(ctx, schemaID uuid.UUID) (*Schema, error) +func (s *Service) ListSchemas(ctx, clientID string, filters ListFilters) ([]SchemaSummary, error) +func (s *Service) DeleteSchema(ctx, schemaID uuid.UUID) error + +// Metadata operations (client_user / admin operations) +func (s *Service) SetDocumentMetadata(ctx, input SetMetadataInput) (*CustomMetadata, error) +func (s *Service) GetCurrentMetadata(ctx, documentID uuid.UUID) (*CustomMetadata, error) +func (s *Service) GetMetadataByVersion(ctx, documentID uuid.UUID, version int) (*CustomMetadata, error) +func (s *Service) GetMetadataHistory(ctx, documentID uuid.UUID) ([]MetadataVersion, error) + +// Document schema binding (super_admin operations) +func (s *Service) AssignSchema(ctx, documentID uuid.UUID, schemaID uuid.UUID) error +func (s *Service) AssignSchemaToFolder(ctx, folderID uuid.UUID, schemaID uuid.UUID, createdBy string) (*BulkAssignResult, error) +func (s *Service) ResetDocumentMetadata(ctx, documentID uuid.UUID, resetBy string) (*ResetMetadataResult, error) +``` + +**ResetMetadataResult:** + +```go +type ResetMetadataResult struct { + DocumentID uuid.UUID + PreviousSchemaID *uuid.UUID // nil if document had no schema bound + PreviousSchemaName *string // nil if document had no schema bound + PreviousSchemaVersion *int // nil if document had no schema bound + MetadataVersionsDeleted int + ResetAt time.Time + ResetBy string +} +``` + +`ResetDocumentMetadata` runs a single transaction that: locks the document row (`SELECT ... FOR UPDATE`), rejects documents with legacy field extractions (`409`), captures the previous schema binding metadata (via join to `client_metadata_schemas`) and the count of metadata versions for the response, executes the `DeleteDocumentCustomMetadata` and `NullifyDocumentCustomSchemaId` queries from Section 8.2 (already present for the delete cascade, reused here verbatim), writes an audit log entry, and returns the captured "previous" state. See Section 5.2 for the full endpoint semantics and rationale. + +**BulkAssignResult:** + +```go +type BulkAssignResult struct { + FolderID uuid.UUID + SchemaID uuid.UUID + DocumentsUpdated int + DocumentsSkipped int + SkippedReasons []SkippedDocument +} + +type SkippedDocument struct { + DocumentID uuid.UUID + Reason string +} +``` + +> **Authorization is enforced at the HTTP middleware layer via Permit.io**, not inside these service methods. The service methods are agnostic to the caller's role. The routing layer for `/super-admin/*` endpoints is what ensures only `super_admin` can invoke `CreateSchema`, `UpdateSchema`, `DeleteSchema`, `AssignSchema`, and `AssignSchemaToFolder`. Keeping the service layer role-unaware preserves testability and makes integration tests simpler. + +### 7.2 Validator Component + +```go +type SchemaValidator struct{} + +// ValidateSchemaDefinition checks that a schema_def is valid JSON Schema. +// Returns error if: not a valid JSON object, fails meta-validation, +// root type is not "object", or additionalProperties is not explicitly present. +func (v *SchemaValidator) ValidateSchemaDefinition(schemaDef json.RawMessage) error + +// ValidateMetadata checks that metadata conforms to a compiled schema +func (v *SchemaValidator) ValidateMetadata(schemaDef json.RawMessage, metadata json.RawMessage) ([]ValidationError, error) +``` + +### 7.3 Mutual Exclusivity Guard + +The service layer enforces mutual exclusivity at two points: + +1. **Schema assignment** (`AssignSchema`): Before setting `custom_schema_id` on a document, check that no rows exist in `documentFieldExtractionVersions` for that document. If they do, return `409 Conflict`. +2. **Legacy field extraction write** (modification to existing `FieldExtractionService`): Before creating a new field extraction, check that `documents.custom_schema_id IS NULL` for the target document. If it is not null, return `409 Conflict` with a message explaining that the document uses the custom schema system. + +> **Note**: The DB trigger on `documents.custom_schema_id` (Section 4.7, Trigger 1) also prevents schema assignment if legacy extractions exist. This is the defense-in-depth pattern: the service layer returns a clean 409; the trigger catches anything that slips through. + +### 7.4 API Controller: `api/queryAPI/customschemas.go` + +Follows the same pattern as `fieldextractions.go`: +- Parse and validate request +- Call service layer +- Convert between API and service types +- Return appropriate HTTP status codes + +The controller file holds all `/super-admin/custom-schemas/*`, `/super-admin/documents/{id}/schema`, and `/super-admin/folders/{folderId}/assign-schema` handlers. A separate controller file `api/queryAPI/custommetadata.go` holds the user-facing `/custom-metadata/*` handlers. Separating them by file mirrors the separation of authorization tiers and makes it obvious at code-review time which handlers are super_admin-gated. + +--- + +## 8. Migration Plan + +### 8.1 Migration Files + +Four new migrations. The repo already contains migrations through `00000000000126_batch_document_outcomes_delete_actions`, so v3 starts at `127`. If another branch lands new migrations before this feature merges, renumber the four files below to the next free sequence and update all references in this plan and the tracking doc. + +**Migration 127: Create `schema_status_type` enum + `client_metadata_schemas` table** +``` +00000000000127_create_client_metadata_schemas.up.sql +00000000000127_create_client_metadata_schemas.down.sql +``` + +**Migration 128: Create `document_custom_metadata` table + view** +``` +00000000000128_create_document_custom_metadata.up.sql +00000000000128_create_document_custom_metadata.down.sql +``` + +**Migration 129: Add `custom_schema_id` to `documents` table** +``` +00000000000129_add_custom_schema_id_to_documents.up.sql +00000000000129_add_custom_schema_id_to_documents.down.sql +``` + +**Migration 130: Add database triggers for invariant enforcement** +``` +00000000000130_add_schema_invariant_triggers.up.sql +00000000000130_add_schema_invariant_triggers.down.sql +``` + +Contents: +- `trg_prevent_schema_reassignment` function + trigger (Section 4.7, Trigger 1) +- `trg_validate_schema_client_match` function + trigger (Section 4.7, Trigger 2) +- `trg_enforce_consistent_schema_id` function + trigger (Section 4.7, Trigger 3) +- Down migration drops triggers and functions + +### 8.2 SQLC Queries + +New query file: `internal/database/queries/customschemas.sql` + +**Schema operations:** +- `CreateClientMetadataSchema` - Insert new schema +- `GetClientMetadataSchema` - Get by ID +- `ListClientMetadataSchemas` - List by client with filters (status filter) +- `GetLatestSchemaByName` - Get latest version of a named schema +- `GetSchemaDocumentCount` - Count documents using a schema +- `GetSchemaMetadataRecordCount` - Count metadata records referencing a schema (for `canDelete`) +- `SetSchemaStatus` - Update status (for supersede/retire) +- `GetMaxSchemaVersion` - For auto-incrementing version +- `LockSchemaVersionsForName` - `SELECT ... FOR UPDATE` on all versions of a (client_id, name) pair to prevent concurrent version creation race + +**Metadata operations:** +- `CreateDocumentCustomMetadata` - Insert new metadata version +- `GetCurrentDocumentCustomMetadata` - Latest version (via view) +- `GetDocumentCustomMetadataByVersion` - Specific version +- `GetDocumentCustomMetadataHistory` - All versions +- `LockDocumentCustomMetadataForVersion` - Row-level locking (SELECT FOR UPDATE on max version) + +**Document modifications:** +- `SetDocumentCustomSchemaId` - Update documents.custom_schema_id +- `GetDocumentCustomSchemaId` - Read documents.custom_schema_id +- `BulkSetDocumentCustomSchemaIdInFolderTree` - Set schema for all docs in folder subtree (WITH RECURSIVE) +- `GetDocumentsWithMetadataInFolderTree` - Find docs that already have metadata (for skip reporting) +- `GetDocumentsWithLegacyExtractionsInFolderTree` - Find docs that have legacy extractions (for skip reporting) +- `DocumentHasLegacyExtractions` - Check if a single document has any legacy field extraction versions +- `CountDocumentsInFolderTree` - Count total documents in a folder subtree (for `MaxBulkAssignDocuments` limit check) + +**Delete cascade operations (additions to `document.sql` and `client.sql`):** +- `DeleteDocumentCustomMetadata` - Delete all custom metadata versions for a document +- `NullifyDocumentCustomSchemaId` - Set `custom_schema_id = NULL` on a document (before hard delete) +- `DeleteClientMetadataSchemas` - Delete all schemas for a client (after all documents deleted) + +**Reset-metadata support queries (v3, additions to `customschemas.sql`):** +- `LockDocumentForReset` - `SELECT id, "clientId", custom_schema_id FROM documents WHERE id = @document_id FOR UPDATE` (used by `ResetDocumentMetadata`; reuses the same row-level lock shape that `AssignSchema` uses so the two operations serialize correctly against each other) +- `GetDocumentSchemaBindingForReset` - Join `documents` to `client_metadata_schemas` to return the document's current `custom_schema_id`, the schema `name`, and `version`. Used to populate the `previous*` fields of the reset response. Returns all-null columns cleanly when the document has no schema bound. +- `CountDocumentCustomMetadataVersions` - `SELECT COUNT(*) FROM document_custom_metadata WHERE document_id = @document_id`. Used to populate `metadataVersionsDeleted` in the reset response. +- `DocumentHasLegacyExtractions` is already listed above and is reused by the reset endpoint for the mutual-exclusivity guard (returns 409 if true). +- `DeleteDocumentCustomMetadata` and `NullifyDocumentCustomSchemaId` are already listed above (for the delete cascade) and are reused verbatim by the reset endpoint -- no new queries for the wipe itself. + +### 8.3 OpenAPI Spec Changes + +Add to `serviceAPIs/queryAPI.yaml`: + +**New `SuperAdminSchemaService` tag** for super_admin-only endpoints: +- `POST /super-admin/custom-schemas` +- `GET /super-admin/custom-schemas` +- `GET /super-admin/custom-schemas/{schemaId}` +- `PUT /super-admin/custom-schemas/{schemaId}` +- `DELETE /super-admin/custom-schemas/{schemaId}` +- `PATCH /super-admin/documents/{id}/schema` +- `POST /super-admin/folders/{folderId}/assign-schema` +- `POST /super-admin/documents/{id}/reset-metadata` (v3) + +**New `CustomMetadataService` tag** for end-user endpoints: +- `GET /custom-metadata` +- `POST /custom-metadata` +- `GET /custom-metadata/version` +- `GET /custom-metadata/history` + +**New schemas**: `CustomSchemaRequest`, `CustomSchemaResponse`, `CustomSchemaListResponse`, `CustomMetadataRequest`, `CustomMetadataResponse`, `CustomMetadataHistoryResponse`, `ValidationErrorResponse`, `BulkSchemaAssignRequest`, `BulkSchemaAssignResponse`, `DocumentSchemaAssignRequest`, `DocumentSchemaAssignResponse`, `ResetDocumentMetadataResponse` (v3) + +**New enum**: `SchemaStatus` with values `active`, `superseded`, `retired` + +**Modified schemas**: `DocumentEnriched` (add `customSchemaId`, `customSchemaName`, `hasCustomMetadata`, `customMetadata` fields) + +**Tag descriptions should explicitly call out the authorization boundary:** +- `SuperAdminSchemaService` description: "Custom metadata schema management and assignment. Restricted to the `super_admin` role. Not accessible to `user_admin`." +- `CustomMetadataService` description: "Per-document custom metadata read/write. Accessible to `client_user`, `user_admin`, and `super_admin`." + +### 8.4 Legacy Field Extraction Guard + +Add a check to the existing field extraction service (`internal/fieldextraction/service.go`): + +Before creating a new field extraction, verify that `documents.custom_schema_id IS NULL` for the target document. If the document has a custom schema assigned, return a `409 Conflict` error explaining that the document uses the custom schema system and legacy field extractions are not allowed. + +This is a small modification to the existing code path, not a new endpoint. + +### 8.5 Delete Path Updates (Cascade Cleanup) + +The new tables and FKs require updates to existing delete paths. Without these changes, hard-deleting a document or client will fail with FK violations. + +#### Document Delete Cascade + +The existing `DeleteDocumentCascade` function (in `internal/document/`) and the SQLC queries in `internal/database/queries/document.sql` must be updated to delete custom metadata before deleting the document: + +**New SQLC queries to add to `document.sql`:** +- `DeleteDocumentCustomMetadata` - `DELETE FROM document_custom_metadata WHERE document_id = @document_id` +- `NullifyDocumentCustomSchemaId` - `UPDATE documents SET custom_schema_id = NULL WHERE id = @document_id` + +**Updated delete order in `DeleteDocumentCascade`:** +1. Delete `documentFieldExtractionArrayFields` (existing) +2. Delete `documentFieldExtractionVersions` (existing) +3. Delete `documentFieldExtractions` (existing) +4. **Delete `document_custom_metadata`** (new -- must come before document row delete) +5. **Nullify `documents.custom_schema_id`** (new -- removes FK to `client_metadata_schemas` before document delete) +6. Delete `documentCleanEntries` / `documentCleans` / `documentEntries` (existing) +7. Delete `documents` row (existing) + +> **Note**: Step 5 (nullify `custom_schema_id`) must happen before step 7 because the FK from `documents.custom_schema_id` to `client_metadata_schemas` would block deletion otherwise. The nullify is safe because step 4 already removed all custom metadata, and Trigger 1 only fires on changes when metadata exists. + +#### Client Delete Cascade + +The existing `client.HardDelete` and `deleteClientDependencies` functions must be updated to clean up schema data after all documents are deleted: + +**New SQLC queries:** +- `DeleteClientMetadataSchemas` - `DELETE FROM client_metadata_schemas WHERE client_id = @client_id` + +**Updated delete order in `client.HardDelete`:** +1. Delete all documents with full cascade (existing, now includes custom metadata cleanup per above) +2. Delete `documentUploads` (existing) +3. Delete all folders (existing) +4. **Delete `client_metadata_schemas`** (new -- safe after all documents are gone since no FKs reference these rows) +5. Delete collector data, batch uploads, sync records (existing) +6. Delete client row (existing) + +### 8.6 Permit.io Setup Tool Run + +After updating `permit_policies.yaml` per Section 5.3, run the permit setup tool in each environment: + +``` +cmd/auth_related/permit.setup/run.tool.dev.sh +cmd/auth_related/permit.setup/run.tool.uat.sh +cmd/auth_related/permit.setup/run.tool.prod.sh +``` + +The tool is idempotent and will provision the new `super-admin` and `custom-metadata` resources and update role grants without disturbing existing resources. Run this **before** deploying the API code so the new endpoints are authorized the moment they go live. + +--- + +## 9. Invariants & Business Rules + +| # | Rule | Enforcement | +|---|------|-------------| +| 1 | Schema must be valid JSON Schema with explicit `additionalProperties` | Application: meta-validation on create/update | +| 2 | Schema `name` + `version` unique per client | Database: unique constraint | +| 3 | Updated schema gets new ID and version; old version becomes `superseded` | Application: INSERT new row, UPDATE old status | +| 4 | Schema deletion blocked if documents reference it | Application: count check before retirement | +| 5 | Document's `custom_schema_id` is immutable once any extraction exists | Application: check in AssignSchema + DB trigger: `trg_prevent_schema_reassignment` | +| 6 | Custom metadata must conform to assigned schema | Application: JSON Schema validation on write | +| 7 | All custom metadata versions for a document use same schema ID | Application: consistency check on write + DB trigger: `trg_enforce_consistent_schema_id` | +| 8 | Schema must belong to same client as document | Application: cross-reference check + DB trigger: `trg_validate_schema_client_match` | +| 9 | Only `active` schemas can be assigned to new documents | Application: status check | +| 10 | Custom metadata is versioned (no in-place updates) | Database: version column + unique constraint | +| 11 | Schema definition must be <= 64KB | Application: size check + Database: CHECK constraint | +| 12 | Schema management requires `super_admin` role (NOT `user_admin`) | Permit.io: role-based access on new `super-admin` resource; `user_admin` is explicitly NOT granted this resource | +| 13 | No query/filter on custom metadata values | By design: not implemented (see Principle 9) | +| 14 | A document uses EITHER legacy extractions OR custom metadata, never both | Application: mutual exclusivity checks in both services + DB trigger: `trg_prevent_schema_reassignment` | +| 15 | Legacy field extraction writes rejected if document has `custom_schema_id` set | Application: guard in `FieldExtractionService` | +| 16 | Custom schema assignment rejected if document has legacy extractions | Application: guard in `AssignSchema` + DB trigger | +| 17 | Metadata payload must be <= 1MB | Application: size check (`MaxMetadataPayloadBytes`) | +| 18 | Bulk folder assignment capped at 10,000 documents | Application: count check (`MaxBulkAssignDocuments`), returns 422 if exceeded | +| 19 | Schema assignment (single doc + bulk folder) requires `super_admin` role | Permit.io: endpoints under `/super-admin/` prefix map to `super-admin` resource; `user_admin` is NOT granted | +| 20 | Delete cascade must clean up custom metadata and schema bindings | Application: updated `DeleteDocumentCascade` + `deleteClientDependencies` (Section 8.5) | +| 21 | Metadata read/write is available to `client_user`, `user_admin`, `super_admin` (not `auditor` for writes) | Permit.io: `custom-metadata` resource grants on each role per Section 5.3 | +| 22 | Reset-metadata is atomic: all `document_custom_metadata` rows deleted AND `custom_schema_id` nulled, or neither | Application: single transaction with row-level lock on the document (Section 5.2, `POST /super-admin/documents/{id}/reset-metadata`) | +| 23 | Reset-metadata requires `super_admin` role | Permit.io: endpoint under `/super-admin/` prefix maps to `super-admin` resource; `user_admin` / `client_user` / `auditor` denied | +| 24 | Reset-metadata is rejected if the document has legacy field extractions | Application: `DocumentHasLegacyExtractions` guard in `ResetDocumentMetadata` returns 409 (mutual exclusivity -- Invariant 14 still holds) | +| 25 | Reset-metadata is idempotent on already-clean documents | Application: transaction succeeds with `metadataVersionsDeleted: 0` when no custom metadata and no schema binding exist | + +--- + +## 10. Relationship to Existing Field Extraction System + +The existing static field extraction system (`/field-extractions`) and the new custom schema system (`/super-admin/custom-schemas` + `/custom-metadata`) are **mutually exclusive per document**: + +- A document uses EITHER the legacy static field extraction system OR the custom schema system +- A document CANNOT have both legacy field extractions AND custom metadata +- Assigning a custom schema to a document that already has legacy extractions returns `409 Conflict` +- Writing a legacy field extraction to a document that has a custom schema assigned returns `409 Conflict` +- Documents without a custom schema (the default) continue to use the legacy system unchanged +- There is no migration path from legacy to custom for individual documents. If a document needs custom metadata, it should not have legacy extractions written to it first. + +**Rationale**: This prevents ambiguity about which system is "authoritative" for a document's metadata. With mutual exclusivity, there is exactly one source of truth per document. This is simpler to reason about, simpler to query, and avoids the data governance problem of conflicting data in two stores. + +**No breaking changes** to the existing API or database schema for documents that do not opt into custom schemas. The existing `/field-extractions` endpoints remain on the `field-extractions` resource and are unaffected by the new `super-admin` resource. + +--- + +## 11. Implementation Order + +| Phase | Work | Dependencies | +|-------|------|--------------| +| 1 | Database migrations (enum, tables, view, document column, triggers) | None | +| 2 | SQLC queries + `task generate` | Phase 1 | +| 3 | Schema validator component (`internal/customschema/validator.go`) | JSON Schema library added to go.mod | +| 4 | Service layer (`internal/customschema/service.go`) incl. bulk folder assignment + mutual exclusivity guards | Phase 2, 3 | +| 5a | Legacy field extraction guard (add check to existing `FieldExtractionService`) | Phase 2 | +| 5b | Delete cascade updates (`DeleteDocumentCascade`, `deleteClientDependencies`, new SQLC queries) | Phase 2 | +| 6 | OpenAPI spec updates (`SuperAdminSchemaService` + `CustomMetadataService` tags) | None (can parallel with 1-3) | +| 7a | Permit.io policy file updates (`permit_policies.yaml`: new `super-admin` + `custom-metadata` resources, role grants) | None (can parallel with 1-6) | +| 7b | Run permit setup tool against dev/uat/prod to provision new resources and role permissions | Phase 7a | +| 8 | API controllers + code generation (super_admin schema CRUD in `customschemas.go`, metadata CRUD in `custommetadata.go`) | Phase 4, 6 | +| 9 | Document endpoint modifications (`PATCH /super-admin/documents/{id}/schema`, `GET /document/{id}` enrichment) | Phase 4, 8 | +| 10 | Bulk folder schema assignment endpoint (`POST /super-admin/folders/{folderId}/assign-schema`) | Phase 4, 8 | +| 10b | Reset-metadata endpoint (`POST /super-admin/documents/{id}/reset-metadata`) -- service method `ResetDocumentMetadata`, handler in `customschemas.go`, OpenAPI `ResetDocumentMetadataResponse` schema, policy mapping entry | Phase 4, 5b (reuses `DeleteDocumentCustomMetadata` + `NullifyDocumentCustomSchemaId` already landed for delete cascade), 8 | +| 11 | Integration tests (including explicit role-based authorization tests per Section 12) | Phase 10b, 7b | +| 12 | Documentation updates (`docs/ai.generated/`) incl. sample schemas, authorization matrix, reset-metadata runbook ("how to upgrade a document to a new schema version") | Phase 11 | + +> **Ordering constraint**: Phase 7b (permit setup run) must complete in each environment before Phase 8 code is deployed to that environment. Otherwise the new endpoints will be rejected by Permit.io (fail-closed) until the setup runs. + +--- + +## 12. Testing Strategy + +- **Schema validation tests**: Valid and invalid JSON Schema documents, edge cases (empty schema, deeply nested, self-referential, oversized >64KB, missing `additionalProperties`) +- **Metadata validation tests**: Conforming and non-conforming payloads, all supported JSON Schema keywords +- **Service integration tests**: Full CRUD lifecycle using testcontainers (real PostgreSQL) +- **API integration tests**: HTTP-level tests for all new endpoints under both `/super-admin/*` and `/custom-metadata/*` +- **Invariant tests**: Schema immutability, cross-client isolation, deletion guards, size limits +- **DB trigger tests**: Verify triggers fire correctly when bypassing the service layer (direct SQL inserts/updates via testcontainers) +- **Schema status lifecycle tests**: `active` -> `superseded` on version create, `active` -> `retired` on delete, assignment rejected for non-`active` schemas +- **Mutual exclusivity tests**: Legacy extraction blocked when schema assigned, schema assignment blocked when legacy extractions exist, bulk folder assignment skips documents with legacy extractions +- **Bulk folder assignment tests**: Recursive assignment, skip behavior for locked docs and legacy docs, empty folders, deeply nested folder trees +- **Authorization tests (expanded for v2)**: + - `super_admin` CAN create, list, get, update, delete schemas, assign schemas to documents, assign schemas to folders + - `user_admin` CANNOT access any `/super-admin/*` endpoint (expect 403 on every method + path combination) + - `user_admin` CAN read and write custom metadata via `/custom-metadata` (no regression) + - `client_user` CANNOT access any `/super-admin/*` endpoint (expect 403) + - `client_user` CAN read and write custom metadata via `/custom-metadata` + - `auditor` CAN read (`GET`) `/super-admin/custom-schemas` and `/super-admin/custom-schemas/{schemaId}` but CANNOT POST/PUT/PATCH/DELETE + - `auditor` CAN read but CANNOT write `/custom-metadata` + - Each test should assert both the HTTP status code AND that the database state is unchanged on denial +- **Backward compatibility tests**: Existing field extraction endpoints continue to work unchanged for documents without custom schemas; existing `/admin/*` endpoints are unaffected by the new `super-admin` resource +- **Delete cascade tests**: Document hard-delete succeeds when document has custom metadata, client hard-delete succeeds when client has schemas and metadata, verify no orphaned rows remain +- **Metadata size limit tests**: Payloads at/above 1MB boundary, verify 400 response with clear message +- **Bulk assignment limit tests**: Folder trees exceeding `MaxBulkAssignDocuments`, verify 422 response +- **Schema version concurrency tests**: Concurrent PUT requests for same schema name produce distinct version numbers +- **Permit.io setup tool tests**: After running the setup tool against a disposable dev tenant, verify that the `super-admin` and `custom-metadata` resources exist with the expected actions and that each role has exactly the grants defined in Section 5.3 +- **Reset-metadata tests (v3)**: + - Happy path: document with schema v1 and N metadata versions -> reset -> assign schema v2 -> POST metadata -> `GET /document/{id}` reflects schema v2 and version 1 metadata; `metadataVersionsDeleted` in the response equals N; `previousSchemaId`/`previousSchemaName`/`previousSchemaVersion` reflect the wiped binding. + - Atomicity: inject a failure between `DELETE FROM document_custom_metadata` and `UPDATE documents SET custom_schema_id = NULL` (e.g., by killing the DB connection mid-transaction in a testcontainers run) and verify that `document_custom_metadata` rows for the document are NOT missing after the failed transaction. + - Idempotency: reset a document that has no custom schema and no custom metadata -> 200 OK with `metadataVersionsDeleted: 0`, `previousSchemaId: null`, DB state unchanged. + - Legacy extraction guard: reset a document that has rows in `documentFieldExtractionVersions` -> 409 Conflict, no DB state change. + - Document not found: reset a non-existent document ID -> 404, no DB state change. + - Trigger 1 still fires normally after a reset: reset, reassign schema v2, write a metadata row, then attempt a second schema reassignment -> trigger fires with the existing error message (confirms no trigger was weakened). + - Trigger 3 still fires normally after a reset: reset, reassign schema v2, write a metadata row under schema v2, then attempt a direct INSERT into `document_custom_metadata` referencing schema v1 -> trigger fires with the existing error message. + - Authorization: `super_admin` succeeds; `user_admin`, `client_user`, and `auditor` all receive 403 with the DB state unchanged. + - Concurrency: two concurrent resets against the same document -> one succeeds, one observes the locked row and serializes (either succeeding on the already-clean state with `metadataVersionsDeleted: 0` or blocking until the first completes). No orphaned rows. + - Concurrency against assign-schema: a reset and a `PATCH /super-admin/documents/{id}/schema` targeting the same document concurrently -> both serialize via `FOR UPDATE`; either ordering produces consistent final state with no trigger violations. + - Audit log: every successful reset writes an audit entry containing the actor, document ID, previous schema ID, and metadata version count. + +--- + +## 13. Resolved Design Decisions + +| # | Question | Decision | +|---|----------|----------| +| 1 | Schema size limits | 64KB cap via `MaxSchemaDefinitionBytes` constant (tunable) | +| 2 | Schema backward compatibility | Not required. New version = new ID. Old docs keep old schema. | +| 3 | Bulk operations | Yes. `POST /super-admin/folders/{folderId}/assign-schema` assigns recursively through folder tree. | +| 4 | Custom metadata search/filter | **Not supported.** No GIN indexes, no query API. Metadata is opaque storage. | +| 5 | Schema templates | No system templates. Documentation will include sample schemas covering all supported types. | +| 6 | Existing field migration path | **Out of scope.** Static fields and custom schemas are mutually exclusive per document. | +| 7 | Endpoint naming/placement | Schema CRUD under `/super-admin/custom-schemas` (requires the `super_admin` role on the new `super-admin` Permit.io resource). Metadata CRUD under `/custom-metadata` (regular `client_user` access; also granted to `user_admin` and `super_admin`). | +| 8 | Legacy/custom coexistence | **Mutually exclusive.** A document uses EITHER legacy OR custom, never both. Simplifies data governance and prevents source-of-truth ambiguity. | +| 9 | Schema status model | Three-state enum (`active`/`superseded`/`retired`) instead of boolean. Distinguishes automatic version succession from explicit admin retirement. | +| 10 | DB-level invariant enforcement | Yes. Three database triggers enforce schema lock, client ownership match, and consistent schema_id as defense-in-depth alongside application-layer checks. | +| 11 | `additionalProperties` in schemas | Required to be explicitly present (not necessarily `false`). Prevents the common mistake of omitting it and silently accepting arbitrary extra fields. | +| 12 | Metadata table design | Single table with version column (simpler than the legacy two-table pattern). Sufficient for our concurrency needs. Can split later if needed. | +| 13 | Schema assignment endpoint path | Under `/super-admin/documents/{id}/schema` (NOT `/admin/document/{id}/schema` as in v1 and NOT `/document/{id}/schema`). The `/super-admin` first segment maps to the new `super-admin` Permit.io resource which only `super_admin` holds. Placing it under `/admin` would also grant `user_admin`; placing it under `/document` would also grant `client_user`. | +| 14 | Metadata payload size limit | 1MB cap via `MaxMetadataPayloadBytes` constant. Prevents unbounded JSONB blobs when schemas allow `additionalProperties: true`. | +| 15 | Bulk assignment document limit | 10,000 documents via `MaxBulkAssignDocuments` constant. Prevents unbounded transactions and lock contention. | +| 16 | Schema_id consistency trigger | DB trigger `trg_enforce_consistent_schema_id` on `document_custom_metadata` INSERT enforces same schema across all metadata versions for a document (defense-in-depth for Invariant 7). | +| 17 | **v2**: Who can administer schemas? | **Only `super_admin`.** v1 unintentionally granted `user_admin` access by placing endpoints under `/admin/*`. v2 moves them to `/super-admin/*` and introduces a new `super-admin` Permit.io resource that only `super_admin` holds. `user_admin` retains its existing capabilities (user creation, field extractions, documents/folders, admin endpoints) but has no schema administration rights. | +| 18 | **v2**: Why a new resource instead of path-specific rules? | The Permit.io middleware resolves resources from the first path segment. Adding path-specific overrides would require middleware code changes. Adding a new first-segment resource (`super-admin`) is a pure configuration change and keeps the middleware simple. | +| 19 | **v2**: Does the service layer enforce role checks? | No. Authorization lives in the HTTP/Permit.io layer. The service layer (`internal/customschema/`) is role-agnostic and remains easy to test without fake identity contexts. | +| 20 | **v3**: How does a document move to a newer schema version once it has metadata? | Explicit, destructive reset. `POST /super-admin/documents/{id}/reset-metadata` wipes all metadata rows and nulls `custom_schema_id` in a single transaction; the caller then re-binds via `PATCH /super-admin/documents/{id}/schema` and re-enters values via `POST /custom-metadata`. | +| 21 | **v3**: Why destructive instead of an in-place compatible-schema swap? | Keeps Invariant 5 strict and trivially auditable; no JSON Schema compatibility comparator to maintain in sync across app and DB; forces explicit re-validation of stored values against the new schema; preserves a clean audit trail. See Section 5.2 for the full rationale. | +| 22 | **v3**: Any trigger changes? | **None.** Triggers 1, 2, and 3 are unchanged. The reset endpoint deletes metadata rows *before* updating `custom_schema_id`, so Trigger 1's `EXISTS` check observes an empty set and permits the UPDATE. No relaxation or bypass of any trigger. | +| 23 | **v3**: Any new Permit.io resource or action? | **None.** The existing `super-admin:post` grant on the `super_admin` role covers `POST /super-admin/documents/{id}/reset-metadata` because the first path segment is already `super-admin`. Only a policy-mapping doc entry is added. | +| 24 | **v3**: New migrations or tables? | **None.** The endpoint reuses `DeleteDocumentCustomMetadata` and `NullifyDocumentCustomSchemaId` queries already added for the delete cascade (Section 8.2). Three read-only queries (`LockDocumentForReset`, `GetDocumentSchemaBindingForReset`, `CountDocumentCustomMetadataVersions`) are added to `customschemas.sql`. No DDL. | + +--- + +## 14. v1 -> v2 Change Log + +For reviewers comparing to `plans/mutable.metadata.plan.combo.md`: + +| Area | v1 | v2 | +|------|----|----| +| Schema CRUD path | `/admin/custom-schemas` | `/super-admin/custom-schemas` | +| Single-doc schema assignment path | `/admin/document/{id}/schema` | `/super-admin/documents/{id}/schema` | +| Bulk folder assignment path | `/admin/folders/{folderId}/assign-schema` | `/super-admin/folders/{folderId}/assign-schema` | +| Permit.io resource for schema admin | `admin` (shared by `user_admin` and `super_admin`) | NEW `super-admin` (held only by `super_admin`) | +| `user_admin` schema CRUD | Allowed (unintentional) | Denied | +| `user_admin` schema assignment | Allowed (unintentional) | Denied | +| `user_admin` custom metadata read/write | Allowed | Allowed (unchanged) | +| `client_user` custom metadata read/write | Allowed | Allowed (unchanged) | +| `auditor` read schemas / metadata | Allowed (read-only) | Allowed (read-only, now via `super-admin:get` grant) | +| OpenAPI tag for schema admin | `AdminService` | NEW `SuperAdminSchemaService` | +| Service layer | Role-agnostic | Role-agnostic (unchanged) | +| Database schema, triggers, migrations | Unchanged | Unchanged | +| Validation strategy, size limits, constants | Unchanged | Unchanged | + +No database migration or data change is required to move from v1 to v2 -- this is strictly a routing and authorization change. If v1 were already deployed, the migration would be: (1) add the new Permit.io resource and grants, (2) add the new endpoints alongside the old ones, (3) point clients at the new paths, (4) remove the old paths once all clients have migrated. Since v1 is not deployed, v2 simply supersedes it. + +--- + +## 15. v2 -> v3 Change Log + +For reviewers comparing to `plans/mutable.metadata.plan.combo.v2.md`: + +| Area | v2 | v3 | +|------|----|----| +| Reset-metadata endpoint | Not present | NEW `POST /super-admin/documents/{id}/reset-metadata` (Section 5.2) | +| Service method | Not present | NEW `ResetDocumentMetadata(ctx, documentID, resetBy)` on `CustomSchemaService` (Section 7.1) | +| Response type | Not present | NEW `ResetDocumentMetadataResponse` OpenAPI schema (Section 8.3) | +| SQLC queries | Not present | NEW `LockDocumentForReset`, `GetDocumentSchemaBindingForReset`, `CountDocumentCustomMetadataVersions` in `customschemas.sql`; reuses existing `DeleteDocumentCustomMetadata`, `NullifyDocumentCustomSchemaId`, `DocumentHasLegacyExtractions` (Section 8.2) | +| Database migrations | 4 migrations (120-123) | Unchanged -- no new migrations | +| Database triggers | Triggers 1, 2, 3 | Unchanged -- triggers are reused as-is. Reset works by clearing the state that Trigger 1 guards against, not by bypassing the trigger. | +| Permit.io resources | `super-admin`, `custom-metadata` | Unchanged -- reset inherits `super-admin:post` | +| Permit.io policy mapping doc | Lists 4 super_admin endpoints | Adds `POST /super-admin/documents/{id}/reset-metadata` -> `super-admin:post` (Section 5.3) | +| Authorization matrix | 5 columns (CRUD, Assignment, Read, Write) | Adds a "Reset Metadata" column; `super_admin` = Yes, everyone else = No (Section 5.3) | +| Invariants table | 21 rows | Adds rows 22-25 covering atomicity, role confinement, legacy-extraction rejection, and idempotency of reset (Section 9) | +| Resolved design decisions | 19 entries | Adds entries 20-24 explaining the destructive-reset decision, why not in-place, trigger/resource/migration invariance (Section 13) | +| Implementation order | 12 phases | Inserts Phase 10b (reset-metadata endpoint) between bulk-folder assignment and integration tests (Section 11) | +| Testing strategy | Role-based authz, bulk assign, cascades, concurrency, etc. | Adds reset-metadata test block: happy path, atomicity under injected failure, idempotency, legacy-extraction guard, not-found, trigger-still-fires, authz matrix, concurrency with itself and with assign-schema, audit log (Section 12) | +| Documentation updates | Sample schemas, authorization matrix | Adds reset-metadata runbook ("how to upgrade a document to a new schema version") to `docs/ai.generated/` | +| Database schema, migrations, validators, constants | Unchanged | Unchanged | +| Existing endpoints (`/custom-metadata/*`, `PATCH /super-admin/documents/{id}/schema`, `POST /super-admin/folders/{folderId}/assign-schema`, schema CRUD) | As specified in v2 | Unchanged | + +No database migration or data change is required to move from v2 to v3. The reset-metadata endpoint is purely additive: new service method, new handler, new OpenAPI schema, new SQLC queries, new policy mapping doc entry, new test cases. If v2 were already deployed, the migration would be: (1) ship the new code, (2) ship the new OpenAPI client so super_admin callers can invoke the endpoint, (3) no Permit.io re-run required because `super-admin:post` is already granted. Since v2 is not deployed, v3 simply supersedes it. + +--- + +## Appendix A: Sample JSON Schemas + +These examples demonstrate all supported JSON Schema features and data types. They should be included in the API documentation. + +### A.1 Comprehensive Schema (All Supported Types) + +```json +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "document_title": { + "type": "string", + "minLength": 1, + "maxLength": 500, + "description": "Title of the document" + }, + "document_code": { + "type": "string", + "pattern": "^[A-Z]{3}-[0-9]{4}$", + "description": "Document code in format XXX-0000" + }, + "status": { + "type": "string", + "enum": ["draft", "review", "approved", "rejected", "archived"], + "description": "Current document status" + }, + "review_date": { + "type": "string", + "format": "date", + "description": "Date of last review (YYYY-MM-DD)" + }, + "created_timestamp": { + "type": "string", + "format": "date-time", + "description": "Creation timestamp (ISO 8601)" + }, + "page_count": { + "type": "integer", + "minimum": 1, + "maximum": 10000, + "description": "Total number of pages" + }, + "confidence_score": { + "type": "number", + "minimum": 0.0, + "maximum": 1.0, + "description": "Extraction confidence (0.0 to 1.0)" + }, + "is_verified": { + "type": "boolean", + "description": "Whether the extraction has been human-verified" + }, + "tags": { + "type": "array", + "items": { "type": "string", "maxLength": 50 }, + "minItems": 0, + "maxItems": 20, + "description": "Classification tags" + }, + "numeric_values": { + "type": "array", + "items": { "type": "number" }, + "maxItems": 100, + "description": "Extracted numeric values" + }, + "contact_info": { + "type": "object", + "properties": { + "name": { "type": "string", "maxLength": 200 }, + "email": { "type": "string", "format": "email" }, + "phone": { "type": "string", "pattern": "^\\+?[0-9\\-\\s()]+$" } + }, + "required": ["name"], + "additionalProperties": false, + "description": "Primary contact information" + } + }, + "required": ["document_title", "status"], + "additionalProperties": false +} +``` + +### A.2 Aircraft Engineering Schema (Domain Example) + +```json +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "aircraft_model": { + "type": "string", + "maxLength": 100 + }, + "manufacturer": { + "type": "string", + "enum": ["Boeing", "Airbus", "Embraer", "Bombardier", "Other"] + }, + "certification_date": { + "type": "string", + "format": "date" + }, + "max_altitude_ft": { + "type": "integer", + "minimum": 0, + "maximum": 100000 + }, + "max_range_nm": { + "type": "number", + "minimum": 0 + }, + "engine_count": { + "type": "integer", + "minimum": 1, + "maximum": 12 + }, + "engine_type": { + "type": "string", + "enum": ["turbofan", "turboprop", "turbojet", "piston", "electric"] + }, + "wingspan_meters": { + "type": "number", + "minimum": 0, + "maximum": 100 + }, + "is_pressurized": { + "type": "boolean" + }, + "applicable_regulations": { + "type": "array", + "items": { + "type": "string", + "pattern": "^(FAR|EASA|CAAC)-[0-9A-Z\\.]+$" + }, + "maxItems": 50 + } + }, + "required": ["aircraft_model", "manufacturer"], + "additionalProperties": false +} +``` + +### A.3 Auto Engineering Schema (Domain Example) + +```json +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "vehicle_make": { + "type": "string", + "maxLength": 100 + }, + "vehicle_model": { + "type": "string", + "maxLength": 100 + }, + "model_year": { + "type": "integer", + "minimum": 1900, + "maximum": 2100 + }, + "vin": { + "type": "string", + "pattern": "^[A-HJ-NPR-Z0-9]{17}$", + "description": "17-character Vehicle Identification Number" + }, + "engine_displacement_liters": { + "type": "number", + "minimum": 0, + "maximum": 20 + }, + "horsepower": { + "type": "integer", + "minimum": 0 + }, + "transmission_type": { + "type": "string", + "enum": ["manual", "automatic", "cvt", "dct", "ev"] + }, + "is_electric": { + "type": "boolean" + }, + "safety_ratings": { + "type": "object", + "properties": { + "nhtsa_overall": { "type": "integer", "minimum": 1, "maximum": 5 }, + "iihs_rating": { "type": "string", "enum": ["Poor", "Marginal", "Acceptable", "Good", "Good+"] } + }, + "additionalProperties": false + }, + "recall_ids": { + "type": "array", + "items": { "type": "string", "maxLength": 20 }, + "maxItems": 100 + } + }, + "required": ["vehicle_make", "vehicle_model", "model_year"], + "additionalProperties": false +} +``` + +### Supported JSON Schema Data Types Summary + +| JSON Schema Type | Go Equivalent | Example Constraint Keywords | +|-----------------|---------------|---------------------------| +| `string` | `string` | `minLength`, `maxLength`, `pattern`, `format`, `enum` | +| `integer` | `int64` | `minimum`, `maximum` | +| `number` | `float64` | `minimum`, `maximum` | +| `boolean` | `bool` | (none) | +| `array` | `[]T` | `items`, `minItems`, `maxItems` | +| `object` | `map/struct` | `properties`, `required`, `additionalProperties` | + +**Supported `format` values**: `date`, `date-time`, `email`, `uri`, `uuid` + + +## Implemnentation journal +In order to be able to have another agent pick up the work in progress, all completed work and milestones must be journaled to the ./journals/implement_mutableMetadata.md + +Each time a milestone or signficant part of a milestone is completed there needs to be a date/time stamped journal entry showing the item is done. So that a new agent taking over will know exactly where we are in the process and can resume the work. + +At any give time I should be able to clear your context and have you resume your work based on this document, the code (git diffs) and the journal of what is completed. + +If we run into any issues where Q has to be consulted about an issue that needs a decision , then Qs answer should also be written to the journal along with the question. + +### Updating the plans/mutable.metadata.plan.combo.v3.tracking.md doc. +As we progress through the phases of development , as each task is verified complete it should be marked off (with the date and time) next to the item. diff --git a/plans/mutable.metadata.plan.combo.v3.tracking.md b/plans/mutable.metadata.plan.combo.v3.tracking.md new file mode 100644 index 00000000..57f5aab7 --- /dev/null +++ b/plans/mutable.metadata.plan.combo.v3.tracking.md @@ -0,0 +1,652 @@ +# 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:ci` clean for the affected code paths) +- `[!]` = blocked -- add a short `(blocked: )` suffix and open a journal entry +- `[-]` = consciously skipped -- add a short `(skipped: )` 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 `COMPLETE` and date-stamp it. +- Never mark a phase complete until `task fullsuite:ci` ends with `All coverage checks passed!` with no `FAIL` lines. + +--- + +## 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 at `127` to 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.sql` with `CREATE TYPE schema_status_type AS ENUM ('active', 'superseded', 'retired')` -- test: migration applies cleanly on empty DB +- [ ] Same migration creates `client_metadata_schemas` table per Section 4.2 (columns, PK, FK to `clients`, `schema_def jsonb`, `version`, `status`, timestamps, `created_by`) -- test: `\d client_metadata_schemas` shows 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_schemas` shows the three indexes +- [ ] Create `00000000000127_create_client_metadata_schemas.down.sql` dropping the table and enum in correct order -- test: up then down leaves DB state byte-identical to pre-migration +- [ ] Run `task db:generate` after migration lands -- test: no errors + +### 1.2 Migration 128 - `document_custom_metadata` table + view + +- [ ] Create `00000000000128_create_document_custom_metadata.up.sql` with `document_custom_metadata` table 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_metadata` view 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 = X` returns 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.sql` that drops index then column -- test: round trip clean +- [ ] Verify existing documents rows have `custom_schema_id = NULL` after migration -- test: `SELECT COUNT(*) FROM documents WHERE custom_schema_id IS NOT NULL` returns 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 in `pg_proc` +- [ ] Attach trigger to `documents` BEFORE UPDATE OF `custom_schema_id` -- test: trigger listed in `\d documents` +- [ ] Trigger 1 unit test: insert document, assign schema, write custom metadata, attempt UPDATE `custom_schema_id` -- expect `RAISE EXCEPTION` with "custom metadata already exists" message +- [ ] Trigger 1 unit test: insert document, add legacy extraction row, attempt UPDATE `custom_schema_id` -- expect `RAISE EXCEPTION` with "legacy field extractions already exist" message +- [ ] Trigger 1 unit test: insert document with no metadata and no extractions, UPDATE `custom_schema_id` from 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 `documents` BEFORE INSERT OR UPDATE OF `custom_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_metadata` BEFORE 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.sql` dropping 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.go` +- [ ] `GetClientMetadataSchema` (by id) -- test: generated +- [ ] `ListClientMetadataSchemas` (filters: client_id required, name optional, status optional with default `active`, pagination limit/offset) -- test: generated +- [ ] `GetLatestSchemaByName` -- test: generated +- [ ] `GetSchemaDocumentCount` (count of `documents.custom_schema_id = $1`) -- test: generated +- [ ] `GetSchemaMetadataRecordCount` (count of `document_custom_metadata.schema_id = $1`) -- test: generated +- [ ] `SetSchemaStatus` (update status, used for supersede and retire) -- test: generated +- [ ] `GetMaxSchemaVersion` (for auto-increment on PUT) -- test: generated +- [ ] `LockSchemaVersionsForName` (`SELECT ... FOR UPDATE` on all versions of a `(client_id, name)` pair) -- test: generated + +**Metadata CRUD queries:** + +- [ ] `CreateDocumentCustomMetadata` -- test: generated +- [ ] `GetCurrentDocumentCustomMetadata` (via view) -- test: generated +- [ ] `GetDocumentCustomMetadataByVersion` -- test: generated +- [ ] `GetDocumentCustomMetadataHistory` (with limit/offset) -- test: generated +- [ ] `LockDocumentCustomMetadataForVersion` (`SELECT ... FOR UPDATE` on max version) -- test: generated + +**Document-binding queries:** + +- [ ] `SetDocumentCustomSchemaId` -- test: generated +- [ ] `GetDocumentCustomSchemaId` -- test: generated +- [ ] `BulkSetDocumentCustomSchemaIdInFolderTree` (`WITH RECURSIVE`) -- test: generated; recursive CTE compiles +- [ ] `GetDocumentsWithMetadataInFolderTree` -- test: generated +- [ ] `GetDocumentsWithLegacyExtractionsInFolderTree` -- test: generated +- [ ] `DocumentHasLegacyExtractions` (boolean, single doc) -- test: generated +- [ ] `CountDocumentsInFolderTree` (for `MaxBulkAssignDocuments` check) -- test: generated + +**Reset-metadata support queries (v3):** + +- [ ] `LockDocumentForReset` (`SELECT id, "clientId", custom_schema_id FROM documents WHERE id = $1 FOR UPDATE`) -- test: generated +- [ ] `GetDocumentSchemaBindingForReset` (join `documents` -> `client_metadata_schemas`, returns `(custom_schema_id, schema_name, schema_version)` or all-null) -- test: generated +- [ ] `CountDocumentCustomMetadataVersions` -- test: generated + +### 2.2 Query additions for delete cascade (Phase 5b prep) + +- [ ] Add `DeleteDocumentCustomMetadata` to `document.sql` -- test: generated +- [ ] Add `NullifyDocumentCustomSchemaId` to `document.sql` -- test: generated +- [ ] Add `DeleteClientMetadataSchemas` to `client.sql` -- test: generated + +### 2.3 Phase 2 integration + +- [ ] `task generate` clean -- 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/v6` to `go.mod` -- test: `go mod tidy` clean, build clean + +### 3.2 Constants file + +- [ ] Create `internal/customschema/constants.go` with `MaxSchemaDefinitionBytes = 65536`, `MaxMetadataPayloadBytes = 1048576`, `MaxBulkAssignDocuments = 10000` -- test: constants referenced by tests + +### 3.3 Validator implementation + +- [ ] Create `internal/customschema/validator.go` with `SchemaValidator` struct -- 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 `type` is not `"object"` -- test: TDD test with `{"type": "array"}` +- [ ] Reject schema definitions where root `additionalProperties` is not explicitly present -- test: TDD test with schema missing the key +- [ ] Accept schema definitions where root `additionalProperties` is explicitly `true` -- test: TDD test +- [ ] Accept schema definitions where root `additionalProperties` is explicitly `false` -- 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 `ValidationError` with field pointer and message -- test: TDD +- [ ] `maxLength` violation produces structured error -- test: TDD +- [ ] `minimum`/`maximum` violation produces structured error -- test: TDD +- [ ] `pattern` violation produces structured error -- test: TDD +- [ ] `enum` violation produces structured error -- test: TDD +- [ ] `format: date` / `date-time` / `email` / `uri` / `uuid` enforced -- test: one TDD test per format +- [ ] `additionalProperties: false` rejects extra fields -- test: TDD +- [ ] Nested object validation works (e.g., `contact_info` from A.1) -- test: TDD +- [ ] Array `items` / `minItems` / `maxItems` enforced -- test: TDD +- [ ] Metadata larger than `MaxMetadataPayloadBytes` rejected -- test: TDD with 1MB+1 payload + +### 3.4 Phase 3 integration + +- [ ] `go test ./internal/customschema/...` green -- test: full package green +- [ ] `task test:race` green 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.go` with `Service` struct holding `cfg serviceconfig.ConfigProvider` and `validator *SchemaValidator` -- test: compiles +- [ ] Create `internal/customschema/models.go` with `Schema`, `SchemaSummary`, `CreateSchemaInput`, `UpdateSchemaInput`, `ListFilters`, `CustomMetadata`, `MetadataVersion`, `SetMetadataInput`, `BulkAssignResult`, `SkippedDocument`, `ResetMetadataResult` -- test: compiles +- [ ] Every `*Input` struct that historically carried `CreatedBy string` is 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 read `CreatedBy` off a request body model. -- test: compiles + unit test that asserts passing an input with a non-empty `CreatedBy` is ignored in favor of the `actor` argument (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) or `internal/audit/` (shared) -- test: decision recorded in journal +- [ ] Define `AuditRecord` struct: `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 `AuditSink` interface with `Record(ctx, AuditRecord) error` -- test: compiles +- [ ] Provide a default implementation (structured `slog` with `audit=true` attribute, or a DB-backed appender if Q decides) -- test: unit test verifies a recorded event lands in the sink +- [ ] Wire the sink into `customschema.Service` via `serviceconfig.ConfigProvider` or a constructor arg -- test: compiles + +### 4.2 Schema CRUD methods + +- [ ] `CreateSchema(ctx, input)` -- meta-validates, enforces 64KB, enforces explicit `additionalProperties`, enforces `name` unique within client for active schemas at version 1 -- test: happy path +- [ ] `CreateSchema` rejects invalid JSON Schema -- test: TDD +- [ ] `CreateSchema` rejects oversize definition -- test: TDD +- [ ] `CreateSchema` rejects duplicate name for same client -- test: TDD +- [ ] `UpdateSchema(ctx, schemaID, input)` -- uses `LockSchemaVersionsForName` -> `GetMaxSchemaVersion` -> INSERT new version -> SetSchemaStatus(old, 'superseded') inside a single tx -- test: happy path produces v2 +- [ ] `UpdateSchema` concurrency: two concurrent calls against same `(client_id, name)` produce distinct version numbers -- test: TDD with goroutines against testcontainer DB +- [ ] `UpdateSchema` rejects when old version does not exist -- test: TDD +- [ ] `UpdateSchema` rejects invalid schema definition (same validator rules as create) -- test: TDD +- [ ] `GetSchema(ctx, schemaID)` -- test: returns full row including `schema_def` +- [ ] `GetSchema` 404 when not found -- test: TDD +- [ ] `ListSchemas(ctx, clientID, filters)` -- default returns only `active` -- test: TDD +- [ ] `ListSchemas` filter by `name` -- test: TDD +- [ ] `ListSchemas` filter by `status` -- test: TDD +- [ ] `ListSchemas` `includeAllVersions=true` returns all versions -- test: TDD +- [ ] `ListSchemas` pagination (`limit`, `offset`) -- test: TDD +- [ ] `ListSchemas` populates `documentCount` and `canDelete` fields -- test: TDD +- [ ] `DeleteSchema(ctx, schemaID)` -- sets status to `retired` -- test: happy path +- [ ] `DeleteSchema` returns 409 if any document references the schema via `custom_schema_id` -- test: TDD +- [ ] `DeleteSchema` returns 409 if any `document_custom_metadata.schema_id` row references it -- test: TDD + +### 4.3 Metadata methods + +- [ ] `SetDocumentMetadata(ctx, input)` -- derives `schemaId` from `documents.custom_schema_id`, never trusts client -- test: happy path +- [ ] `SetDocumentMetadata` rejects when document has no `custom_schema_id` -- test: TDD (400) +- [ ] `SetDocumentMetadata` rejects when document has legacy extractions -- test: TDD (409) +- [ ] `SetDocumentMetadata` validates metadata against schema via `validator.ValidateMetadata` -- test: TDD with conforming and non-conforming payloads +- [ ] `SetDocumentMetadata` enforces `MaxMetadataPayloadBytes` -- test: TDD +- [ ] `SetDocumentMetadata` serializes version assignment by first taking `SELECT id FROM documents WHERE id = $1 FOR UPDATE` on the parent row, then reading `GetMaxDocumentMetadataVersion`, then INSERTing `version = max+1` -- test: TDD confirms the parent-row lock is acquired before the version read +- [ ] `SetDocumentMetadata` concurrency: 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 DB +- [ ] `SetDocumentMetadata` concurrency: two concurrent **first** writers against a document with zero metadata rows -- both succeed, produce `version=1` and `version=2` in some order, no unique-violation surfaces to the caller -- test: TDD with goroutines, proves the parent-row lock (not just `FOR UPDATE` on the non-existent max-version row) is what serializes the first write +- [ ] `SetDocumentMetadata` retries once on `UNIQUE (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 attempt +- [ ] `SetDocumentMetadata` subsequent writes must use same schema (consistency with Trigger 3) -- test: TDD +- [ ] `GetCurrentMetadata(ctx, documentID)` -- test: uses view, returns latest +- [ ] `GetMetadataByVersion(ctx, documentID, version)` -- test: TDD +- [ ] `GetMetadataHistory(ctx, documentID)` with pagination -- test: TDD + +### 4.4 Document schema binding methods + +- [ ] `AssignSchema(ctx, documentID, schemaID)` -- test: happy path single-document assignment +- [ ] `AssignSchema` rejects if schema and document belong to different clients (app-layer + trigger 2) -- test: TDD +- [ ] `AssignSchema` rejects if schema status is not `active` -- test: TDD +- [ ] `AssignSchema` rejects if document already has custom metadata (matches Trigger 1 behavior; expect 409) -- test: TDD +- [ ] `AssignSchema` rejects if document has legacy extractions -- test: TDD +- [ ] `AssignSchema` allows setting `customSchemaId=nil` to clear binding when no metadata exists -- test: TDD +- [ ] `AssignSchemaToFolder(ctx, folderID, schemaID, createdBy)` -- test: walks subtree with `WITH RECURSIVE` and returns `BulkAssignResult` +- [ ] Bulk assignment skips documents with different schema + existing metadata with `SkippedDocument` reason -- 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 `metadataVersionsDeleted` matching 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 `AssignSchema` on same document serialize -- test: TDD + +### 4.6 Phase 4 integration + +- [ ] `go test ./internal/customschema/...` green -- test: full package green +- [ ] `task test:race` green for package -- test: no races +- [ ] `task test:unit:short` still 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.CreateFieldExtraction` entry point (currently `internal/fieldextraction/service.go`) -- test: `grep` and read code +- [ ] Add a pre-check: read `documents.custom_schema_id` for 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 asserts `errors.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 `CreateFieldExtraction` handler in `api/queryAPI/fieldextractions.go` to branch on the service sentinel error and return `echo.NewHTTPError(http.StatusConflict, ...)` with the mutual-exclusivity message -- test: handler unit test asserts status 409 and body message +- [ ] Audit the other `FieldExtractionService` handler entry points in `api/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 is `409` and 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 `DeleteDocumentCascade` implementation -- test: found +- [ ] Insert step: `DeleteDocumentCustomMetadata` between legacy extractions delete and document row delete -- test: TDD +- [ ] Insert step: `NullifyDocumentCustomSchemaId` before 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_id` returns 0 -- test: TDD +- [ ] Post-delete: `client_metadata_schemas` rows 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: `DeleteClientMetadataSchemas` after 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_id` returns 0 -- test: TDD +- [ ] Post-delete: no orphaned `document_custom_metadata` rows -- 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 `SuperAdminSchemaService` tag with description explicitly stating the `super_admin`-only boundary -- test: spec lints +- [ ] Add `CustomMetadataService` tag with description stating `client_user` + `user_admin` + `super_admin` access -- test: spec lints + +### 6.2 Schemas + +- [ ] `CustomSchemaRequest` -- test: codegen produces Go type +- [ ] `CustomSchemaResponse` -- test: codegen +- [ ] `CustomSchemaListResponse` -- test: codegen +- [ ] `CustomMetadataRequest` -- test: codegen +- [ ] `CustomMetadataResponse` -- test: codegen +- [ ] `CustomMetadataHistoryResponse` -- test: codegen +- [ ] `ValidationErrorResponse` -- test: codegen +- [ ] `BulkSchemaAssignRequest` -- test: codegen +- [ ] `BulkSchemaAssignResponse` -- test: codegen +- [ ] `DocumentSchemaAssignRequest` -- test: codegen +- [ ] `DocumentSchemaAssignResponse` -- test: codegen +- [ ] `ResetDocumentMetadataResponse` (v3) -- test: codegen +- [ ] `SchemaStatus` enum (`active`/`superseded`/`retired`) -- test: codegen +- [ ] Modify `DocumentEnriched` to add `customSchemaId`, `customSchemaName`, `hasCustomMetadata`, `customMetadata` -- test: codegen + +### 6.3 Paths + +- [ ] `POST /super-admin/custom-schemas` -- test: codegen produces handler interface +- [ ] `GET /super-admin/custom-schemas` (with query params) -- test: codegen +- [ ] `GET /super-admin/custom-schemas/{schemaId}` -- test: codegen +- [ ] `PUT /super-admin/custom-schemas/{schemaId}` -- test: codegen +- [ ] `DELETE /super-admin/custom-schemas/{schemaId}` -- test: codegen +- [ ] `PATCH /super-admin/documents/{id}/schema` -- test: codegen +- [ ] `POST /super-admin/folders/{folderId}/assign-schema` -- test: codegen +- [ ] `POST /super-admin/documents/{id}/reset-metadata` (v3) -- test: codegen +- [ ] `GET /custom-metadata` -- test: codegen +- [ ] `POST /custom-metadata` -- test: codegen +- [ ] `GET /custom-metadata/version` -- test: codegen +- [ ] `GET /custom-metadata/history` -- test: codegen +- [ ] Modify `GET /document/{id}` to document new fields and the `customMetadata` query parameter -- test: codegen + +### 6.4 Phase 6 integration + +- [ ] `task generate` clean -- 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-admin` resource with `get`, `post`, `patch`, `delete` actions -- test: YAML valid +- [ ] Add `custom-metadata` resource with `get`, `post` actions -- test: YAML valid +- [ ] Grant `super_admin` role: `super-admin: [get, post, patch, delete]`, `custom-metadata: [get, post]` -- test: YAML valid +- [ ] Confirm `user_admin` role NOT granted `super-admin` -- test: visual diff against v1 intent; test-assertion in Phase 11 +- [ ] Grant `user_admin` role: `custom-metadata: [get, post]` -- test: YAML valid +- [ ] Grant `auditor` role: `super-admin: [get]`, `custom-metadata: [get]` -- test: YAML valid +- [ ] Grant `client_user` role: `custom-metadata: [get, post]` -- test: YAML valid +- [ ] Add documentation-only `policy_mappings` entries for every `/super-admin/*` path including `POST /super-admin/documents/{id}/reset-metadata` -- test: YAML valid +- [ ] Add documentation-only `policy_mappings` entries 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 `put` action to `super-admin` resource 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-admin` resource exists in dev Permit.io tenant -- test: list +- [ ] Verify `custom-metadata` resource exists in dev -- test: list +- [ ] Verify `super_admin` role has expected grants in dev -- test: role inspect +- [ ] Verify `user_admin` role does NOT have `super-admin` grants in dev -- test: role inspect +- [ ] Run `run.tool.uat.sh` after dev verified -- test: exit 0 +- [ ] Same verification pass on uat -- test: list + role inspect +- [ ] Run `run.tool.prod.sh` after 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; any `createdBy` on the request body is ignored -- test: handler unit test sets a JWT subject via middleware fixture, posts a body with a different `createdBy`, asserts the service receives the JWT subject +- [ ] `POST /super-admin/custom-schemas` handler -- parse, validate, derive actor from JWT, call `CreateSchema`, map errors to 400/409/500 -- test: TDD handler test +- [ ] `GET /super-admin/custom-schemas` handler with query params -- test: TDD +- [ ] `GET /super-admin/custom-schemas/{schemaId}` handler -- test: TDD +- [ ] `PUT /super-admin/custom-schemas/{schemaId}` handler -- derives actor from JWT -- test: TDD +- [ ] `DELETE /super-admin/custom-schemas/{schemaId}` handler -- derives actor from JWT -- test: TDD including 409 path +- [ ] `PATCH /super-admin/documents/{id}/schema` handler -- derives actor from JWT -- test: TDD +- [ ] `POST /super-admin/folders/{folderId}/assign-schema` handler -- derives actor from JWT -- test: TDD (covered more deeply in Phase 10) +- [ ] `POST /super-admin/documents/{id}/reset-metadata` handler (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-metadata` derives actor identity from the JWT and ignores any body-supplied `createdBy` -- test: TDD handler test asserts body `createdBy` does not override +- [ ] `POST /custom-metadata` handler -- test: TDD +- [ ] `GET /custom-metadata` handler -- test: TDD +- [ ] `GET /custom-metadata/version` handler -- test: TDD +- [ ] `GET /custom-metadata/history` handler -- 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 `DocumentEnriched` builder to include `customSchemaId`, `customSchemaName`, `hasCustomMetadata` unconditionally -- test: TDD +- [ ] Extend builder to include `customMetadata` when `?customMetadata=true` -- test: TDD +- [ ] Authorization: confirm `GET /document/{id}` still uses the existing `document` resource, not `super-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: `, `hasCustomMetadata: false` -- test: TDD +- [ ] Document with schema + metadata returns populated fields -- test: TDD +- [ ] `PATCH /super-admin/documents/{id}/schema` handler delegates to `AssignSchema` -- 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 `documentsUpdated` count -- 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 `resetBy` field -- 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_admin` CAN `POST /super-admin/custom-schemas` -- test: integration +- [ ] `super_admin` CAN `GET /super-admin/custom-schemas` -- test: integration +- [ ] `super_admin` CAN `GET /super-admin/custom-schemas/{id}` -- test: integration +- [ ] `super_admin` CAN `PUT /super-admin/custom-schemas/{id}` -- test: integration +- [ ] `super_admin` CAN `DELETE /super-admin/custom-schemas/{id}` -- test: integration +- [ ] `super_admin` CAN `PATCH /super-admin/documents/{id}/schema` -- test: integration +- [ ] `super_admin` CAN `POST /super-admin/folders/{folderId}/assign-schema` -- test: integration +- [ ] `super_admin` CAN `POST /super-admin/documents/{id}/reset-metadata` -- test: integration +- [ ] `user_admin` gets 403 on every `/super-admin/*` path+method combination (8 tests) -- test: integration, one per route +- [ ] `user_admin` CAN read + write `/custom-metadata` (no regression) -- test: integration +- [ ] `client_user` gets 403 on every `/super-admin/*` path+method combination (8 tests) -- test: integration +- [ ] `client_user` CAN read + write `/custom-metadata` -- test: integration +- [ ] `auditor` CAN `GET` on `/super-admin/custom-schemas` and `/super-admin/custom-schemas/{id}` -- test: integration +- [ ] `auditor` gets 403 on every `/super-admin/*` write path (POST/PUT/PATCH/DELETE) -- test: integration +- [ ] `auditor` CAN `GET /custom-metadata`, CANNOT `POST /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` -> `superseded` on PUT create new version -- test: integration +- [ ] `active` -> `retired` on DELETE -- test: integration +- [ ] Assignment rejected for non-`active` status -- 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_id` on a document with metadata -- trigger fires +- [ ] Direct SQL bypass: INSERT `document_custom_metadata` with mismatched `schema_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-extractions` endpoints unchanged for documents with no custom schema -- test: integration +- [ ] Existing `/admin/*` endpoints unaffected by `super-admin` resource -- test: integration +- [ ] `DocumentEnriched` for 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 -cover` +- [ ] `api/queryAPI/customschemas.go` + `custommetadata.go` >= 80% coverage -- test: `go test -cover` +- [ ] `task test:race` green -- test: no race reports +- [ ] `task test:perf` checked for regressions in existing hot paths -- test: review output +- [ ] `task fullsuite:ci` ends with `All coverage checks passed!` and NO `FAIL` lines -- 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.md` index 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 +- [ ] `CustomMetadataService` usage guide (end-user perspective) -- test: review +- [ ] `SuperAdminSchemaService` usage 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.md` with 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:ci` must be green at the end of every phase, not just the end of the project. Do not stack broken phases. diff --git a/plans/mutable.metadata.plan.combo.v4.md b/plans/mutable.metadata.plan.combo.v4.md new file mode 100644 index 00000000..4e4c6863 --- /dev/null +++ b/plans/mutable.metadata.plan.combo.v4.md @@ -0,0 +1,2008 @@ +# Mutable Metadata & Per-Client Custom Schemas - Combined Design Plan (v4) + +## Status: DRAFT - Combined Claude + Codex + Jay's recommendations (2026-03-24, rev 2026-03-31 post-review, rev 2026-04-10: schema administration restricted to `super_admin` role only, rev 2026-04-13: add reset-metadata escape hatch, rev 2026-04-14: v4 simplification pass) + +**Lineage**: This plan was informed by two original detailed specs done adversarily. One by claude Opus 4.6 and one by Codex 5.4 (xhigh) based on human written requirements. Each was human reviewed for common items and conflicting items. Where there were conflicts the human was the final decision maker. This resulting hybrid document represents an improved design over each of the source documents. + +**v2 change summary (2026-04-10)**: In v1 all mutable-metadata administrative endpoints lived under `/admin/*`. Because the Permit.io middleware maps resources from the first path segment and both `user_admin` and `super_admin` hold full permissions on the `admin` resource, v1 exposed schema CRUD and schema assignment to the lesser (client-scoped) `user_admin` role. v2 moves every schema administration endpoint onto a new `/super-admin/*` path prefix backed by a new Permit.io resource named `super-admin` that only the `super_admin` role can access. User-facing custom metadata read/write endpoints (`/custom-metadata/*`) are unchanged. The underlying service layer, database schema, triggers, and validation strategy are unchanged -- this is strictly an authorization and routing change. + +**v3 change summary (2026-04-13)**: Adds a single new super_admin endpoint, `POST /super-admin/documents/{id}/reset-metadata`, to support upgrading a document to a newer schema version when the document already has custom metadata written. Once custom metadata exists, Trigger 1 (`trg_prevent_schema_reassignment`) freezes `documents.custom_schema_id` permanently. The practical effect is that documents cannot move to a revised schema even when the new schema is a strict additive superset (e.g., one new optional field). v3 adds an explicit opt-in path: reset-metadata wipes all `document_custom_metadata` rows for the document and nulls `documents.custom_schema_id` in a single transaction, after which the existing `PATCH /super-admin/documents/{id}/schema` and `POST /custom-metadata` endpoints can bind a new schema and re-enter values. The escape hatch is deliberately destructive (metadata version history for the document is lost) so the invariant "schema is frozen once metadata exists" remains true -- you are explicitly opting out of "metadata exists" first. + +**v4 change summary (2026-04-14)**: Applies a simplification pass that removes redundant state and replaces imperative trigger code with declarative constraints while preserving every feature designed in v3 (schema CRUD, single-doc assign, bulk folder assign, reset-metadata, mutual exclusivity, schema versioning). The full v3 -> v4 change log is in Section 16. Core simplifications: + +1. **`document_custom_metadata.schema_id` removed.** The document row (`documents.custom_schema_id`) is already the single source of truth for schema binding (Trigger 1 freezes it once metadata exists; reset-metadata wipes rows before rebinding). Keeping a second schema pointer on every metadata row was redundant. Dropping it removes one index (`idx_dcm_schema_id`), one trigger (Trigger 3 `trg_enforce_consistent_schema_id`), one SQLC query (`GetSchemaMetadataRecordCount`), and an entire class of app-layer consistency checks. All invariants still hold. +2. **v3's Trigger 2 replaced by a composite foreign key.** The "schema must belong to same client as document" invariant is expressed as `FOREIGN KEY (custom_schema_id, "clientId") REFERENCES client_metadata_schemas(id, client_id)` on the `documents` table. Declarative integrity replaces PL/pgSQL. v4 keeps **two** triggers: Trigger 1 (`trg_prevent_schema_reassignment`) blocks schema reassignment once metadata or legacy extractions exist, and a new Trigger 2 (`trg_prevent_legacy_extraction_on_custom_document`) blocks legacy field extraction inserts on documents already bound to a custom schema. Together they close the concurrent-writer race between `AssignSchema` and `CreateFieldExtraction` (see Section 7.3). +3. **`current_document_custom_metadata` view removed.** The new metadata table is simple enough that `ORDER BY version DESC LIMIT 1` using `idx_dcm_doc_version_desc` is clearer and has zero overhead compared to a `DISTINCT ON` view. +4. **FK cascades handle delete cleanup.** `document_custom_metadata.document_id` uses `ON DELETE CASCADE`. `client_metadata_schemas.client_id` uses `ON DELETE CASCADE`. Hard-deleting a document automatically removes its custom metadata rows; hard-deleting a client automatically removes its schemas after its documents are gone. **The existing `DeleteDocumentCascade` and `client.HardDelete` code paths are not modified by v4.** The v3 claim that `custom_schema_id` must be nulled before a document delete was incorrect -- deleting the referencing row removes the reference without needing to null it first. The only surviving "manual delete" code is inside reset-metadata (which explicitly wipes metadata while keeping the document row alive) -- see Section 5.2. +5. **`POST /super-admin/custom-schemas/{schemaId}/versions`** replaces `PUT /super-admin/custom-schemas/{schemaId}`. The operation creates a new schema row with a new ID and supersedes the old one -- that is creation semantics, not update semantics. The new route matches the actual behavior, makes code review clearer, and sidesteps the unresolved Permit.io "`PUT` -> `patch` action mapping" question that the v3 plan deferred to implementation. +6. **`createdBy` removed from new request bodies; response `createdBy` is the Cognito subject ID, not an email.** v3 already said the server must derive actor identity from the JWT and ignore any body-supplied `createdBy`. v4 drops the misleading field from the OpenAPI schemas of all new endpoints so the contract matches the behavior. The value used server-side is the JWT `sub` claim (an opaque Cognito subject UUID), sourced via `cognitoauth.GetUserSubject(c)`. Email is **not** guaranteed present in the JWT on this service and is deliberately not used. Response `createdBy` / `resetBy` fields on all new endpoints are therefore declared as plain strings in OpenAPI (no `format: email`) — see Section 8.3 "Actor fields". This is a deliberate contract divergence from existing endpoints elsewhere in `queryAPI.yaml` that use `format: email`; those are not touched by v4. +7. **`GET /document/{id}` enrichment trimmed.** v3 proposed adding `customSchemaId`, `customSchemaName`, `hasCustomMetadata`, and an optional `customMetadata` blob (gated by a `?customMetadata=true` query param) to the existing read path. v4 adds only `customSchemaId` and `hasCustomMetadata` and does **not** inline the metadata payload. Clients who need the full metadata call `GET /custom-metadata` (which already exists). This keeps a stable cross-cutting read path narrow. +8. **`documentCount` / `canDelete` kept on the schema list response.** These two fields are cheap to compute in a single subquery, prevent an extra round-trip for the delete-guard UX, and survived the simplification review. `previousVersionId` on the version-creation response and `customSchemaName` decorations on unrelated responses are dropped. +9. **Vertical slice milestones replace 12 horizontal phases.** Each milestone is testable end-to-end, so the feature is usable much earlier in the rollout. The full milestone shape is in Section 11 and drives the structure of `plans/mutable.metadata.plan.combo.v4.tracking.md`. + +No functional requirements were cut. Bulk folder assignment, reset-metadata, schema versioning, mutual exclusivity, and the `super_admin`-only authorization boundary are all preserved exactly as in v3. + +--- + +## 1. Problem Statement + +Today, all field extraction data is stored in a rigid 3-table schema with ~130 statically-defined columns (19 single-value fields + 112 array fields). Every client shares the same field definitions. This means: + +- Clients cannot define domain-specific fields (e.g., "aircraft engineering" vs. "auto engineering" document classes) +- Adding new fields requires database migrations and code changes across the full stack +- The system cannot accommodate varying data shapes per document class within a single client + +**Goal**: Allow each client to curate their own collections of document schemas, where each schema defines the custom fields (with types and constraints) that a document's metadata must conform to. Administration of these schemas (create, update, retire, list, assign to documents/folders) is a platform-level responsibility and must be restricted to the `super_admin` role, not delegated to the lower-privileged `user_admin` role. + +--- + +## 2. Current Architecture Summary + +### 2.1 Existing Tables + +``` +documentFieldExtractions -- 19 single-value fields (1:1 per document) +documentFieldExtractionVersions -- Version tracking with row-level locking +documentFieldExtractionArrayFields -- 112 array fields (1:N per extraction) +``` + +### 2.2 Existing API Endpoints + +| Method | Path | Description | +|--------|------|-------------| +| POST | `/field-extractions` | Create versioned extraction (singleFields + arrayFields) | +| GET | `/field-extractions` | Get current (latest version) extraction for a document | +| GET | `/field-extractions/version` | Get extraction by specific version | +| GET | `/field-extractions/history` | List all versions for a document | + +### 2.3 Key Relationships + +- `documents.id` -> `documentFieldExtractions.documentId` (1:N via versions) +- `documentFieldExtractions.id` -> `documentFieldExtractionArrayFields.fieldExtractionId` (1:N) +- `documentFieldExtractions.id` -> `documentFieldExtractionVersions.fieldExtractionId` (1:N) +- Documents belong to clients via `documents.clientId` +- No per-client customization of field definitions exists today + +### 2.4 Current Role Model (Relevant to This Plan) + +Two admin-class roles exist today, both configured in `cmd/auth_related/permit.setup/permit_policies.yaml`: + +- **`super_admin`**: Platform administrator. Holds full permissions across every resource (`admin`, `client`, `clients`, `document`, `documents`, `folders`, etc.). Intended for AArete-internal operators. +- **`user_admin`**: Client-scoped administrator. Primarily used for creating new client users via the admin user tool. Holds full permissions on the `admin` resource but intentionally does **not** hold permissions on `client`, `clients`, or `document` at the resource level. + +Because the middleware extracts the resource from the first path segment (`/admin/foo` -> resource `admin`), any endpoint placed under `/admin/*` is accessible to both `super_admin` and `user_admin`. v1 of this plan placed schema administration under `/admin/*` and therefore exposed schema CRUD and assignment to `user_admin`. v2 corrects this. + +--- + +## 3. Design Principles + +1. **Additive, not destructive** - The existing static field extraction system continues to work unchanged for documents that do not opt into custom schemas. Custom schemas are an additive capability. +2. **Mutual exclusivity** - A document uses EITHER the legacy static field extraction system OR the custom schema system, never both. This prevents ambiguity about which system is authoritative for a document's metadata. Once a document is assigned a custom schema, the legacy field extraction endpoints reject writes for that document, and vice versa. +3. **Schema-as-data** - Schemas are stored as JSON Schema documents in the database, not as DDL. No migrations needed when clients add/modify schemas. +4. **Immutable schema versions** - When a schema is updated, a new version (with new ID) is created. Old versions remain for historical reference. No backward compatibility checking is required between versions since each version gets a distinct ID. +5. **Schema binding is permanent** - Once custom metadata is written to a document with a schema ID, that schema ID cannot be changed on the document. This guarantees data integrity. +6. **JSON blob storage** - Custom metadata is stored as JSONB in PostgreSQL, validated against the schema at write time. This avoids dynamic column creation. +7. **Client isolation** - Schemas are scoped to clients. A client cannot reference another client's schema. +8. **Size limits** - Schema definitions are capped at 64KB (`MaxSchemaDefinitionBytes = 65536`). Metadata payloads are capped at 1MB (`MaxMetadataPayloadBytes = 1048576`). Bulk folder assignments are capped at 10,000 documents (`MaxBulkAssignDocuments = 10000`). All are tunable constants. +9. **No JSONB search/filter** - The system does NOT support querying or filtering documents by custom metadata values. Custom metadata is opaque storage validated on write. If search is needed in the future, it would require GIN indexes on the JSONB columns and a dedicated query API (separate phase). +10. **`super_admin`-scoped schema management** - Schema CRUD operations, single-document schema assignment, and bulk folder-level schema assignment live under `/super-admin/*` and require the `super_admin` role. The lesser `user_admin` role does NOT have access to schema administration. Custom metadata read/write on documents (`/custom-metadata/*`) is available to regular `client_user` users and to both admin roles. +11. **Defense-in-depth** - Critical invariants are enforced at BOTH the application layer AND the database layer, but v4 prefers declarative database constraints over imperative triggers wherever possible. The app-layer checks provide better error messages; the DB constraints are safety nets against bypass. v4 retains two triggers: `trg_prevent_schema_reassignment` on `documents` UPDATE ("schema is frozen once metadata or legacy extraction exists") and `trg_prevent_legacy_extraction_on_custom_document` on `documentFieldExtractions` INSERT (closes the concurrent-writer race on the mutual-exclusivity invariant; see Section 7.3). Both invariants are stateful and cannot be expressed as constraints. Client/schema ownership is enforced by a composite foreign key. Schema-id consistency across metadata versions is no longer a separate invariant because `document_custom_metadata` no longer carries a `schema_id` column -- the document row is the single source of truth. +12. **No migration from static fields** - There is no migration path from the existing static field extraction data into the custom schema system. They are mutually exclusive per document. Out of scope. + +--- + +## 4. Database Schema Design + +### 4.1 New Type: `schema_status_type` + +A three-state enum that distinguishes between different reasons a schema is no longer assignable: + +```sql +CREATE TYPE schema_status_type AS ENUM ('active', 'superseded', 'retired'); +``` + +- **`active`**: The schema version is current and can be assigned to new documents. +- **`superseded`**: A newer version of this schema (same `name`) was created. The schema is still valid for documents already using it, but should not be assigned to new documents. Set automatically when a new version is created via `POST /super-admin/custom-schemas/{schemaId}/versions`. +- **`retired`**: An admin explicitly disabled this schema version. Functionally similar to `superseded` but indicates a deliberate administrative action rather than an automatic version succession. + +### 4.2 New Table: `client_metadata_schemas` + +Stores client-defined JSON Schema documents. Each row is an immutable version of a schema definition. + +```sql +CREATE TABLE client_metadata_schemas ( + id uuid NOT NULL DEFAULT uuid_generate_v7(), + client_id varchar(255) NOT NULL REFERENCES clients(clientId) ON DELETE CASCADE, + name varchar(255) NOT NULL, + description text, + schema_def jsonb NOT NULL, -- The JSON Schema document (max 64KB enforced at app layer) + version int NOT NULL DEFAULT 1, -- Auto-incremented per (client_id, name) + status schema_status_type NOT NULL DEFAULT 'active', + created_at timestamptz NOT NULL DEFAULT NOW(), + created_by varchar(255) NOT NULL, + + CONSTRAINT pk_client_metadata_schemas PRIMARY KEY (id), + CONSTRAINT uq_client_schema_name_version UNIQUE (client_id, name, version), + -- Composite uniqueness required as the target of the same-client foreign key on `documents` (Section 4.4). + -- Because `id` is already the primary key, `(id, client_id)` is trivially unique -- this constraint exists + -- purely to make the tuple referenceable by a foreign key. + CONSTRAINT uq_cms_id_client UNIQUE (id, client_id), + CONSTRAINT chk_schema_def_size CHECK (pg_column_size(schema_def) <= 70000) -- ~68KB to account for JSONB overhead; app layer enforces 64KB on raw JSON +); + +CREATE INDEX idx_cms_client_id ON client_metadata_schemas(client_id); +CREATE INDEX idx_cms_client_name ON client_metadata_schemas(client_id, name); +CREATE INDEX idx_cms_client_status ON client_metadata_schemas(client_id, status); +``` + +> **Note**: The 64KB limit is enforced at the application layer (Go constant `MaxSchemaDefinitionBytes = 65536`) by checking `len(rawJSON)`. The database CHECK constraint uses `pg_column_size()` which measures on-disk JSONB storage (includes header overhead), so it is set slightly higher (~68KB) as a safety net. The app-layer check provides the user-facing 64KB limit and a better error message. + +**Design decisions:** + +- **`name` + `version`** scoped to client: Allows human-friendly naming ("aircraft-engineering-v3") with automatic versioning. When a new version is created, a new row is inserted with `version = max(version) + 1` for that `(client_id, name)`. +- **`schema_def` is JSONB**: Stores the full JSON Schema document. PostgreSQL can index into it if needed later. +- **`status` enum**: Three-state lifecycle. `superseded` is set automatically when a newer version is created. `retired` is set explicitly by admin action (soft-delete). Both states prevent assignment to new documents but allow existing documents to continue using the schema for validation. This is richer than a boolean `is_active` because it distinguishes "replaced by v2" from "admin decided this is bad." +- **Retirement permanently reserves `(client_id, name)` (v4)**: Once every row in a `(client_id, name)` lineage is non-`active` (i.e. the lineage has been fully retired), the `name` cannot be reused for that client. Three independent rules interact to produce this: (a) `POST /super-admin/custom-schemas` always inserts at `version = 1`, which collides with the retired v1 row on `uq_client_schema_name_version`; (b) `POST /super-admin/custom-schemas/{schemaId}/versions` rejects non-`active` parents (Section 5.1); (c) the UNIQUE constraint prevents any lineage branching. This is **intentional**: it keeps the audit history unambiguous (a name in the history always refers to exactly one lineage) and keeps the invariants from Section 4 stated as simple structural rules. Callers that need a "similar" schema after retirement must pick a new name (e.g. `aircraft-engineering-v2`, `aircraft-engineering-replacement`). See also the retirement note on `DELETE /super-admin/custom-schemas/{schemaId}` in Section 5.1. +- **`id` is the schema version ID**: This is what documents reference. Each version creates a new `id`. The `(client_id, name, version)` tuple provides the human-readable lineage. +- **`ON DELETE CASCADE` on `client_id`**: Hard-deleting a client automatically removes all its schema rows. Because the FK on `documents.custom_schema_id` (Section 4.4) points here, and because documents are deleted before the client row in `client.HardDelete`, the cascade fires only after every document referencing a schema has already been deleted. This eliminates the need for a feature-specific `DeleteClientMetadataSchemas` step in the client delete path. +- **`uq_cms_id_client`**: Required as the target of the composite FK on `documents` (Section 4.4). It adds no data-model meaning beyond the primary key but is necessary because PostgreSQL only allows FKs to reference columns backed by a unique constraint. + +### 4.3 New Table: `document_custom_metadata` + +Stores the actual custom metadata for documents. Each row is a versioned JSON blob bound to a single document. The schema used to validate the blob is derived from `documents.custom_schema_id` at write time -- it is not stored on the row. + +```sql +CREATE TABLE document_custom_metadata ( + id uuid PRIMARY KEY DEFAULT uuid_generate_v7(), + document_id uuid NOT NULL REFERENCES documents(id) ON DELETE CASCADE, + metadata jsonb NOT NULL, -- The actual custom field values + version int NOT NULL DEFAULT 1, -- Versioned like field extractions + created_at timestamptz NOT NULL DEFAULT NOW(), + created_by varchar(255) NOT NULL, + + CONSTRAINT uq_doc_custom_metadata_version UNIQUE (document_id, version) +); + +CREATE INDEX idx_dcm_doc_version_desc ON document_custom_metadata(document_id, version DESC); +``` + +**Design decisions:** + +- **No `schema_id` column (v4 simplification)**: The document row (`documents.custom_schema_id`) is the single source of truth for which schema a document's metadata conforms to. Writing a second schema pointer onto every metadata row was redundant because (1) `SetDocumentMetadata` already derives the schema from `documents.custom_schema_id` at write time, (2) Trigger 1 freezes `documents.custom_schema_id` as soon as any metadata row exists, and (3) `ResetDocumentMetadata` deletes all metadata rows before any rebinding can occur. Removing the column also removes `idx_dcm_schema_id`, Trigger 3 (`trg_enforce_consistent_schema_id`), the `GetSchemaMetadataRecordCount` SQLC query, and an entire class of "make sure these two pointers agree" app-layer checks. If a future release ever needs non-destructive in-place schema migration (relaxing the "frozen schema" invariant), the column can be added back with a dedicated migration. +- **`ON DELETE CASCADE` on `document_id`**: Hard-deleting a document automatically removes its metadata rows. The existing `DeleteDocumentCascade` code path is unchanged -- no feature-specific delete step is needed. Reset-metadata still wipes rows explicitly (Section 5.2) because the document row itself survives that operation. +- **Single table with version column**: We use one table (rather than separate extraction + version tables like the legacy pattern) because the simpler design is sufficient. The legacy two-table pattern exists to support row-level locking on the version counter independently of the data row. +- **Concurrency strategy for version assignment**: `SELECT ... FOR UPDATE` on the max-version row correctly serializes the *second and subsequent* writes for a document, but it does **not** serialize the *first* write -- there is no row to lock yet, so two concurrent first-writers both see `max(version) = 0`, both try to INSERT `version = 1`, and one fails with a `UNIQUE (document_id, version)` violation. v4 handles this by locking the parent `documents` row with `SELECT id FROM documents WHERE id = $1 FOR UPDATE` *before* reading the max version. The document row is guaranteed to exist (enforced by the service layer's existence check and by the FK) so this lock serializes the first write as well as every subsequent write. As a belt-and-suspenders measure, the service layer also retries once on a `UNIQUE (document_id, version)` violation; if the retry still conflicts the request returns `500`. If this proves insufficient under load, splitting into two tables is a backward-compatible change. +- **`metadata` is JSONB**: Validated against `client_metadata_schemas.schema_def` (looked up via the document's `custom_schema_id`) at write time. Stored as a single blob for simplicity and query flexibility. + +### 4.4 Alteration: `documents` Table + +Add an optional schema binding column and a composite foreign key that enforces the same-client invariant declaratively: + +```sql +ALTER TABLE documents + ADD COLUMN custom_schema_id uuid NULL; + +-- Composite FK: the (custom_schema_id, clientId) tuple on a document must match the +-- (id, client_id) tuple on a schema. This is what guarantees a document can only be +-- bound to a schema owned by its own client -- no trigger needed. +ALTER TABLE documents + ADD CONSTRAINT fk_documents_custom_schema_same_client + FOREIGN KEY (custom_schema_id, "clientId") + REFERENCES client_metadata_schemas(id, client_id); + +CREATE INDEX idx_documents_custom_schema_id ON documents(custom_schema_id); +``` + +**Behavior:** + +- `NULL` = document uses the legacy static field extraction system (default, backward-compatible) +- `NOT NULL` = document is opted into the custom schema system; legacy field extraction endpoints will reject writes for this document +- Once set AND custom metadata has been written, this field becomes immutable (enforced at app layer AND DB trigger: `trg_prevent_schema_reassignment`) +- Can be set before metadata is written (pre-binding) and changed freely until first metadata write +- Must reference a schema owned by the same client as the document. **v4: this is enforced declaratively by the composite FK `fk_documents_custom_schema_same_client`**, not by a trigger. Any attempt to set `custom_schema_id` to a schema owned by a different client fails with a foreign-key-violation error, which the service layer maps to `409 Conflict` with a human-readable message. The app-layer check remains as well, so the user-facing error message is clean on the common path; the FK is the defense-in-depth safety net. +- Cannot be set on a document that already has legacy field extractions (returns 409 Conflict) + +> **Why a composite FK instead of a trigger (v4)**: In v3, Trigger 2 (`trg_validate_schema_client_match`) read the referenced schema's `client_id` in PL/pgSQL and raised if it did not match the document's `clientId`. That approach worked but required a function definition, per-operation execution cost, and a dedicated unit test. The composite FK expresses the same invariant as a single DDL constraint that PostgreSQL enforces natively. `uq_cms_id_client` on `client_metadata_schemas` makes the `(id, client_id)` tuple referenceable (Section 4.2). No `ON UPDATE CASCADE` is needed because neither `documents.clientId` nor `client_metadata_schemas.client_id` is ever updated in place. + +> **Note on the deleted plain FK**: The v3 plan wrote `ADD COLUMN custom_schema_id uuid NULL REFERENCES client_metadata_schemas(id)`, creating an implicit single-column FK alongside Trigger 2. v4 deletes that line entirely. The composite FK above already references `client_metadata_schemas(id, client_id)`, which transitively guarantees that `custom_schema_id` points at a real schema row -- a second, single-column FK would duplicate the reference and add a redundant constraint entry in `pg_constraint`. + +### 4.5 Reading the latest metadata version (no view, v4 simplification) + +v3 defined a `current_document_custom_metadata` view using `DISTINCT ON (document_id) ... ORDER BY document_id, version DESC`. v4 removes the view. The equivalent query is short, uses the `idx_dcm_doc_version_desc` index directly, and produces identical results: + +```sql +-- name: GetCurrentDocumentCustomMetadata :one +SELECT id, document_id, metadata, version, created_at, created_by +FROM document_custom_metadata +WHERE document_id = @document_id +ORDER BY version DESC +LIMIT 1; +``` + +**Why remove the view**: The legacy field-extraction system benefits from a view because its state is spread across multiple tables. Custom metadata is a single table, so the indirection added no clarity. Removing it trims one migration artifact, one drift risk between query and view definitions, and one thing for the test matrix to verify. + +### 4.6 Entity Relationship Diagram + +``` +clients + | + |-- 1:N --> client_metadata_schemas (per-client schema definitions) + | | [ON DELETE CASCADE via client_id] + | | + | |-- referenced by --> documents.custom_schema_id + | (composite FK on (custom_schema_id, clientId)) + | + |-- 1:N --> documents + | + |-- 1:N --> document_custom_metadata (versioned JSONB blobs) + | [ON DELETE CASCADE via document_id] + | [MUTUALLY EXCLUSIVE with legacy extractions] + | [no schema_id column -- schema is derived from documents.custom_schema_id] + | + |-- 1:N --> documentFieldExtractions (existing static fields) + [MUTUALLY EXCLUSIVE with custom metadata] +``` + +### 4.7 Database Triggers (Defense-in-Depth) + +**v4 keeps two triggers.** The v3 plan defined three triggers; v4 removes v3's former Trigger 2 for client/schema ownership (replaced by the composite FK in Section 4.4) and v3's former Trigger 3 for schema_id consistency (no longer needed because `document_custom_metadata` has no `schema_id` column -- schema consistency is now a tautology, not an invariant to enforce). v4 reintroduces a different Trigger 2 that closes the mutual-exclusivity race between `AssignSchema` and legacy `CreateFieldExtraction` (see Section 7.3). + +#### Trigger 1: Prevent schema reassignment after first extraction + +Fires on `documents` BEFORE UPDATE of `custom_schema_id`. Prevents changes if the document has any extraction data (legacy or custom). This invariant is stateful -- it depends on the existence of rows in other tables -- and cannot be expressed as a constraint, so it stays as a trigger. + +```sql +CREATE OR REPLACE FUNCTION trg_prevent_schema_reassignment() +RETURNS TRIGGER AS $$ +BEGIN + -- Only fire if custom_schema_id is actually changing + IF OLD.custom_schema_id IS DISTINCT FROM NEW.custom_schema_id THEN + -- Check for existing custom metadata + IF EXISTS ( + SELECT 1 FROM document_custom_metadata + WHERE document_id = NEW.id + LIMIT 1 + ) THEN + RAISE EXCEPTION 'Cannot change custom_schema_id on document % because custom metadata already exists', NEW.id; + END IF; + + -- Check for existing legacy field extractions + IF EXISTS ( + SELECT 1 FROM "documentFieldExtractionVersions" + WHERE "documentId" = NEW.id + LIMIT 1 + ) THEN + RAISE EXCEPTION 'Cannot change custom_schema_id on document % because legacy field extractions already exist', NEW.id; + END IF; + END IF; + + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER trg_documents_prevent_schema_reassignment + BEFORE UPDATE OF custom_schema_id ON documents + FOR EACH ROW + EXECUTE FUNCTION trg_prevent_schema_reassignment(); +``` + +> **Note on reset-metadata interaction**: `ResetDocumentMetadata` (Section 5.2) deletes all `document_custom_metadata` rows for the document in the same transaction *before* setting `custom_schema_id = NULL`. Because the trigger's `EXISTS` check runs at `UPDATE` time and reads the already-deleted rows as absent, the UPDATE succeeds without any relaxation of the trigger. v4 does not weaken this trigger. + +#### Trigger 2: Prevent legacy field extraction on documents with a custom schema + +Fires on `documentFieldExtractions` BEFORE INSERT. Rejects the insert if the target document already has `custom_schema_id IS NOT NULL`. This is the symmetric counterpart to Trigger 1: Trigger 1 blocks schema assignment when legacy rows exist; Trigger 2 blocks legacy writes when a schema is bound. Together they eliminate the last-writer-wins race that would otherwise be possible if two concurrent transactions each passed their application-layer pre-check against a stale snapshot and then committed in opposite orders (see Section 7.3 for the full race analysis). + +```sql +CREATE OR REPLACE FUNCTION trg_prevent_legacy_extraction_on_custom_document() +RETURNS TRIGGER AS $$ +DECLARE + v_custom_schema_id uuid; +BEGIN + SELECT custom_schema_id INTO v_custom_schema_id + FROM documents + WHERE id = NEW."documentId" + FOR SHARE; + + IF v_custom_schema_id IS NOT NULL THEN + RAISE EXCEPTION 'Cannot create legacy field extraction on document % because it is bound to custom schema %', NEW."documentId", v_custom_schema_id + USING ERRCODE = 'check_violation'; + END IF; + + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER trg_dfe_prevent_legacy_extraction_on_custom_document + BEFORE INSERT ON "documentFieldExtractions" + FOR EACH ROW + EXECUTE FUNCTION trg_prevent_legacy_extraction_on_custom_document(); +``` + +The `FOR SHARE` lock on the `documents` row makes the trigger conflict with any concurrent `AssignSchema` that is holding `FOR UPDATE` on the same row: the trigger waits for the assign transaction to commit or roll back, then reads the final value. Together with the application-layer parent-row lock described in Section 7.3, this closes both sides of the race at the DB layer. The `check_violation` SQLSTATE is what `internal/fieldextraction/service.go` will detect and convert to the sentinel `ErrMutualExclusivityViolation` returned to the controller layer. + +#### Removed in v4: v3's client/schema ownership match trigger + +v3 used `trg_validate_schema_client_match` to reject `INSERT` or `UPDATE` on `documents` when the referenced schema belonged to a different client. v4 expresses the same invariant declaratively using the composite foreign key `fk_documents_custom_schema_same_client` defined in Section 4.4. The FK refuses any `(custom_schema_id, clientId)` tuple that is not present in `client_metadata_schemas(id, client_id)`, which is exactly the same guarantee with no function code to maintain. + +#### Removed in v4: v3's consistent schema_id trigger + +v3 used `trg_enforce_consistent_schema_id` on `document_custom_metadata INSERT` to ensure every metadata version for a document referenced the same schema. v4 removes the `schema_id` column from the table entirely, so every row for a given `document_id` trivially "uses" whatever schema `documents.custom_schema_id` currently points at -- there is no way for them to disagree. The invariant is preserved by making it structurally impossible rather than by enforcing it at runtime. + +> **Overall v4 trigger surface**: two triggers (`trg_prevent_schema_reassignment` and `trg_prevent_legacy_extraction_on_custom_document`), one composite FK (`fk_documents_custom_schema_same_client`), one `ON DELETE CASCADE` on each new table. Both triggers duplicate checks that also exist in the application service layer. This is intentional: the app-layer check returns a structured 400/409 on the common path; the triggers are a last-resort safety net that fires even if a future code path, migration script, or admin query bypasses the service layer. + +--- + +## 5. API Design + +### 5.1 Schema Management Endpoints (`super_admin` only) + +All schema CRUD endpoints live under the `/super-admin/custom-schemas` path prefix. The first path segment `super-admin` maps (via the existing first-segment resource extraction rule in the Permit.io middleware) to a new Permit.io resource named `super-admin`. Only the `super_admin` role holds permissions on this resource. The `user_admin` role does NOT have access. + +These endpoints are grouped under a new OpenAPI tag `SuperAdminSchemaService` (distinct from the existing `AdminService`) to make the authorization boundary visible in the generated client and in Swagger. + +#### `POST /super-admin/custom-schemas` + +Create a new schema for a client. + +**Request:** +```json +{ + "clientId": "acme-corp", + "name": "aircraft-engineering", + "description": "Custom fields for aircraft engineering documents", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "aircraft_model": { + "type": "string", + "maxLength": 100 + }, + "certification_date": { + "type": "string", + "format": "date" + }, + "max_altitude_ft": { + "type": "number", + "minimum": 0, + "maximum": 100000 + }, + "engine_count": { + "type": "integer", + "minimum": 1, + "maximum": 12 + } + }, + "required": ["aircraft_model"], + "additionalProperties": false + } +} +``` + +> **Actor identity (v4)**: The request body no longer contains a `createdBy` field. The handler derives the actor from the authenticated JWT via `cognitoauth.GetUserSubject(c)` and passes it to the service layer as a separate argument. The value stored and echoed back is the **Cognito subject ID** (the JWT `sub` claim — an opaque UUID like `b7a4c1d2-8e3f-4a5b-9c6d-0e1f2a3b4c5d`), **not** an email address. Email is not guaranteed to be present in the JWT and is explicitly rejected as a source of actor identity for these endpoints. `createdBy` / `resetBy` are therefore declared in OpenAPI as plain strings (no `format: email`) — see Section 5.5. Clients that send an unexpected `createdBy` key will have it silently ignored by the generated OpenAPI parser; the same applies to every new endpoint in Sections 5.1, 5.2, and 5.4. + +**Response (201):** +```json +{ + "id": "019577a3-...", + "clientId": "acme-corp", + "name": "aircraft-engineering", + "description": "Custom fields for aircraft engineering documents", + "schema": { ... }, + "version": 1, + "status": "active", + "createdAt": "2026-03-23T12:00:00Z", + "createdBy": "b7a4c1d2-8e3f-4a5b-9c6d-0e1f2a3b4c5d" +} +``` + +**Validation:** +- The `schema` field MUST be a valid JSON Schema document (validated server-side using a JSON Schema meta-validator) +- The `schema` field must be <= 64KB in size (returns 400 with message referencing the limit) +- Root `type` must be `"object"` +- Root `additionalProperties` MUST be explicitly present (either `true` or `false`). If omitted, the server returns 400 with a message explaining that `additionalProperties` must be explicitly declared. This prevents the common mistake of omitting it and inadvertently accepting arbitrary extra fields. +- `name` must be unique within the client (for active schemas at the same version) + +#### `POST /super-admin/custom-schemas/{schemaId}/versions` + +Create a new version of an existing schema. This operation creates a new schema row with a **new ID** and supersedes the old one. The old version remains intact and its status is automatically set to `superseded`. No backward compatibility check is performed -- the new version is treated as an independent schema that happens to share a name lineage. + +> **Why `POST /versions` instead of `PUT` (v4)**: The v3 plan used `PUT /super-admin/custom-schemas/{schemaId}` for this operation, but `PUT` in HTTP semantics means "replace the resource at this URL." That is not what this operation does -- it returns a *new* resource at a *new* URL and leaves the original resource in place (with its status changed). That is creation semantics, not replacement. `POST /super-admin/custom-schemas/{schemaId}/versions` describes the behavior accurately and also avoids the unresolved Permit.io action-mapping question (Permit.io's default action vocabulary does not include `put`; v3 Section 5.3 flagged this as an implementation decision). `POST` maps unambiguously to the `super-admin:post` grant. + +**Request:** +```json +{ + "description": "Updated aircraft engineering fields (added wingspan)", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "aircraft_model": { "type": "string", "maxLength": 100 }, + "certification_date": { "type": "string", "format": "date" }, + "max_altitude_ft": { "type": "number", "minimum": 0, "maximum": 100000 }, + "engine_count": { "type": "integer", "minimum": 1, "maximum": 12 }, + "wingspan_meters": { "type": "number", "minimum": 0 } + }, + "required": ["aircraft_model"], + "additionalProperties": false + } +} +``` + +**Response (201):** +```json +{ + "id": "019577b4-...", + "clientId": "acme-corp", + "name": "aircraft-engineering", + "version": 2, + "status": "active", + ... +} +``` + +**Behavior:** +- The `schemaId` in the path identifies the schema whose lineage is being extended (the parent version whose `name` + `client_id` tuple is inherited) +- The new version inherits the `name` and `client_id` from the parent version +- **Parent must be the currently active version for its `(client_id, name)` lineage.** If the parent's status is `superseded` or `retired`, the request is rejected with `409 Conflict` and message `"Cannot version from a non-active parent schema. Latest active version for this lineage is {activeId}."`. This prevents forking lineages and guarantees there is never more than one `active` row per `(client_id, name)` (see also the transaction sketch below). +- On success, the parent version's status is set to `superseded` in the **same** transaction that inserts the new row — no other version in the lineage can be active by construction. +- Returns the newly created schema version (HTTP 201 with `Location: /super-admin/custom-schemas/{newId}`) +- The `schema` field in the request body follows the same validation rules as the root `POST` endpoint (including the `additionalProperties` requirement) +- **Concurrency**: The transaction runs `SELECT id, version, status FROM client_metadata_schemas WHERE client_id = $1 AND name = $2 ORDER BY version FOR UPDATE` so every existing row in the lineage is locked before the active-parent check runs. The handler then verifies the path `schemaId` matches the single row with `status = 'active'` (rejecting with 409 if not), computes `max(version) + 1`, inserts the new row with `status = 'active'`, and marks the old active row `superseded`. The unique constraint `uq_client_schema_name_version` is the final safety net against a concurrent duplicate. +- **`previousVersionId` not returned (v4)**: v3's response decorated this endpoint with a `previousVersionId` field. v4 drops the decoration because the caller already knows the parent ID (it is in the request URL). Callers that want the lineage explicitly can call `GET /super-admin/custom-schemas?name=...&includeAllVersions=true`. + +#### `GET /super-admin/custom-schemas` + +List all schemas for a client. + +**Query Parameters:** +- `clientId` (required): The client whose schemas to list +- `name` (optional): Filter by schema name +- `status` (optional): Filter by status. Accepted values: `active`, `superseded`, `retired`, or the sentinel `any` to return all statuses. Default: `active` (only active schemas are returned when the parameter is omitted). +- `includeAllVersions` (optional, default: `false`): When `false`, return only the latest version per `(client_id, name)` lineage; when `true`, return every row the other filters match (one row per version). The default is deliberately narrow so the common admin UX does not have to paginate through historical versions. +- `limit` (optional, default: 50, max: 200): Maximum number of results to return +- `offset` (optional, default: 0): Number of results to skip for pagination + +> **Filter interaction note**: To see a full lineage with both the currently active version and its superseded predecessors in one response, callers must pass **both** `includeAllVersions=true` **and** `status=any` (the default `status=active` would strip out superseded rows even when `includeAllVersions=true`). The integration tests in tracking Milestone 1 rely on this exact combination; see tracking Milestone 1 exit criteria. + +**Response (200):** +```json +{ + "schemas": [ + { + "id": "019577b4-...", + "clientId": "acme-corp", + "name": "aircraft-engineering", + "description": "...", + "version": 2, + "status": "active", + "documentCount": 45, + "canDelete": false, + "createdAt": "...", + "createdBy": "..." + }, + { + "id": "019577c1-...", + "clientId": "acme-corp", + "name": "auto-engineering", + "description": "...", + "version": 1, + "status": "active", + "documentCount": 0, + "canDelete": true, + "createdAt": "...", + "createdBy": "..." + } + ] +} +``` + +**Notes:** +- `documentCount` shows how many documents reference each schema version via `documents.custom_schema_id`. Computed via a single subquery in the list query. +- `canDelete` is `true` when `documentCount == 0`. Because `document_custom_metadata` no longer carries a `schema_id` column (Section 4.3), schema-usage is knowable from `documents.custom_schema_id` alone -- there is no second table to consult. This saves the client a round-trip to check before attempting delete. + +#### `GET /super-admin/custom-schemas/{schemaId}` + +Get a specific schema by ID (returns full schema definition). + +**Response (200):** +```json +{ + "id": "019577a3-...", + "clientId": "acme-corp", + "name": "aircraft-engineering", + "description": "...", + "schema": { ... }, + "version": 1, + "status": "superseded", + "documentCount": 45, + "canDelete": false, + "createdAt": "...", + "createdBy": "..." +} +``` + +#### `DELETE /super-admin/custom-schemas/{schemaId}` + +Retire a schema. Only succeeds if no documents reference it. + +**Response (204):** No content on success. + +**Response (409 Conflict):** +```json +{ + "error": "Schema is in use by 45 documents and cannot be deleted" +} +``` + +**Behavior:** +- Sets `status = 'retired'` +- Fails with 409 if any documents reference this schema ID via `documents.custom_schema_id`. v4 no longer needs to consult `document_custom_metadata` (the `schema_id` column was removed in Section 4.3), so the check is a single count against `documents`. +- Retired schemas still serve validation for existing documents but cannot be assigned to new documents +- **Retirement permanently reserves the name (v4)**: if the retired row was the only active version of its `(client_id, name)` lineage, the lineage is now fully retired and **the name cannot be reused** for that client. `POST /super-admin/custom-schemas` with the same `name` will collide on `uq_client_schema_name_version` (version 1 already exists as retired), and `POST /super-admin/custom-schemas/{schemaId}/versions` against the retired row is rejected by the non-`active` parent check above. This is intentional (see Section 4.2 design decisions); callers that need a similar schema must choose a new `name` such as `{originalName}-v2` or `{originalName}-replacement`. Operators who want to retire a lineage without losing the ability to reuse the name should instead supersede it by creating a new version and then retiring the old version — superseding is reversible in principle, full retirement is not. + +--- + +### 5.2 Document Schema Assignment Endpoints (`super_admin` only) + +Schema assignment endpoints also live under `/super-admin/*` so that the same first-segment resource extraction rule confines them to the `super-admin` Permit.io resource. Placing them under `/admin/*` (v1) or under `/document/*` would expose them to `user_admin` or `client_user` respectively, which we explicitly do not want. + +#### `PATCH /super-admin/documents/{id}/schema` + +Assign or change a custom schema on a single document. + +> **Path note**: The sub-segment uses plural `documents/{id}` (not `document/{id}` as in v1) to avoid any possible collision with the existing top-level `/document/{id}` resource path during code review or manual URL inspection. This has no authorization impact because the first segment (`super-admin`) is what the middleware uses to resolve the resource, but it makes the namespacing visually unambiguous. + +**Request:** +```json +{ + "customSchemaId": "019577a3-..." +} +``` + +**Response (200):** +```json +{ + "documentId": "...", + "customSchemaId": "019577a3-...", + "schemaName": "aircraft-engineering", + "schemaVersion": 1 +} +``` + +**Validation rules:** +- Schema must belong to the same client as the document (enforced at app layer + **composite FK `fk_documents_custom_schema_same_client`** -- v4 replaces Trigger 2 with the FK) +- Schema must have status `active` +- If the document already has custom metadata written, the schema cannot be changed (returns 409, also enforced by DB Trigger 1 `trg_prevent_schema_reassignment`) +- If the document already has legacy field extractions, the schema cannot be assigned (returns 409 -- mutual exclusivity) +- Setting `customSchemaId` to `null` removes the binding (only if no custom metadata exists) + +#### `POST /super-admin/folders/{folderId}/assign-schema` (Bulk Folder Assignment) + +Assign a custom schema to ALL documents in a folder and all its subfolders (recursive). + +**Request:** +```json +{ + "customSchemaId": "019577a3-..." +} +``` + +**Response (200):** +```json +{ + "folderId": "...", + "customSchemaId": "019577a3-...", + "schemaName": "aircraft-engineering", + "documentsUpdated": 87, + "documentsSkipped": 5, + "skippedReasons": [ + {"documentId": "...", "reason": "Document already has custom metadata with a different schema"}, + {"documentId": "...", "reason": "Document already has custom metadata with a different schema"}, + {"documentId": "...", "reason": "Document already assigned to same schema (no-op)"}, + {"documentId": "...", "reason": "Document has legacy field extractions"}, + {"documentId": "...", "reason": "Document has legacy field extractions"} + ] +} +``` + +**Behavior:** +- Uses `WITH RECURSIVE` CTE to walk the entire folder subtree (same pattern as folder deletion) +- For each document in the tree: + - If the document has no schema, no custom metadata, and no legacy extractions -> assign the schema + - If the document already has the same schema -> skip (no-op, counted in `documentsSkipped`) + - If the document has a different schema but no custom metadata yet -> reassign to new schema + - If the document has a different schema AND has custom metadata -> skip with reason (cannot change) + - If the document has legacy field extractions -> skip with reason (mutual exclusivity) +- Runs in a single transaction +- **Document count limit**: If the folder subtree contains more than `MaxBulkAssignDocuments` (10,000) documents, returns `422 Unprocessable Entity` with a message explaining the limit. This prevents unbounded transaction duration, excessive lock contention, and replication lag. +- Returns summary of what was updated and what was skipped (with reasons) +- Requires `super_admin` role (the `super-admin` resource grants neither `user_admin` nor `client_user` access) + +#### `POST /super-admin/documents/{id}/reset-metadata` + +Wipe all custom metadata and schema binding from a single document so the document can be re-bound to a different schema (typically a newer version of the same schema, e.g., after an additive field was added via `POST /super-admin/custom-schemas/{schemaId}/versions`). + +This is the explicit, audited escape hatch for the "schema is frozen once metadata exists" invariant. Under normal circumstances, once `document_custom_metadata` has any rows for a document, Trigger 1 (`trg_prevent_schema_reassignment`) blocks `documents.custom_schema_id` from ever changing. Reset removes the blocking state (the metadata rows themselves and the schema binding) in a single transaction so the existing assign-schema + write-metadata endpoints can operate from a clean slate. **The one surviving trigger (`trg_prevent_schema_reassignment`) is not added, modified, or bypassed** -- after the metadata rows are deleted inside the transaction, the trigger's `EXISTS` check simply has nothing to object to. + +**Request:** empty body (path parameter `id` is the document ID). + +**Response (200):** +```json +{ + "documentId": "019577de-...", + "previousSchemaId": "019577a3-...", + "previousSchemaName": "aircraft-engineering", + "previousSchemaVersion": 1, + "metadataVersionsDeleted": 3, + "resetAt": "2026-04-13T12:00:00Z", + "resetBy": "b7a4c1d2-8e3f-4a5b-9c6d-0e1f2a3b4c5d" +} +``` + +The `previous*` fields describe the state *before* the reset so the caller can log or display what was wiped. The post-reset state (`custom_schema_id = NULL`, no metadata rows) is implicit — callers that want to confirm it can hit `GET /document/{id}` next. We deliberately do **not** echo a `customSchemaId: null` / `hasCustomMetadata: false` pair back on this response; they are invariant for every successful reset and the `ResetMetadataResult` struct (Section 7.1) does not carry them. + +**Transaction sequence** (single transaction, serializable-acceptable at read committed with the row locks below): + +1. `SELECT id, "clientId", custom_schema_id FROM documents WHERE id = $1 FOR UPDATE` -- lock the document row so concurrent assigns/writes serialize behind the reset. +2. If the document does not exist, return `404 Not Found`. +3. Capture `previousSchemaId` (may be `NULL`) and join to `client_metadata_schemas` to capture `previousSchemaName` / `previousSchemaVersion` for the response (null if no schema was bound). +4. `SELECT COUNT(*) FROM document_custom_metadata WHERE document_id = $1` -- captured as `metadataVersionsDeleted` for the response. +5. `DELETE FROM document_custom_metadata WHERE document_id = $1`. +6. `UPDATE documents SET custom_schema_id = NULL WHERE id = $1`. Trigger 1 fires on this UPDATE; because step 5 already removed all metadata rows and the document is guaranteed (by Invariant 14 and the existing mutual-exclusivity guards) to have no legacy field extractions, the trigger's `EXISTS` checks both return false and the UPDATE succeeds. +7. Insert an audit log entry (same log sink used by `AssignSchema` / `DeleteSchema`) recording `actor`, `documentId`, `previousSchemaId`, `metadataVersionsDeleted`, and `resetAt`. +8. Commit. + +**Behavior and guarantees:** + +- **Atomic**: If any step fails, the transaction rolls back and the document is unchanged. +- **Idempotent on already-clean documents**: If the document has no custom metadata and `custom_schema_id IS NULL`, the endpoint succeeds and returns `metadataVersionsDeleted: 0`, `previousSchemaId: null`. This keeps retries safe. +- **Does NOT touch legacy field extractions**: The mutual-exclusivity invariant (Invariant 14) still holds. A document that has any legacy field extraction rows is **rejected with `409 Conflict` before any DELETE runs** -- see validation below. Reset-metadata is deliberately scoped to documents using the custom schema system; it is not a "force wipe everything" endpoint and will not silently no-op on legacy documents. +- **Does NOT touch the schema definitions**: `client_metadata_schemas` rows are untouched. Retiring the document's binding does not affect other documents using the same schema. +- **Does NOT re-assign the new schema**: This endpoint is deliberately scoped to "wipe" only. The caller must follow up with `PATCH /super-admin/documents/{id}/schema` to bind the new schema and `POST /custom-metadata` to re-enter values. Keeping the two operations separate preserves the existing endpoints' semantics and makes the audit trail explicit (one log entry for the reset, one for the re-assignment, one per metadata write). +- **History is lost**: All prior metadata versions for this document are deleted. If the caller needs to preserve values across the reset, they must `GET /custom-metadata` beforehand and re-POST after the new schema is bound. Consider this the v3 "release valve," not the normal path. The `metadataVersionsDeleted` count in the response is a safety check for callers who want to confirm how much history they gave up. + +**Validation and error responses:** + +- `404 Not Found` if the document does not exist in this stack. Per Section 5.3 the stack itself is the client scope, so the handler only needs to check existence — no additional client-ownership check is performed. +- `409 Conflict` if the document has legacy field extraction rows (`documentFieldExtractionVersions`). Message: "Document uses the legacy field extraction system; reset-metadata only applies to documents using the custom schema system." This mirrors the mutual-exclusivity guard used elsewhere. +- `200 OK` on successful wipe (even if the document was already clean). +- `500` on unexpected DB errors. The transaction ensures partial state is impossible. + +**Authorization:** + +- Path lives under `/super-admin/*`, so the first-segment resource extraction rule maps the request to the `super-admin` Permit.io resource. Only the `super_admin` role holds `super-admin:post`, so `user_admin`, `client_user`, and `auditor` all receive `403`. No new Permit.io resource or action is required -- the existing `super-admin:post` grant already covers this route. +- The endpoint is also reflected in the documentation-only `policy_mappings` block in `permit_policies.yaml` (see Section 5.3). + +**Why wipe-and-reassign instead of allowing a compatible schema swap in place?** + +An earlier design considered relaxing Trigger 1 to permit reassignment when the new schema is a strict superset of the old one (additional optional fields, no type changes, no newly-required fields). That approach was rejected because: + +1. **Invariant 5 stays clean.** "`custom_schema_id` is immutable once metadata exists" is a strong, trivially auditable rule. Introducing "immutable *except* when the new schema is compatible" replaces a one-line check with a structural JSON Schema comparator that must agree exactly across the app layer and the DB trigger. Keeping the trigger unchanged is worth the cost of a destructive reset for the edge case of in-place upgrades. +2. **No silent reinterpretation of stored data.** Deleting the metadata and requiring the caller to re-submit guarantees that the new schema is actually applied to the values being stored, with full validation. An in-place schema swap would leave the JSONB blobs untouched and implicitly promote them to the new schema -- which means a later schema tightening (e.g., a pattern constraint added to an existing field) would not catch violations in legacy rows. +3. **Audit clarity.** The reset leaves an explicit log entry indicating that a super_admin chose to discard prior metadata history for this document. An in-place swap would silently change the schema linkage with no indication that the on-disk data predates the new schema. +4. **Simpler to implement and test.** One new endpoint, zero migrations, zero trigger changes, and the reset path owns its own focused queries (`DeleteDocumentCustomMetadataForReset`, `NullifyDocumentCustomSchemaIdForReset`, see Section 8.2). No JSON Schema compatibility comparator to build, maintain, or debug. (v4 note: v3 anticipated sharing these with a delete-cascade workstream; v4 replaced that workstream with `ON DELETE CASCADE` FKs, so the reset path is the sole caller.) + +If a non-destructive upgrade path is needed in the future, it can be added as a separate post-v4 endpoint (`POST /super-admin/documents/{id}/upgrade-schema`) that performs a JSON Schema compatibility check and a targeted UPDATE of `documents.custom_schema_id` guarded by a narrowly scoped relaxation of Trigger 1. v4 does not commit to that design. + +#### `GET /document/{id}` (Modified) + +Add exactly two fields to `DocumentEnriched`: + +```json +{ + "id": "...", + "clientId": "acme-corp", + "hash": "...", + "customSchemaId": "019577a3-...", + "hasCustomMetadata": true, + ...existing fields... +} +``` + +**v4 scope trim**: v3 proposed also adding `customSchemaName` (decoration) and an optional inlined `customMetadata` JSONB blob gated by a new `?customMetadata=true` query parameter. v4 drops both: + +- `customSchemaName`: callers that need the name can resolve it via `GET /super-admin/custom-schemas/{schemaId}` (or a schema-list endpoint already on the same client). Duplicating it on every document read response is a denormalization cost on a cross-cutting read path. +- Inline `customMetadata` blob: the feature already ships a dedicated `GET /custom-metadata` endpoint that returns the current metadata version in full. Inlining the same payload on `GET /document/{id}` would add controller, service, query, OpenAPI, and test work to a widely used read path without giving callers any new capability. + +The remaining two fields (`customSchemaId`, `hasCustomMetadata`) are deliberately minimal: `customSchemaId` is already a column on the `documents` row (no extra query), and `hasCustomMetadata` is a single `EXISTS` subquery that piggybacks on the existing document read. + +> **Authorization note**: The `GET /document/{id}` endpoint stays on the existing `document` resource. Both admin roles, `client_user`, and `auditor` can read it today. Returning `customSchemaId` / `hasCustomMetadata` on this endpoint is read-only and does not grant any administrative capability. The `super-admin` gating applies only to the write/assign/manage paths. + +--- + +### 5.3 Authorization & Permit.io Changes + +> **Isolation model (v4).** Production is deployed **one client per stack**: each client runs in its own isolated environment with a separate database, separate services, and a separate Cognito pool. There is no shared production tenant, so the first-path-segment Permit.io check performed by `internal/cognitoauth/middleware.go` and `internal/cognitoauth/permitio.go` is sufficient for production — a caller's JWT can only reach their own client's data because the stack itself only contains that client's data. The new `/custom-metadata/*` and `/super-admin/custom-schemas/*` endpoints inherit this model intentionally; role gating (the table below) is the whole authorization story for these routes. +> +> **Shared non-production caution.** Shared dev/uat stacks may host fixtures for more than one client in the same database. Do not load customer-sensitive fixtures into a shared stack — treat those environments as test data only. The role gating below still applies, but "client" is not an enforced security boundary in shared stacks by design. +> +> **If the deployment model ever changes.** If any future environment hosts more than one real client in a single stack, the `custom-metadata` and `super-admin` resources must be retrofitted to receive document and client objects in the Permit.io check, and the service layer must refuse cross-stack reads/writes with `404 Not Found` (never `403`, to avoid leaking existence). That is a separate initiative that would also need to retrofit `documents`, `field-extractions`, and `folders`, and is explicitly **out of scope** for this plan. + +v2 splits admin-class authorization for mutable metadata into two tiers; v3 keeps the same split and simply adds the reset-metadata endpoint to the `super_admin`-only column: + +| Role | Schema CRUD (`/super-admin/custom-schemas/*`) | Schema Assignment (`/super-admin/documents/{id}/schema`, `/super-admin/folders/{folderId}/assign-schema`) | Reset Metadata (`/super-admin/documents/{id}/reset-metadata`) | Metadata Read (`GET /custom-metadata/*`, `GET /document/{id}` enrichment) | Metadata Write (`POST /custom-metadata`) | +|------|-----------------------------------------------|-----------------------------------------------------------------------------------------------------------|----------------------------------------------------------------|----------------------------------------------------------------------------|-------------------------------------------| +| `super_admin` | Yes | Yes | Yes | Yes | Yes | +| `user_admin` | **No** | **No** | **No** | Yes | Yes | +| `auditor` | Read-only (`GET` only) | No | No | Read-only | No | +| `client_user` | No | No | No | Yes | Yes | + +Key change from v1: `user_admin` has NO schema CRUD and NO schema assignment rights. The role continues to function exactly as it does today for everything else (user creation, labels, folders, etc.). Reset-metadata (new in v3) follows the same confinement: only `super_admin` can call it, because it is destructive and structurally equivalent to a schema reassignment. + +#### New Permit.io resource: `super-admin` + +Add a new resource to `cmd/auth_related/permit.setup/permit_policies.yaml`: + +```yaml +resources: + # ...existing resources unchanged... + + - name: super-admin + description: Platform-level administrative functions restricted to super_admin role (custom metadata schema management, schema assignment) + actions: + - get + - post + - patch + - delete +``` + +#### Role permission updates + +```yaml +roles: + - name: super_admin + description: Full system access for platform administrators + permissions: + # ...existing permissions unchanged... + super-admin: + - get + - post + - patch + - delete + custom-metadata: + - get + - post + + - name: user_admin + description: User management - primarily for creating new client users + permissions: + # ...existing permissions unchanged (admin, documents, field-extractions, folders)... + # NOTE: user_admin is intentionally NOT granted super-admin:* permissions. + custom-metadata: + - get + - post + + - name: auditor + description: Full read-only access across the entire system for auditing and compliance + permissions: + # ...existing permissions unchanged... + super-admin: + - get + custom-metadata: + - get + + - name: client_user + description: Client-specific access for external users (requires tenant filtering) + permissions: + # ...existing permissions unchanged... + custom-metadata: + - get + - post +``` + +#### New custom-metadata resource + +The user-facing custom metadata endpoints (`/custom-metadata/*`) use a separate `custom-metadata` resource (first path segment). This is unchanged from v1 except that it now also needs to be granted to `super_admin` explicitly (v1 relied on `admin:*` coverage which no longer covers the metadata path). + +```yaml +resources: + - name: custom-metadata + description: Per-document custom metadata read/write (validated against client schemas) + actions: + - get + - post +``` + +#### Policy mapping documentation additions + +Add to the documentation-only `policy_mappings` section at the bottom of `permit_policies.yaml`: + +```yaml +super_admin_endpoints: + - path: /super-admin/custom-schemas + methods: + GET: super-admin:get + POST: super-admin:post + - path: /super-admin/custom-schemas/{schemaId} + methods: + GET: super-admin:get + DELETE: super-admin:delete + - path: /super-admin/custom-schemas/{schemaId}/versions + methods: + POST: super-admin:post # v4: create-new-version, replaces v3's PUT on the parent path + - path: /super-admin/documents/{id}/schema + methods: + PATCH: super-admin:patch + - path: /super-admin/folders/{folderId}/assign-schema + methods: + POST: super-admin:post + - path: /super-admin/documents/{id}/reset-metadata + methods: + POST: super-admin:post + +custom_metadata_endpoints: + - path: /custom-metadata + methods: + GET: custom-metadata:get + POST: custom-metadata:post + - path: /custom-metadata/version + methods: + GET: custom-metadata:get + - path: /custom-metadata/history + methods: + GET: custom-metadata:get +``` + +> **v4 note on HTTP methods**: v4 avoids the `PUT`-to-`patch` action-mapping ambiguity entirely by replacing the v3 `PUT /super-admin/custom-schemas/{schemaId}` with `POST /super-admin/custom-schemas/{schemaId}/versions`. No `put` action needs to be added to the `super-admin` resource. The `super_admin` role's existing `super-admin:post` grant already covers the new route. + +#### Middleware impact + +The Permit.io resource-extraction middleware uses the first path segment. Introducing `super-admin` as a resource requires no middleware code changes -- it is purely a configuration addition via the permit setup tool. The setup tool (`cmd/auth_related/permit.setup/`) must be re-run in each environment (dev, uat, prod) to provision the new resource and role permissions before these endpoints can pass authorization. + +#### Deployment ordering + +Because the authorization changes must land in Permit.io before the API routes are live, the deployment sequence is: + +1. Update `permit_policies.yaml` with the new resources and role grants. +2. Run the permit setup tool against dev/uat/prod to provision the new resource and permissions. +3. Deploy the API code containing the new endpoints. + +Step 2 must complete before step 3 in each environment, otherwise the new endpoints would either be rejected by Permit.io (if fail-closed) or accidentally allowed (if fail-open) during the window between code deploy and permit sync. + +--- + +### 5.4 Custom Metadata CRUD Endpoints (User-Facing) + +All under a new `CustomMetadataService` tag. These endpoints are NOT moved under `/super-admin/*` because they are intended for regular users (including `client_user`) to read and write the custom metadata values on documents. Schema administration and schema assignment are separated from value read/write precisely so the day-to-day data entry workload does not require super_admin. + +> **Actor identity is authoritative, not client-supplied (v4).** v4 removes `createdBy` from every new-endpoint request body schema. The handler derives the actor identity from the authenticated JWT / Cognito subject via `cognitoauth.GetUserSubject(c)` and passes it to the service layer as a separate function argument. The resulting value is the JWT `sub` claim (an opaque Cognito subject UUID), **not** an email — email is not guaranteed present in the JWT on this service and is deliberately not used. Response payloads still include `createdBy` / `resetBy` for audit visibility (those values come from the server, not from the caller) and are therefore typed as plain strings, not `format: email`. +> +> Applies to all new endpoints: `POST /super-admin/custom-schemas`, `POST /super-admin/custom-schemas/{schemaId}/versions`, `DELETE /super-admin/custom-schemas/{schemaId}`, `PATCH /super-admin/documents/{id}/schema`, `POST /super-admin/folders/{folderId}/assign-schema`, `POST /super-admin/documents/{id}/reset-metadata`, and `POST /custom-metadata`. +> +> **Audit sink**: Sections 5.2 and 8 reference "the same log sink used by `AssignSchema` / `DeleteSchema`." If that sink does not already exist as a first-class abstraction in `internal/customschema/` or an `audit` package, Milestone 1 must define it (signature, where records are written, whether it is synchronous or queued) before Reset-metadata, Schema CRUD, and Schema assignment can log through it. The audit record must include at minimum: `actor` (derived from auth context), `action`, `resourceId`, `timestamp`, and an action-specific detail payload. + +#### `POST /custom-metadata` + +Create or update custom metadata for a document. Creates a new version. + +**Request:** +```json +{ + "documentId": "...", + "metadata": { + "aircraft_model": "Boeing 737-800", + "certification_date": "2024-06-15", + "max_altitude_ft": 41000, + "engine_count": 2 + } +} +``` + +**Response (201):** +```json +{ + "id": "...", + "documentId": "...", + "schemaId": "019577a3-...", + "schemaName": "aircraft-engineering", + "schemaVersion": 1, + "metadata": { ... }, + "version": 1, + "createdAt": "...", + "createdBy": "b7a4c1d2-8e3f-4a5b-9c6d-0e1f2a3b4c5d" +} +``` + +> The response `schemaId`, `schemaName`, and `schemaVersion` are returned as a convenience (resolved server-side from `documents.custom_schema_id -> client_metadata_schemas` via the joined query in Section 8.2) even though the `document_custom_metadata` table no longer stores them. `createdBy` is also server-derived; see the "Actor identity" note in Section 5 — it is the caller's Cognito subject ID, not an email. + +**Validation:** +- Document must have a `custom_schema_id` assigned (400 if not -- a super_admin must have assigned the schema first) +- Document must NOT have any legacy field extractions (409 if it does -- mutual exclusivity, also enforced by DB trigger on schema assignment) +- The `metadata` payload must be <= 1MB in size (`MaxMetadataPayloadBytes = 1048576`). Returns 400 if exceeded. +- The `metadata` payload is validated against the JSON Schema stored in `client_metadata_schemas.schema_def` +- The request body does NOT include `schemaId` -- the server derives it from the document's `custom_schema_id`. This prevents client/server drift. +- If validation fails, returns 400 with detailed validation errors: + ```json + { + "error": "Metadata does not conform to schema", + "validationErrors": [ + {"field": "engine_count", "message": "must be >= 1, got 0"}, + {"field": "aircraft_model", "message": "required field missing"} + ] + } + ``` +- On first write, locks the document's `custom_schema_id` (makes it immutable via app-layer flag + DB trigger) +- Subsequent writes create new versions but must use the same schema + +#### `GET /custom-metadata` + +Get current (latest version) custom metadata for a document. + +**Query Parameters:** +- `documentId` (required) + +**Response (200):** +```json +{ + "id": "...", + "documentId": "...", + "schemaId": "019577a3-...", + "schemaName": "aircraft-engineering", + "schemaVersion": 1, + "metadata": { ... }, + "version": 3, + "createdAt": "...", + "createdBy": "b7a4c1d2-8e3f-4a5b-9c6d-0e1f2a3b4c5d" +} +``` + +> `schemaId`, `schemaName`, and `schemaVersion` are resolved server-side from the document's current binding via the joined `GetCurrentDocumentCustomMetadata` query (Section 8.2). `createdBy` is the Cognito subject ID (see Section 5 "Actor identity"), not an email. + +#### `GET /custom-metadata/version` + +Get a specific version of custom metadata. + +**Query Parameters:** +- `documentId` (required) +- `version` (required, >= 1) + +#### `GET /custom-metadata/history` + +Get version history for a document's custom metadata. + +**Query Parameters:** +- `documentId` (required) +- `limit` (optional, default: 50, max: 200): Maximum number of versions to return +- `offset` (optional, default: 0): Number of versions to skip for pagination + +**Response (200):** +```json +{ + "versions": [ + {"id": "...", "documentId": "...", "version": 3, "createdAt": "...", "createdBy": "..."}, + {"id": "...", "documentId": "...", "version": 2, "createdAt": "...", "createdBy": "..."}, + {"id": "...", "documentId": "...", "version": 1, "createdAt": "...", "createdBy": "..."} + ] +} +``` + +**Edge-case behavior (v4 — committed, not implementer's choice):** + +| Input | Result | Rationale | +|-------|--------|-----------| +| `limit=0` | `400 Bad Request` with message `"limit must be between 1 and 200"` | Zero-limit is a client bug (the caller already decided not to fetch anything); rejecting is louder than returning an empty page. | +| `limit > 200` | Clamp to `200` and return `200 OK` | Matches how the existing list endpoints on this service clamp rather than reject, and keeps callers that pass a large "give me everything" sentinel from failing outright. The clamped value is the one used for the DB query; no warning header is added. | +| `limit < 0` | `400 Bad Request` with message `"limit must be between 1 and 200"` | Same shape as `limit=0` — obvious client bug. | +| `offset < 0` | `400 Bad Request` with message `"offset must be >= 0"` | Same reasoning. | +| `documentId` refers to a document that does not exist (or belongs to another client in a shared stack) | `404 Not Found` with message `"document not found"` | Matches the existing `GET /document/{id}` shape. Using `200 + empty array` would make the endpoint unable to distinguish "document doesn't exist" from "document has never had metadata." | +| `documentId` exists but has zero metadata rows | `200 OK` with `{"versions": []}` | The resource (document) exists; its history is legitimately empty. Returning `404` here would conflate "no history" with "no document" and force callers to double-check. | + +These are hard declarations, not guidance — handlers must implement exactly this shape and the integration tests in tracking Milestone 2 cover every row above. Any deviation is a test failure. + +--- + +## 6. Validation Strategy + +### 6.1 JSON Schema Validation + +Use a Go JSON Schema validation library for both: + +1. **Schema validation** (meta-validation): When a super_admin submits a new schema, validate it against the JSON Schema meta-schema to ensure it is a well-formed JSON Schema document. +2. **Metadata validation**: When custom metadata is submitted, validate the payload against the document's assigned schema. + +**Recommended library**: `github.com/santhosh-tekuri/jsonschema/v6` + +Reasons: +- Supports JSON Schema draft 2020-12 +- Active maintenance +- Good error reporting +- Supports custom formats + +### 6.2 Schema Definition Requirements + +When a schema definition is submitted (via `POST /super-admin/custom-schemas` or `POST /super-admin/custom-schemas/{schemaId}/versions`), the server validates: + +1. It is a valid JSON object +2. It compiles successfully as a JSON Schema document (meta-validation) +3. Root `type` is `"object"` +4. Root `additionalProperties` is explicitly present (either `true` or `false`). This is required because JSON Schema defaults `additionalProperties` to `true` when omitted, which silently accepts any extra fields. Requiring explicit declaration forces a conscious choice. +5. Size is <= 64KB + +### 6.3 Supported JSON Schema Features + +The system will support the following JSON Schema keywords for custom field definitions: + +| Feature | JSON Schema Keyword | Example | +|---------|-------------------|---------| +| Data type | `type` | `"string"`, `"number"`, `"integer"`, `"boolean"`, `"array"`, `"object"` | +| Required fields | `required` | `["field1", "field2"]` | +| String length | `minLength`, `maxLength` | `"maxLength": 100` | +| Numeric range | `minimum`, `maximum` | `"minimum": 0` | +| Pattern matching | `pattern` | `"pattern": "^[A-Z]{2}$"` | +| Enum values | `enum` | `"enum": ["active", "inactive"]` | +| Date/time format | `format` | `"format": "date"` | +| Array items | `items`, `minItems`, `maxItems` | Nested array support | +| Nested objects | `properties` | Hierarchical field groups | +| No extra fields | `additionalProperties` | `false` to enforce strict schemas | + +### 6.4 Validation Error Reporting + +Validation errors will be returned as structured JSON with: +- The JSON pointer to the failing field (`/aircraft_model`) +- The constraint that was violated (`maxLength`) +- A human-readable message + +--- + +## 7. Service Layer Design + +### 7.1 New Service: `internal/customschema/` + +``` +internal/customschema/ + service.go -- CustomSchemaService (CRUD + validation) + models.go -- Input/output types + validator.go -- JSON Schema meta-validation + payload validation + constants.go -- MaxSchemaDefinitionBytes and other limits + service_test.go -- Integration tests +``` + +**Constants (`constants.go`):** + +```go +const ( + // MaxSchemaDefinitionBytes is the maximum size of a JSON Schema definition. + // This limit can be increased if clients need larger schemas. + MaxSchemaDefinitionBytes = 65536 // 64KB + + // MaxMetadataPayloadBytes is the maximum size of a custom metadata JSON payload. + // Prevents unbounded JSONB blobs when schemas use additionalProperties: true. + MaxMetadataPayloadBytes = 1048576 // 1MB + + // MaxBulkAssignDocuments is the maximum number of documents that can be updated + // in a single bulk folder schema assignment. Prevents unbounded transactions. + MaxBulkAssignDocuments = 10000 +) +``` + +**Key methods (v4 -- all write methods take `actor` as a separate argument, never read it off request bodies):** + +```go +type Service struct { + cfg serviceconfig.ConfigProvider + validator *SchemaValidator + audit AuditSink +} + +// Schema management (super_admin operations) +func (s *Service) CreateSchema(ctx, input CreateSchemaInput, actor string) (*Schema, error) +func (s *Service) CreateSchemaVersion(ctx, parentSchemaID uuid.UUID, input CreateSchemaVersionInput, actor string) (*Schema, error) +func (s *Service) GetSchema(ctx, schemaID uuid.UUID) (*Schema, error) +func (s *Service) ListSchemas(ctx, clientID string, filters ListFilters) ([]SchemaSummary, error) +func (s *Service) DeleteSchema(ctx, schemaID uuid.UUID, actor string) error + +// Metadata operations (client_user / admin operations) +func (s *Service) SetDocumentMetadata(ctx, input SetMetadataInput, actor string) (*CustomMetadata, error) +func (s *Service) GetCurrentMetadata(ctx, documentID uuid.UUID) (*CustomMetadata, error) +func (s *Service) GetMetadataByVersion(ctx, documentID uuid.UUID, version int) (*CustomMetadata, error) +func (s *Service) GetMetadataHistory(ctx, documentID uuid.UUID) ([]MetadataVersion, error) + +// CustomMetadata is returned by the Set/Get/GetByVersion methods above. +// SchemaID / SchemaName / SchemaVersion are resolved via the joined +// GetCurrentDocumentCustomMetadata / GetDocumentCustomMetadataByVersion +// queries (Section 8.2). They are NOT stored on the metadata row itself. +type CustomMetadata struct { + ID uuid.UUID + DocumentID uuid.UUID + SchemaID uuid.UUID + SchemaName string + SchemaVersion int + Metadata json.RawMessage + Version int + CreatedAt time.Time + CreatedBy string // Cognito subject ID (see Section 5 "Actor identity") +} + +// Document schema binding (super_admin operations) +func (s *Service) AssignSchema(ctx, documentID uuid.UUID, schemaID *uuid.UUID, actor string) (*AssignSchemaResult, error) +func (s *Service) AssignSchemaToFolder(ctx, folderID uuid.UUID, schemaID uuid.UUID, actor string) (*BulkAssignResult, error) +func (s *Service) ResetDocumentMetadata(ctx, documentID uuid.UUID, actor string) (*ResetMetadataResult, error) +``` + +`CreateSchemaVersion` replaces the v3 `UpdateSchema` method to match the new `POST /super-admin/custom-schemas/{schemaId}/versions` route. The semantics are unchanged (lock existing versions for the `(client_id, name)` pair, read max version, insert new row, mark the parent as `superseded` in the same transaction). The method's parameter name is changed from `schemaID` to `parentSchemaID` to make the lineage explicit. + +**AssignSchemaResult:** + +```go +type AssignSchemaResult struct { + DocumentID uuid.UUID + CustomSchemaID *uuid.UUID // nil when the caller cleared the binding + SchemaName *string // nil when the caller cleared the binding + SchemaVersion *int // nil when the caller cleared the binding +} +``` + +The `AssignSchema` signature takes `schemaID *uuid.UUID` so callers can clear the binding by passing `nil` (a non-nil pointer is treated as "assign or re-assign"). The existing validation path already reads the target schema row to verify `status='active'` and the same-client constraint, so returning `SchemaName` / `SchemaVersion` is a free decoration — no new query is required. When the caller clears the binding (`schemaID == nil`), all three schema-related fields in the result are `nil`. This replaces v3's `error`-only return, which did not give the `PATCH /super-admin/documents/{id}/schema` handler enough information to build its documented response. + +**ResetMetadataResult:** + +```go +type ResetMetadataResult struct { + DocumentID uuid.UUID + PreviousSchemaID *uuid.UUID // nil if document had no schema bound + PreviousSchemaName *string // nil if document had no schema bound + PreviousSchemaVersion *int // nil if document had no schema bound + MetadataVersionsDeleted int + ResetAt time.Time + ResetBy string +} +``` + +`ResetDocumentMetadata` runs a single transaction that: locks the document row (`SELECT ... FOR UPDATE`), rejects documents with legacy field extractions (`409`), captures the previous schema binding metadata (via join to `client_metadata_schemas`) and the count of metadata versions for the response, executes the `DeleteDocumentCustomMetadataForReset` and `NullifyDocumentCustomSchemaIdForReset` queries from Section 8.2, writes an audit log entry, and returns the captured "previous" state. See Section 5.2 for the full endpoint semantics and rationale. + +> **v4 query ownership**: In v3, the `DeleteDocumentCustomMetadata` and `NullifyDocumentCustomSchemaId` queries lived on the delete-cascade code path and were reused by reset-metadata. In v4 the delete cascade no longer needs either query (FK cascade handles it, see Section 8.5), so these two queries are **owned by reset-metadata alone** and live in `internal/database/queries/customschemas.sql`, renamed to `DeleteDocumentCustomMetadataForReset` and `NullifyDocumentCustomSchemaIdForReset` to make the single-caller attribution explicit. Functionally identical to the v3 queries, just renamed and attributed to a single caller. + +**BulkAssignResult:** + +```go +type BulkAssignResult struct { + FolderID uuid.UUID + SchemaID uuid.UUID + DocumentsUpdated int + DocumentsSkipped int + SkippedReasons []SkippedDocument +} + +type SkippedDocument struct { + DocumentID uuid.UUID + Reason string +} +``` + +> **Authorization is enforced at the HTTP middleware layer via Permit.io**, not inside these service methods. The service methods are agnostic to the caller's role. The routing layer for `/super-admin/*` endpoints is what ensures only `super_admin` can invoke `CreateSchema`, `CreateSchemaVersion`, `DeleteSchema`, `AssignSchema`, and `AssignSchemaToFolder`. Keeping the service layer role-unaware preserves testability and makes integration tests simpler. + +### 7.2 Validator Component + +```go +type SchemaValidator struct{} + +// ValidateSchemaDefinition checks that a schema_def is valid JSON Schema. +// Returns error if: not a valid JSON object, fails meta-validation, +// root type is not "object", or additionalProperties is not explicitly present. +func (v *SchemaValidator) ValidateSchemaDefinition(schemaDef json.RawMessage) error + +// ValidateMetadata checks that metadata conforms to a compiled schema +func (v *SchemaValidator) ValidateMetadata(schemaDef json.RawMessage, metadata json.RawMessage) ([]ValidationError, error) +``` + +### 7.3 Mutual Exclusivity Guard + +The service layer enforces mutual exclusivity at two points, and **both sides must serialize on the parent `documents` row** to close the concurrent-write race. A non-locking pre-check is not sufficient because PostgreSQL's default `READ COMMITTED` isolation lets two transactions each read a stale snapshot and then commit opposing writes. + +**The race (what a naive pre-check fails to prevent):** + +``` +T1 (AssignSchema) T2 (CreateFieldExtraction) +----------------- -------------------------- +BEGIN BEGIN + SELECT custom_schema_id FROM documents + -> NULL (passes app pre-check) +UPDATE documents + SET custom_schema_id = S INSERT documentFieldExtractions (...) + (Trigger 1: EXISTS extractions? (Trigger 1 already fired; + -> empty -> OK) no re-check on this INSERT) +COMMIT COMMIT +Final state: document has BOTH custom_schema_id AND legacy extractions. +``` + +**The fix (two symmetric parent-row locks plus Trigger 2):** + +1. **Schema assignment** (`AssignSchema` in `internal/customschema/service.go`): First statement inside the transaction is `SELECT id, "clientId", custom_schema_id FROM documents WHERE id = $1 FOR UPDATE`. Only after the row lock is held does the service check for existing `documentFieldExtractionVersions` rows and then run the `UPDATE`. Any concurrent legacy writer that tries to take the same row lock (or Trigger 2's `FOR SHARE` read) will block until this transaction commits or rolls back, and will then observe the final `custom_schema_id` value. + +2. **Legacy field extraction write** (modification to `internal/fieldextraction/service.go::CreateFieldExtraction` and every other legacy mutation entry point listed in `tracking.md` section 2.5): First statement inside the transaction is `SELECT id, custom_schema_id FROM documents WHERE id = $1 FOR UPDATE`. If `custom_schema_id IS NOT NULL`, return the sentinel `ErrMutualExclusivityViolation` (which the controller maps to `409 Conflict`). Only if the column is null does the service proceed with the existing `AddFieldExtraction` + version-lock + version-insert sequence. + +The parent-row lock is held for the duration of the enclosing transaction, so it serializes with both: +- a concurrent `AssignSchema` on the same document (which holds `FOR UPDATE` on the same row), and +- Trigger 2 on `documentFieldExtractions INSERT` (which takes `FOR SHARE` on the same row, see Section 4.7). + +**Defense-in-depth (Section 4.7):** +- **Trigger 1** (`trg_prevent_schema_reassignment`) on `documents BEFORE UPDATE OF custom_schema_id`: rejects assignment if legacy extractions or custom metadata exist. +- **Trigger 2** (`trg_prevent_legacy_extraction_on_custom_document`) on `documentFieldExtractions BEFORE INSERT`: rejects legacy inserts on documents already bound to a custom schema. Takes a `FOR SHARE` lock on the `documents` row, which cannot be granted while `AssignSchema` holds `FOR UPDATE`. + +Both triggers catch anything that slips past the service layer (admin SQL, a new code path that forgets the lock, a migration script). The service-layer locks are the common-path fast check; the triggers are the guarantee. + +**What must NOT happen:** +- A legacy mutation entry point that does not take the parent-row lock. Every write path in `internal/fieldextraction/` must be audited (tracking section 2.5 enumerates them). +- A read-only pre-check (`SELECT` without `FOR UPDATE`). That is the exact shape of the bug that motivates this section. + +### 7.4 API Controller: `api/queryAPI/customschemas.go` + +Follows the same pattern as `fieldextractions.go`: +- Parse and validate request +- Call service layer +- Convert between API and service types +- Return appropriate HTTP status codes + +The controller file holds all `/super-admin/custom-schemas/*`, `/super-admin/documents/{id}/schema`, and `/super-admin/folders/{folderId}/assign-schema` handlers. A separate controller file `api/queryAPI/custommetadata.go` holds the user-facing `/custom-metadata/*` handlers. Separating them by file mirrors the separation of authorization tiers and makes it obvious at code-review time which handlers are super_admin-gated. + +--- + +## 8. Migration Plan + +### 8.1 Migration Files + +Four new migrations. The repo already contains migrations through `00000000000126_batch_document_outcomes_delete_actions`, so v4 starts at `127`. If another branch lands new migrations before this feature merges, renumber the four files below to the next free sequence and update all references in this plan and the tracking doc. + +**Migration 127: Create `schema_status_type` enum + `client_metadata_schemas` table** +``` +00000000000127_create_client_metadata_schemas.up.sql +00000000000127_create_client_metadata_schemas.down.sql +``` + +Contents: +- `CREATE TYPE schema_status_type AS ENUM ('active', 'superseded', 'retired')` +- `client_metadata_schemas` table per Section 4.2, including `ON DELETE CASCADE` on the `client_id` FK, the `uq_cms_id_client` composite unique constraint, and the `chk_schema_def_size` CHECK +- Down migration drops the table and enum in the correct order + +**Migration 128: Add `custom_schema_id` column + composite FK to `documents` table** +``` +00000000000128_add_custom_schema_id_to_documents.up.sql +00000000000128_add_custom_schema_id_to_documents.down.sql +``` + +Contents: +- `ALTER TABLE documents ADD COLUMN custom_schema_id uuid NULL` (no inline `REFERENCES` clause -- the composite FK provides that) +- `ALTER TABLE documents ADD CONSTRAINT fk_documents_custom_schema_same_client FOREIGN KEY (custom_schema_id, "clientId") REFERENCES client_metadata_schemas(id, client_id)` (Section 4.4, replaces v3's Trigger 2) +- `CREATE INDEX idx_documents_custom_schema_id ON documents(custom_schema_id)` +- Down migration drops the constraint, the index, and the column in the correct order + +> **v4 renumbering note**: In earlier drafts of v4, this migration was numbered 129 and `document_custom_metadata` (now Migration 129) was numbered 128. The order was swapped so Milestone 1 can ship monotonically (127 + 128) and Milestone 2 can ship its own monotonic pair (129 + 130). `golang-migrate` only applies files with versions strictly greater than the current DB version, so Milestone 1 must not leave any gaps that Milestone 2 would need to backfill. + +**Migration 129: Create `document_custom_metadata` table** +``` +00000000000129_create_document_custom_metadata.up.sql +00000000000129_create_document_custom_metadata.down.sql +``` + +Contents: +- `document_custom_metadata` table per Section 4.3, including `ON DELETE CASCADE` on the `document_id` FK. **No `schema_id` column. No view.** +- Down migration drops the table. + +**Migration 130: Add `trg_prevent_schema_reassignment` and `trg_prevent_legacy_extraction_on_custom_document` triggers** +``` +00000000000130_add_schema_invariant_triggers.up.sql +00000000000130_add_schema_invariant_triggers.down.sql +``` + +Contents: +- `trg_prevent_schema_reassignment()` function + trigger (Section 4.7, Trigger 1). +- `trg_prevent_legacy_extraction_on_custom_document()` function + trigger (Section 4.7, Trigger 2 -- the v4 defense-in-depth pair for the mutual-exclusivity invariant). +- v4 does not create v3's former Trigger 2 for client ownership match (replaced by the composite FK in Migration 128) or v3's former Trigger 3 for schema_id consistency (not needed because `document_custom_metadata` has no `schema_id` column). +- Down migration drops the trigger and function + +The filename `00000000000130_add_schema_invariant_triggers.up.sql` is retained for continuity with v3's migration numbering. v4 lands two triggers (`trg_prevent_schema_reassignment` and `trg_prevent_legacy_extraction_on_custom_document`) instead of v3's three. A reviewer comparing v3 and v4 migration diffs will see the simplification clearly by reading the file's body. + +### 8.2 SQLC Queries + +New query file: `internal/database/queries/customschemas.sql` + +**Schema operations:** +- `CreateClientMetadataSchema` - Insert new schema +- `GetClientMetadataSchema` - Get by ID +- `ListClientMetadataSchemas` - List by client with filters (status filter). Computes `documentCount` via a `COUNT(*) FROM documents WHERE custom_schema_id = cms.id` subquery so `canDelete` can be derived in the handler without a second round-trip. +- `GetLatestSchemaByName` - Get latest version of a named schema +- `GetSchemaDocumentCount` - Count documents using a schema (via `documents.custom_schema_id`) +- `SetSchemaStatus` - Update status (for supersede/retire) +- `GetMaxSchemaVersion` - For auto-incrementing version +- `LockSchemaVersionsForName` - `SELECT ... FOR UPDATE` on all versions of a (client_id, name) pair to prevent concurrent version creation race + +> **v4 query removed**: `GetSchemaMetadataRecordCount` is gone. Schema usage is knowable from `GetSchemaDocumentCount` alone because `document_custom_metadata` no longer carries a `schema_id` column. + +**Metadata operations:** +- `CreateDocumentCustomMetadata` - Insert new metadata version (no `schema_id` parameter -- the column does not exist) +- `GetCurrentDocumentCustomMetadata` - Latest version via `ORDER BY version DESC LIMIT 1` using `idx_dcm_doc_version_desc` (v4: no view). **Joins `documents` and `client_metadata_schemas` in the same query** to return the metadata row together with `schema_id`, `schema_name`, and `schema_version` (sourced from `documents.custom_schema_id -> client_metadata_schemas`). These three columns populate the response fields `schemaId` / `schemaName` / `schemaVersion` on `GET /custom-metadata`; they are **not** stored on `document_custom_metadata` itself. Columns are nullable and will all be null if the document has no schema bound (defensive — in practice a metadata row implies a bound schema). +- `GetDocumentCustomMetadataByVersion` - Specific version. **Also joins** `documents` + `client_metadata_schemas` so `GET /custom-metadata/version` can return the same decoration as `GET /custom-metadata`. +- `GetDocumentCustomMetadataHistory` - All versions (history endpoint returns version summaries only, no schema decoration needed — the history response shape in Section 5.4 does not include schema fields). +- `LockDocumentForMetadataWrite` - `SELECT id FROM documents WHERE id = @document_id FOR UPDATE`. Parent-row lock that serializes both the first write and all subsequent writes for a document (see Section 4.3 concurrency notes). **Replaces** v3's `LockDocumentCustomMetadataForVersion`, which locked the max-version row and could not serialize the first write. +- `GetMaxDocumentMetadataVersion` - `SELECT COALESCE(MAX(version), 0) FROM document_custom_metadata WHERE document_id = @document_id`. Called after the parent-row lock is held. + +**Document modifications:** +- `SetDocumentCustomSchemaId` - Update documents.custom_schema_id +- `GetDocumentCustomSchemaId` - Read documents.custom_schema_id +- `BulkSetDocumentCustomSchemaIdInFolderTree` - Set schema for all docs in folder subtree (WITH RECURSIVE) +- `GetDocumentsWithMetadataInFolderTree` - Find docs that already have metadata (for skip reporting) +- `GetDocumentsWithLegacyExtractionsInFolderTree` - Find docs that have legacy extractions (for skip reporting) +- **Reused, not new**: single-document "does this document have a legacy extraction row?" check uses the existing `HasFieldExtraction` query in `internal/database/queries/fieldextractions.sql` (already `EXISTS(SELECT 1 FROM documentFieldExtractionVersions WHERE documentId = $1)`). v4 does **not** add a new `DocumentHasLegacyExtractions` query; the mutual-exclusivity guards in `AssignSchema`, `SetDocumentMetadata`, and `ResetDocumentMetadata` call `HasFieldExtraction` directly. If a readability wrapper is desired at the call site, add a private service-layer helper (e.g. `documentHasLegacyExtractions(ctx, id)` that forwards to `HasFieldExtraction`) — no new SQL. +- **Reused, not new**: folder-subtree total-document count uses the existing `CountDocumentsInFolderTree` in `internal/database/queries/folders.sql` (recursive CTE over `folders` + `documents.folderId`). v4's bulk endpoint calls it directly for the `MaxBulkAssignDocuments` pre-check; no new query. + +**`GetDocumentEnriched` (modified, not new)**: `internal/database/queries/document.sql` already defines `GetDocumentEnriched`. Milestone 2 extends its SELECT list to add `d.custom_schema_id` and `EXISTS(SELECT 1 FROM document_custom_metadata WHERE document_id = d.id) AS has_custom_metadata` so `GET /document/{id}` can populate the two new `DocumentEnriched` response fields (Section 5.2) in a single round-trip. The corresponding `DocumentEnriched` struct in `internal/document/service.go` and the `GetEnriched` function in `internal/document/get.go` are extended in the same milestone; see tracking §2.3a. + +**Delete cascade operations: none required in v4.** + +In v3 the delete cascade added three queries: `DeleteDocumentCustomMetadata` on `document.sql`, `NullifyDocumentCustomSchemaId` on `document.sql`, and `DeleteClientMetadataSchemas` on `client.sql`. **v4 removes all three from the delete cascade path**: `ON DELETE CASCADE` on `document_custom_metadata.document_id` makes the first unnecessary, the v3 claim that `custom_schema_id` must be nulled before document delete was incorrect (Section 4.4 / Section 8.5), and `ON DELETE CASCADE` on `client_metadata_schemas.client_id` makes the third unnecessary. **The existing `DeleteDocumentCascade` and `client.HardDelete` code paths are not touched by v4.** + +**Reset-metadata support queries (v4, all in `customschemas.sql`):** +- `LockDocumentForReset` - `SELECT id, "clientId", custom_schema_id FROM documents WHERE id = @document_id FOR UPDATE`. Used by `ResetDocumentMetadata`; uses the same parent-row lock shape as `LockDocumentForMetadataWrite` so the two operations serialize correctly against each other. +- `GetDocumentSchemaBindingForReset` - Join `documents` to `client_metadata_schemas` to return the document's current `custom_schema_id`, the schema `name`, and `version`. Used to populate the `previous*` fields of the reset response. Returns all-null columns cleanly when the document has no schema bound. +- `CountDocumentCustomMetadataVersions` - `SELECT COUNT(*) FROM document_custom_metadata WHERE document_id = @document_id`. Used to populate `metadataVersionsDeleted` in the reset response. +- `DeleteDocumentCustomMetadataForReset` - `DELETE FROM document_custom_metadata WHERE document_id = @document_id`. Owned by the reset-metadata path (v4: not shared with a delete cascade). Name disambiguates from v3's cascade query. +- `NullifyDocumentCustomSchemaIdForReset` - `UPDATE documents SET custom_schema_id = NULL WHERE id = @document_id`. Owned by the reset-metadata path. +- The reset path reuses the existing `HasFieldExtraction` query (not a new `DocumentHasLegacyExtractions`) for the mutual-exclusivity guard — same reason as the AssignSchema / SetDocumentMetadata guards above. If the legacy extraction check returns `true`, `ResetDocumentMetadata` returns 409 and rolls back. + +### 8.3 OpenAPI Spec Changes + +Add to `serviceAPIs/queryAPI.yaml`: + +**New `SuperAdminSchemaService` tag** for super_admin-only endpoints: +- `POST /super-admin/custom-schemas` +- `GET /super-admin/custom-schemas` +- `GET /super-admin/custom-schemas/{schemaId}` +- `POST /super-admin/custom-schemas/{schemaId}/versions` **(v4: replaces `PUT /super-admin/custom-schemas/{schemaId}`)** +- `DELETE /super-admin/custom-schemas/{schemaId}` +- `PATCH /super-admin/documents/{id}/schema` +- `POST /super-admin/folders/{folderId}/assign-schema` +- `POST /super-admin/documents/{id}/reset-metadata` + +**New `CustomMetadataService` tag** for end-user endpoints: +- `GET /custom-metadata` +- `POST /custom-metadata` +- `GET /custom-metadata/version` +- `GET /custom-metadata/history` + +**New request schemas (v4: none include a `createdBy` field)**: +- `CustomSchemaRequest` (for `POST /super-admin/custom-schemas`) +- `CustomSchemaVersionRequest` (for `POST /super-admin/custom-schemas/{schemaId}/versions` -- v4 rename from v3's `CustomSchemaUpdateRequest`) +- `CustomMetadataRequest` +- `BulkSchemaAssignRequest` +- `DocumentSchemaAssignRequest` + +**New response schemas**: +- `CustomSchemaResponse` +- `CustomSchemaListResponse` +- `CustomMetadataResponse` +- `CustomMetadataHistoryResponse` +- `ValidationErrorResponse` +- `BulkSchemaAssignResponse` +- `DocumentSchemaAssignResponse` +- `ResetDocumentMetadataResponse` + +**New enum**: `SchemaStatus` with values `active`, `superseded`, `retired` + +**Modified schemas**: `DocumentEnriched` (v4 adds exactly two fields: `customSchemaId` and `hasCustomMetadata`. v3's proposed `customSchemaName` and inlined `customMetadata` payload are dropped -- see Section 5.2). + +**Tag descriptions should explicitly call out the authorization boundary:** +- `SuperAdminSchemaService` description: "Custom metadata schema management and assignment. Restricted to the `super_admin` role. Not accessible to `user_admin`." +- `CustomMetadataService` description: "Per-document custom metadata read/write. Accessible to `client_user`, `user_admin`, and `super_admin`." + +**Actor fields — schema type (v4):** Every response schema above that carries a `createdBy` or `resetBy` field (`CustomSchemaResponse`, `CustomMetadataResponse`, `CustomMetadataHistoryResponse`, `DocumentSchemaAssignResponse`, `BulkSchemaAssignResponse`, `ResetDocumentMetadataResponse`) MUST declare the field as: + +```yaml +createdBy: + type: string + minLength: 1 + maxLength: 255 + description: "Cognito subject ID (JWT sub claim) of the caller. Opaque UUID, not an email." + example: "b7a4c1d2-8e3f-4a5b-9c6d-0e1f2a3b4c5d" +``` + +Do **NOT** use `format: email` on these fields. This is a deliberate break from existing `createdBy` fields elsewhere in `queryAPI.yaml` (e.g. folder `createdBy`, legacy field-extraction `createdBy`), which declare `format: email`. On this service, email is not guaranteed to be present in the Cognito JWT, so every new endpoint sources actor identity from the `sub` claim via `cognitoauth.GetUserSubject(c)`. Using `format: email` would cause spec validation failures on every response. If existing `createdBy` fields are ever migrated to the same subject-ID model, they will need a separate migration and changelog; v4 does not touch them. + +### 8.4 Legacy Field Extraction Guard + +Modify the existing field extraction service (`internal/fieldextraction/service.go`): + +At the **top** of every mutation entry point's transaction (starting with `CreateFieldExtraction`, then every other legacy write method), take `SELECT id, custom_schema_id FROM documents WHERE id = $1 FOR UPDATE`. If `custom_schema_id IS NOT NULL`, return the sentinel `ErrMutualExclusivityViolation`, which the controller maps to `409 Conflict`. This lock is the service-layer half of the mutual-exclusivity serialization described in Section 7.3; Trigger 2 on `documentFieldExtractions INSERT` (Section 4.7) is the DB-layer half. + +Important: the parent-row lock must be the **first** statement inside the transaction, before `AddFieldExtraction` or any other DML. Taking the lock after the first INSERT defeats the purpose -- Trigger 2 still catches the race, but the error path is less clean and the app's 409 mapping relies on SQLSTATE detection instead of a sentinel from the service. + +This is a small modification to each existing code path, not a new endpoint. + +### 8.5 Delete Path Updates: **None required in v4** + +v3 added an entire workstream (Phase 5b in the v3 tracking doc) to update `DeleteDocumentCascade` and `client.HardDelete` to wire in three new SQLC queries (`DeleteDocumentCustomMetadata`, `NullifyDocumentCustomSchemaId`, `DeleteClientMetadataSchemas`). **v4 removes that workstream entirely.** The existing delete code paths are not modified. + +Two independent design choices let v4 drop the extra delete code: + +1. **`ON DELETE CASCADE` on `document_custom_metadata.document_id` and `client_metadata_schemas.client_id`**. Hard-deleting a document causes PostgreSQL to automatically delete its custom metadata rows. Hard-deleting a client causes PostgreSQL to automatically delete its schemas -- which is safe because by the time the client row is deleted, every document that could have been referencing a schema is already gone (documents are cascaded first in `client.HardDelete`). +2. **The v3 claim that `custom_schema_id` must be nulled before the document row delete was incorrect.** PostgreSQL does not require a referencing column to be cleared before the referencing row is deleted -- deleting the referencing row removes the reference atomically. The FK on `documents.custom_schema_id` (now composite, see Section 4.4) points *out* of the `documents` row, not *in* to it, so there is nothing to unlink first. + +**What still happens in the delete code paths (unchanged from main):** + +`DeleteDocumentCascade` in `internal/document/` continues to: +1. Delete `documentFieldExtractionArrayFields` +2. Delete `documentFieldExtractionVersions` +3. Delete `documentFieldExtractions` +4. Delete `documentCleanEntries` / `documentCleans` / `documentEntries` +5. Delete the `documents` row + +Step 5 automatically cascades to `document_custom_metadata` via the FK. + +`client.HardDelete` in `internal/client/` continues to: +1. Delete all documents with full cascade (which now auto-cascades to their custom metadata) +2. Delete `documentUploads` +3. Delete all folders +4. Delete collector data, batch uploads, sync records +5. Delete the `clients` row + +Step 5 automatically cascades to `client_metadata_schemas` via the FK. The FK cascade is safe because step 1 already deleted every document that could have referenced any schema, so no `documents.custom_schema_id` FK check can fail at cascade time. + +**Regression risk**: Because v4 does not touch any of this code, there is no regression risk to existing delete paths. The only thing that needs to be verified is that `task fullsuite:ci` still passes end-to-end with documents and clients that carry custom metadata (see the delete-cascade integration tests in Section 12 / the v4 tracking doc). **v4 intentionally does not split out a "delete cascade phase"** -- verification is folded into the integration test milestone. + +**Reset-metadata is not a delete-cascade path.** Reset-metadata (Section 5.2) explicitly wipes `document_custom_metadata` rows while keeping the `documents` row alive. That code path owns its own `DeleteDocumentCustomMetadataForReset` and `NullifyDocumentCustomSchemaIdForReset` queries (Section 8.2) and is unrelated to the delete cascade discussion in this section. + +### 8.6 Permit.io Setup Tool Run + +After updating `permit_policies.yaml` per Section 5.3, run the permit setup tool in each environment: + +``` +cmd/auth_related/permit.setup/run.tool.dev.sh +cmd/auth_related/permit.setup/run.tool.uat.sh +cmd/auth_related/permit.setup/run.tool.prod.sh +``` + +The tool is idempotent and will provision the new `super-admin` and `custom-metadata` resources and update role grants without disturbing existing resources. Run this **before** deploying the API code so the new endpoints are authorized the moment they go live. + +--- + +## 9. Invariants & Business Rules + +| # | Rule | Enforcement (v4) | +|---|------|-------------| +| 1 | Schema must be valid JSON Schema with explicit `additionalProperties` | Application: meta-validation on create/version-create | +| 2 | Schema `name` + `version` unique per client | Database: unique constraint `uq_client_schema_name_version` | +| 3 | New schema version gets new ID; old version becomes `superseded` | Application: INSERT new row, UPDATE old status in the same transaction (service method `CreateSchemaVersion`) | +| 4 | Schema deletion blocked if documents reference it | Application: `GetSchemaDocumentCount` check before retirement (v4: single-table check; `document_custom_metadata.schema_id` no longer exists so no second count needed) | +| 5 | Document's `custom_schema_id` is immutable once any extraction exists | Application: check in `AssignSchema` + DB trigger: `trg_prevent_schema_reassignment` | +| 6 | Custom metadata must conform to assigned schema | Application: JSON Schema validation on write | +| 7 | ~~All custom metadata versions for a document use same schema ID~~ | **Removed in v4.** The `document_custom_metadata` table no longer carries a `schema_id` column, so the invariant is structurally impossible to violate -- the "schema a metadata row uses" is always `documents.custom_schema_id` as of the write time, and Invariant 5 keeps that frozen. | +| 8 | Schema must belong to same client as document | Database: composite FK `fk_documents_custom_schema_same_client` (v4 replaces v3's Trigger 2); Application: app-layer check still runs for clean 409 error messages | +| 9 | Only `active` schemas can be assigned to new documents | Application: status check | +| 10 | Custom metadata is versioned (no in-place updates) | Database: version column + unique constraint | +| 11 | Schema definition must be <= 64KB | Application: size check + Database: CHECK constraint | +| 12 | Schema management requires `super_admin` role (NOT `user_admin`) | Permit.io: role-based access on the `super-admin` resource; `user_admin` is explicitly NOT granted this resource | +| 13 | No query/filter on custom metadata values | By design: not implemented (see Principle 9) | +| 14 | A document uses EITHER legacy extractions OR custom metadata, never both | Application: mutual exclusivity checks in both services + DB trigger `trg_prevent_schema_reassignment` | +| 15 | Legacy field extraction writes rejected if document has `custom_schema_id` set | Application: guard in `FieldExtractionService` + 409 mapping in the controller | +| 16 | Custom schema assignment rejected if document has legacy extractions | Application: guard in `AssignSchema` + DB trigger | +| 17 | Metadata payload must be <= 1MB | Application: size check (`MaxMetadataPayloadBytes`) | +| 18 | Bulk folder assignment capped at 10,000 documents | Application: count check (`MaxBulkAssignDocuments`), returns 422 if exceeded | +| 19 | Schema assignment (single doc + bulk folder) requires `super_admin` role | Permit.io: endpoints under `/super-admin/` prefix map to `super-admin` resource; `user_admin` is NOT granted | +| 20 | Delete of a document cleans up custom metadata; delete of a client cleans up schemas | **Database: `ON DELETE CASCADE`** on `document_custom_metadata.document_id` and `client_metadata_schemas.client_id`. No application code changes required in v4. | +| 21 | Metadata read/write is available to `client_user`, `user_admin`, `super_admin` (not `auditor` for writes) | Permit.io: `custom-metadata` resource grants on each role per Section 5.3 | +| 22 | Reset-metadata is atomic: all `document_custom_metadata` rows deleted AND `custom_schema_id` nulled, or neither | Application: single transaction with row-level lock on the document (Section 5.2, `POST /super-admin/documents/{id}/reset-metadata`) | +| 23 | Reset-metadata requires `super_admin` role | Permit.io: endpoint under `/super-admin/` prefix maps to `super-admin` resource; `user_admin` / `client_user` / `auditor` denied | +| 24 | Reset-metadata is rejected if the document has legacy field extractions | Application: `HasFieldExtraction` guard in `ResetDocumentMetadata` returns 409 (mutual exclusivity -- Invariant 14 still holds). v4 reuses the existing `HasFieldExtraction` query rather than adding a new `DocumentHasLegacyExtractions` alias — see Section 8.2. | +| 25 | Reset-metadata is idempotent on already-clean documents | Application: transaction succeeds with `metadataVersionsDeleted: 0` when no custom metadata and no schema binding exist | +| 26 | Actor identity on every mutable write comes from the authenticated JWT `sub` claim (an opaque Cognito subject UUID), not the request body, not an email | **v4**: `createdBy` removed from all new request body schemas (Section 5.4 note, Section 7.1 method signatures). The service layer accepts `actor` as a separate argument, sourced from `cognitoauth.GetUserSubject(ctx)` in the handler. Response `createdBy`/`resetBy` fields are declared as plain strings in OpenAPI (no `format: email`) — see Section 8.3 "Actor fields". | + +--- + +## 10. Relationship to Existing Field Extraction System + +The existing static field extraction system (`/field-extractions`) and the new custom schema system (`/super-admin/custom-schemas` + `/custom-metadata`) are **mutually exclusive per document**: + +- A document uses EITHER the legacy static field extraction system OR the custom schema system +- A document CANNOT have both legacy field extractions AND custom metadata +- Assigning a custom schema to a document that already has legacy extractions returns `409 Conflict` +- Writing a legacy field extraction to a document that has a custom schema assigned returns `409 Conflict` +- Documents without a custom schema (the default) continue to use the legacy system unchanged +- There is no migration path from legacy to custom for individual documents. If a document needs custom metadata, it should not have legacy extractions written to it first. + +**Rationale**: This prevents ambiguity about which system is "authoritative" for a document's metadata. With mutual exclusivity, there is exactly one source of truth per document. This is simpler to reason about, simpler to query, and avoids the data governance problem of conflicting data in two stores. + +**No breaking changes** to the existing API or database schema for documents that do not opt into custom schemas. The existing `/field-extractions` endpoints remain on the `field-extractions` resource and are unaffected by the new `super-admin` resource. + +--- + +## 11. Implementation Order -- Vertical Slice Milestones + +v3 organized the work as 12 horizontal layers (migrations -> SQLC -> validator -> service -> controllers -> tests), which meant the feature was not end-to-end exercisable until very late in the rollout. v4 reorganizes the work into four vertical slice milestones. Each milestone contains migration/query/service/controller/test work and ends with a real feature that a super_admin or client_user can use. If any milestone slips, the earlier milestones are still usable in isolation. + +### Milestone 1: Schema foundation + +**Goal**: A super_admin can create and list client-scoped custom schemas. Invalid schemas are rejected. Cross-client bindings are structurally impossible. + +**Includes**: +- Migration 127 (`schema_status_type` + `client_metadata_schemas` table with `uq_cms_id_client`) +- Migration 128 (`documents.custom_schema_id` column + composite FK `fk_documents_custom_schema_same_client` -- depends on 127 landing first) +- SQLC queries for schema CRUD (`CreateClientMetadataSchema`, `GetClientMetadataSchema`, `ListClientMetadataSchemas`, `GetLatestSchemaByName`, `GetSchemaDocumentCount`, `SetSchemaStatus`, `GetMaxSchemaVersion`, `LockSchemaVersionsForName`) +- `internal/customschema/validator.go` + constants + dependency on `github.com/santhosh-tekuri/jsonschema/v6` +- Audit sink (`AuditSink` interface, default slog-backed implementation) -- needed now so schema CRUD can log +- Service methods: `CreateSchema`, `CreateSchemaVersion`, `GetSchema`, `ListSchemas`, `DeleteSchema` +- OpenAPI: `SuperAdminSchemaService` tag, `CustomSchemaRequest`, `CustomSchemaVersionRequest`, `CustomSchemaResponse`, `CustomSchemaListResponse`, `SchemaStatus` enum; paths for `POST /super-admin/custom-schemas`, `GET /super-admin/custom-schemas`, `GET /super-admin/custom-schemas/{schemaId}`, `POST /super-admin/custom-schemas/{schemaId}/versions`, `DELETE /super-admin/custom-schemas/{schemaId}` +- Permit.io: `super-admin` resource with `get`/`post`/`patch`/`delete` actions; role grants; **run setup tool against dev/uat/prod BEFORE the controller code is deployed** +- Controllers in `api/queryAPI/customschemas.go` for all five schema CRUD routes +- Authorization integration tests (positive for `super_admin`, 403 for `user_admin`, `client_user`, `auditor`; read-only positive for `auditor`) +- End-to-end tests: super_admin creates schema v1, creates v2 via `POST .../versions`, lists schemas (sees both with `status` correct), deletes retired schema +- Cross-client rejection test: attempt to reference a schema owned by a different client -> FK violation -> 409 response + +**Exit criteria**: `task fullsuite:ci` green across all of the above. Feature is half-useful on its own (you can create and inspect schemas but not yet assign them to documents). + +### Milestone 2: Core document flow + +**Goal**: A super_admin can assign a schema to a document; a client_user can write and read custom metadata; the legacy field extraction guard is in place. + +**Includes**: +- Migration 129 (`document_custom_metadata` table, no `schema_id`, no view, `ON DELETE CASCADE`) +- Migration 130 (`trg_prevent_schema_reassignment` and `trg_prevent_legacy_extraction_on_custom_document` triggers -- depends on Migration 129 landing first because Trigger 1 references `document_custom_metadata`) +- SQLC queries for metadata operations (`CreateDocumentCustomMetadata`, `GetCurrentDocumentCustomMetadata`, `GetDocumentCustomMetadataByVersion`, `GetDocumentCustomMetadataHistory`, `LockDocumentForMetadataWrite`, `GetMaxDocumentMetadataVersion`) +- SQLC queries for document binding (`SetDocumentCustomSchemaId`, `GetDocumentCustomSchemaId`; legacy extraction existence check **reuses** the existing `HasFieldExtraction` query — no new `DocumentHasLegacyExtractions` query is added) +- **Extend `GetDocumentEnriched` query** (`internal/database/queries/document.sql`) to SELECT `d.custom_schema_id` and an `EXISTS(SELECT 1 FROM document_custom_metadata WHERE document_id = d.id)` subquery as `has_custom_metadata`. This keeps the enrichment to **one** round-trip; no separate follow-up query. +- **Extend `DocumentEnriched` struct** (`internal/document/service.go`) to add `CustomSchemaID *uuid.UUID` and `HasCustomMetadata bool` fields, and **update `GetEnriched`** (`internal/document/get.go`) to copy these fields out of the extended generated row into the struct. +- Service methods: `SetDocumentMetadata`, `GetCurrentMetadata`, `GetMetadataByVersion`, `GetMetadataHistory`, `AssignSchema` +- Legacy field extraction guard: service-layer sentinel error + controller 409 mapping in `api/queryAPI/fieldextractions.go` +- `GET /document/{id}` enrichment: add `customSchemaId` + `hasCustomMetadata` (and only those two -- v4 scope trim, Section 5.2). The controller-side change in `api/queryAPI` depends on the `GetDocumentEnriched` / `DocumentEnriched` / `GetEnriched` backend extension above — do that work first, then wire the controller builder. +- OpenAPI: `PATCH /super-admin/documents/{id}/schema`, `GET/POST/... /custom-metadata`, `CustomMetadataService` tag, `CustomMetadataRequest`, `CustomMetadataResponse`, `CustomMetadataHistoryResponse`, `ValidationErrorResponse`, `DocumentSchemaAssignRequest`, `DocumentSchemaAssignResponse`, updated `DocumentEnriched` +- Permit.io: `custom-metadata` resource grants (if not already landed in Milestone 1); no setup tool re-run needed if Milestone 1 already provisioned it +- Controllers in `api/queryAPI/customschemas.go` (assign-schema) and `api/queryAPI/custommetadata.go` (metadata CRUD) +- Integration tests for the full user flow: create schema, assign to document, write valid metadata, reject invalid metadata, reject legacy field extraction on custom-schema document, reject schema assignment on legacy document, verify `GET /document/{id}` reflects the new fields +- Concurrency tests for `SetDocumentMetadata` (first-writer parent-row lock) +- Delete cascade verification: delete a document with custom metadata, delete a client with schemas and metadata -- both succeed via FK cascade, no orphaned rows + +**Exit criteria**: `task fullsuite:ci` green. The feature now satisfies the central requirement ("client-scoped schemas validated at write time, frozen once metadata exists"). Everything after this milestone is additive. + +### Milestone 3: Administrative completion + +**Goal**: The full schema lifecycle works end-to-end, including reset-metadata for document-level schema upgrades. + +**Includes**: +- Service method `ResetDocumentMetadata` with the full transactional implementation (Section 7.1) +- SQLC queries for reset-metadata (`LockDocumentForReset`, `GetDocumentSchemaBindingForReset`, `CountDocumentCustomMetadataVersions`, `DeleteDocumentCustomMetadataForReset`, `NullifyDocumentCustomSchemaIdForReset`) +- Handler for `POST /super-admin/documents/{id}/reset-metadata` in `api/queryAPI/customschemas_reset.go` (new file, split out from `customschemas.go` to unlock parallel execution with Milestone 4 — see the pre-partitioning note in Milestone 4's dependencies) +- OpenAPI: `ResetDocumentMetadataResponse` schema, path entry, policy-mapping doc entry +- Schema version-creation concurrency tests (two concurrent `POST .../versions` calls produce distinct versions via `LockSchemaVersionsForName`) +- Reset-metadata integration tests: happy path, atomicity under injected failure, idempotency, legacy-extraction guard (409), not-found (404), trigger regression (trigger still fires after reset+reassign+write) +- Authorization tests for reset-metadata and version-creation across all four roles +- End-to-end upgrade flow test: create v1 -> assign -> write metadata -> create v2 via `POST .../versions` -> reset -> re-assign to v2 -> write new metadata with the new field + +**Exit criteria**: `task fullsuite:ci` green. A super_admin can complete a "document upgrade to new schema version" without any direct database access. + +### Milestone 4: Bulk operations + +**Goal**: Bulk folder schema assignment is available for administrative convenience. + +**Depends on**: Milestone 2 (single-document `AssignSchema` semantics). Milestone 3's reset-metadata work is **not** a prerequisite — nothing in the recursive folder walk reads or writes the reset path. Milestones 3 and 4 are **parallelizable only if shared files are pre-partitioned first**: as originally drafted both milestones add handler code to `api/queryAPI/customschemas.go` and types to `internal/customschema/models.go`, so running them on separate workstreams without partitioning will cause merge conflicts at the seam. To unlock true parallelism, carve the files before branching: M3's reset handler lives in a new file `api/queryAPI/customschemas_reset.go` with reset types in `internal/customschema/models_reset.go`, and M4's bulk handler lives in `api/queryAPI/customschemas_bulk.go` with bulk types in `internal/customschema/models_bulk.go`. With those four files pre-created (even as empty stubs with just the package declaration), the two workstreams have disjoint file ownership and can land independently, using `task fullsuite:ci` green as the merge gate. Serializing M4 behind M3 remains the safe fallback if the pre-partitioning step is skipped. + +**Includes**: +- SQLC queries for the bulk path (`BulkSetDocumentCustomSchemaIdInFolderTree`, `GetDocumentsWithMetadataInFolderTree`, `GetDocumentsWithLegacyExtractionsInFolderTree`, `CountDocumentsInFolderTree`) +- Service method `AssignSchemaToFolder` with the full recursive-CTE behavior, skip-reason reporting, and the 10K document cap +- Handler for `POST /super-admin/folders/{folderId}/assign-schema` in `api/queryAPI/customschemas_bulk.go` (new file, per the pre-partitioning note in this milestone's `Depends on` block) +- OpenAPI: `BulkSchemaAssignRequest`, `BulkSchemaAssignResponse`, path entry, policy-mapping doc entry +- Integration tests: recursive walk, all skip reasons, empty folder, deeply nested trees, `MaxBulkAssignDocuments` boundary (10000 pass, 10001 fail with 422) +- Authorization tests for the bulk endpoint + +**Exit criteria**: `task fullsuite:ci` green. All features from v3 are present. + +### Documentation (parallel with Milestones 3 and 4) + +Once Milestone 2 is green the feature is shippable in a narrow form, and documentation can begin in parallel: + +- `docs/ai.generated/` pages for the custom metadata feature: sample schemas from Appendix A, authorization matrix, mutual-exclusivity explanation +- Reset-metadata runbook ("How to upgrade a document to a new schema version") lands alongside Milestone 3 +- Bulk folder runbook lands alongside Milestone 4 +- Deployment ordering note (run the Permit.io setup tool in each environment before deploying the controllers) + +> **Cross-cutting ordering constraint**: In every environment, the Permit.io setup tool must run before the controller code lands, so the new routes are authorized on first request. This constraint applies to Milestone 1 (creates the `super-admin` resource) and Milestone 2 (creates `custom-metadata` resource if it was not rolled up into Milestone 1). Milestones 3 and 4 do not require a setup tool re-run because they reuse existing grants. + +--- + +## 12. Testing Strategy + +- **Schema validation tests**: Valid and invalid JSON Schema documents, edge cases (empty schema, deeply nested, self-referential, oversized >64KB, missing `additionalProperties`) +- **Metadata validation tests**: Conforming and non-conforming payloads, all supported JSON Schema keywords +- **Service integration tests**: Full CRUD lifecycle using testcontainers (real PostgreSQL) +- **API integration tests**: HTTP-level tests for all new endpoints under both `/super-admin/*` and `/custom-metadata/*` +- **Invariant tests**: Schema immutability, cross-client isolation, deletion guards, size limits +- **DB-level safety-net tests (v4)**: + - `trg_prevent_schema_reassignment` fires on direct SQL UPDATE when metadata exists (bypass test) -- positive and negative cases + - `trg_prevent_schema_reassignment` fires on direct SQL UPDATE when legacy extractions exist (bypass test) + - Composite FK `fk_documents_custom_schema_same_client` refuses a direct SQL UPDATE that points a document at a schema owned by a different client (replaces v3's Trigger 2 test) + - `ON DELETE CASCADE` on `document_custom_metadata.document_id` fires on direct SQL delete of a document row + - `ON DELETE CASCADE` on `client_metadata_schemas.client_id` fires on direct SQL delete of a client row after its documents are gone +- **Schema status lifecycle tests**: `active` -> `superseded` on version create, `active` -> `retired` on delete, assignment rejected for non-`active` schemas +- **Mutual exclusivity tests**: Legacy extraction blocked when schema assigned, schema assignment blocked when legacy extractions exist, bulk folder assignment skips documents with legacy extractions +- **Bulk folder assignment tests**: Recursive assignment, skip behavior for documents with existing metadata and legacy docs, empty folders, deeply nested folder trees +- **Authorization tests**: + - `super_admin` CAN create, list, get, create-new-version, delete schemas, assign schemas to documents, assign schemas to folders, reset metadata + - `user_admin` CANNOT access any `/super-admin/*` endpoint (expect 403 on every method + path combination) + - `user_admin` CAN read and write custom metadata via `/custom-metadata` (no regression) + - `client_user` CANNOT access any `/super-admin/*` endpoint (expect 403) + - `client_user` CAN read and write custom metadata via `/custom-metadata` + - `auditor` CAN read (`GET`) `/super-admin/custom-schemas` and `/super-admin/custom-schemas/{schemaId}` but CANNOT POST/PATCH/DELETE (v4: no `PUT` to test) + - `auditor` CAN read but CANNOT write `/custom-metadata` + - Each test should assert both the HTTP status code AND that the database state is unchanged on denial +- **Actor identity tests (v4)**: for every new write endpoint, set the middleware fixture so `cognitoauth.GetUserSubject` returns a known Cognito subject UUID, then POST a body that includes a spurious `createdBy` field with an email-shaped value; assert that (a) the stored `created_by` column holds the subject UUID (not the body value, not an email), (b) the response `createdBy` / `resetBy` fields echo the subject UUID, and (c) the response passes OpenAPI schema validation (confirming the plain-string `createdBy` declaration in Section 8.3 is in force) +- **Backward compatibility tests**: Existing field extraction endpoints continue to work unchanged for documents without custom schemas; existing `/admin/*` endpoints are unaffected by the new `super-admin` resource +- **Delete cascade tests**: Document hard-delete succeeds when document has custom metadata, client hard-delete succeeds when client has schemas and metadata, verify no orphaned rows remain +- **Metadata size limit tests**: Payloads at/above 1MB boundary, verify 400 response with clear message +- **Bulk assignment limit tests**: Folder trees exceeding `MaxBulkAssignDocuments`, verify 422 response +- **Schema version concurrency tests**: Concurrent `POST /super-admin/custom-schemas/{schemaId}/versions` requests for the same schema name produce distinct version numbers +- **Permit.io setup tool tests**: After running the setup tool against a disposable dev tenant, verify that the `super-admin` and `custom-metadata` resources exist with the expected actions and that each role has exactly the grants defined in Section 5.3 +- **Reset-metadata tests**: + - Happy path: document with schema v1 and N metadata versions -> reset -> assign schema v2 -> POST metadata -> `GET /document/{id}` reflects schema v2 and version 1 metadata; `metadataVersionsDeleted` in the response equals N; `previousSchemaId`/`previousSchemaName`/`previousSchemaVersion` reflect the wiped binding. + - Atomicity: inject a failure between `DELETE FROM document_custom_metadata` and `UPDATE documents SET custom_schema_id = NULL` (e.g., by killing the DB connection mid-transaction in a testcontainers run) and verify that `document_custom_metadata` rows for the document are NOT missing after the failed transaction. + - Idempotency: reset a document that has no custom schema and no custom metadata -> 200 OK with `metadataVersionsDeleted: 0`, `previousSchemaId: null`, DB state unchanged. + - Legacy extraction guard: reset a document that has rows in `documentFieldExtractionVersions` -> 409 Conflict, no DB state change. + - Document not found: reset a non-existent document ID -> 404, no DB state change. + - Trigger 1 still fires normally after a reset: reset, reassign schema v2, write a metadata row, then attempt a second schema reassignment -> trigger fires with the existing error message (confirms no trigger was weakened by reset-metadata). + - (v3's "Trigger 3 regression" test is removed in v4: `document_custom_metadata` has no `schema_id` column, so there is nothing to mismatch. The consistency test is replaced by a structural test that confirms the table has no `schema_id` column after migrations.) + - Authorization: `super_admin` succeeds; `user_admin`, `client_user`, and `auditor` all receive 403 with the DB state unchanged. + - Concurrency: two concurrent resets against the same document -> one succeeds, one observes the locked row and serializes (either succeeding on the already-clean state with `metadataVersionsDeleted: 0` or blocking until the first completes). No orphaned rows. + - Concurrency against assign-schema: a reset and a `PATCH /super-admin/documents/{id}/schema` targeting the same document concurrently -> both serialize via `FOR UPDATE`; either ordering produces consistent final state with no trigger violations. + - Audit log: every successful reset writes an audit entry containing the actor, document ID, previous schema ID, and metadata version count. + +--- + +## 13. Resolved Design Decisions + +| # | Question | Decision | +|---|----------|----------| +| 1 | Schema size limits | 64KB cap via `MaxSchemaDefinitionBytes` constant (tunable) | +| 2 | Schema backward compatibility | Not required. New version = new ID. Old docs keep old schema. | +| 3 | Bulk operations | Yes. `POST /super-admin/folders/{folderId}/assign-schema` assigns recursively through folder tree. | +| 4 | Custom metadata search/filter | **Not supported.** No GIN indexes, no query API. Metadata is opaque storage. | +| 5 | Schema templates | No system templates. Documentation will include sample schemas covering all supported types. | +| 6 | Existing field migration path | **Out of scope.** Static fields and custom schemas are mutually exclusive per document. | +| 7 | Endpoint naming/placement | Schema CRUD under `/super-admin/custom-schemas` (requires the `super_admin` role on the new `super-admin` Permit.io resource). Metadata CRUD under `/custom-metadata` (regular `client_user` access; also granted to `user_admin` and `super_admin`). | +| 8 | Legacy/custom coexistence | **Mutually exclusive.** A document uses EITHER legacy OR custom, never both. Simplifies data governance and prevents source-of-truth ambiguity. | +| 9 | Schema status model | Three-state enum (`active`/`superseded`/`retired`) instead of boolean. Distinguishes automatic version succession from explicit admin retirement. | +| 10 | DB-level invariant enforcement | Yes. Two database triggers (`trg_prevent_schema_reassignment` on `documents` UPDATE, `trg_prevent_legacy_extraction_on_custom_document` on `documentFieldExtractions` INSERT) plus one composite foreign key (`fk_documents_custom_schema_same_client`) enforce the invariants as defense-in-depth alongside application-layer checks. v3's `trg_validate_schema_client_match` is replaced by the FK; v3's `trg_enforce_consistent_schema_id` is removed because `document_custom_metadata` has no `schema_id` column. | +| 11 | `additionalProperties` in schemas | Required to be explicitly present (not necessarily `false`). Prevents the common mistake of omitting it and silently accepting arbitrary extra fields. | +| 12 | Metadata table design | Single table with version column (simpler than the legacy two-table pattern). Sufficient for our concurrency needs. Can split later if needed. | +| 13 | Schema assignment endpoint path | Under `/super-admin/documents/{id}/schema` (NOT `/admin/document/{id}/schema` as in v1 and NOT `/document/{id}/schema`). The `/super-admin` first segment maps to the new `super-admin` Permit.io resource which only `super_admin` holds. Placing it under `/admin` would also grant `user_admin`; placing it under `/document` would also grant `client_user`. | +| 14 | Metadata payload size limit | 1MB cap via `MaxMetadataPayloadBytes` constant. Prevents unbounded JSONB blobs when schemas allow `additionalProperties: true`. | +| 15 | Bulk assignment document limit | 10,000 documents via `MaxBulkAssignDocuments` constant. Prevents unbounded transactions and lock contention. | +| 16 | Mutual exclusivity race (v4) | Closed by (a) parent-row `SELECT ... FOR UPDATE` on `documents` at the top of both `AssignSchema` and every legacy `FieldExtractionService` mutation, and (b) DB trigger `trg_prevent_legacy_extraction_on_custom_document` on `documentFieldExtractions` INSERT as defense-in-depth. v3's planned `trg_enforce_consistent_schema_id` on `document_custom_metadata` INSERT is removed in v4 because the table has no `schema_id` column. | +| 17 | **v2**: Who can administer schemas? | **Only `super_admin`.** v1 unintentionally granted `user_admin` access by placing endpoints under `/admin/*`. v2 moves them to `/super-admin/*` and introduces a new `super-admin` Permit.io resource that only `super_admin` holds. `user_admin` retains its existing capabilities (user creation, field extractions, documents/folders, admin endpoints) but has no schema administration rights. | +| 18 | **v2**: Why a new resource instead of path-specific rules? | The Permit.io middleware resolves resources from the first path segment. Adding path-specific overrides would require middleware code changes. Adding a new first-segment resource (`super-admin`) is a pure configuration change and keeps the middleware simple. | +| 19 | **v2**: Does the service layer enforce role checks? | No. Authorization lives in the HTTP/Permit.io layer. The service layer (`internal/customschema/`) is role-agnostic and remains easy to test without fake identity contexts. | +| 20 | **v3**: How does a document move to a newer schema version once it has metadata? | Explicit, destructive reset. `POST /super-admin/documents/{id}/reset-metadata` wipes all metadata rows and nulls `custom_schema_id` in a single transaction; the caller then re-binds via `PATCH /super-admin/documents/{id}/schema` and re-enters values via `POST /custom-metadata`. | +| 21 | **v3**: Why destructive instead of an in-place compatible-schema swap? | Keeps Invariant 5 strict and trivially auditable; no JSON Schema compatibility comparator to maintain in sync across app and DB; forces explicit re-validation of stored values against the new schema; preserves a clean audit trail. See Section 5.2 for the full rationale. | +| 22 | **v3**: Any trigger changes? | **None.** Triggers 1, 2, and 3 are unchanged. The reset endpoint deletes metadata rows *before* updating `custom_schema_id`, so Trigger 1's `EXISTS` check observes an empty set and permits the UPDATE. No relaxation or bypass of any trigger. | +| 23 | **v3**: Any new Permit.io resource or action? | **None.** The existing `super-admin:post` grant on the `super_admin` role covers `POST /super-admin/documents/{id}/reset-metadata` because the first path segment is already `super-admin`. Only a policy-mapping doc entry is added. | +| 24 | **v3**: New migrations or tables? | **None.** The endpoint reuses `DeleteDocumentCustomMetadata` and `NullifyDocumentCustomSchemaId` queries already added for the delete cascade (Section 8.2). Three read-only queries (`LockDocumentForReset`, `GetDocumentSchemaBindingForReset`, `CountDocumentCustomMetadataVersions`) are added to `customschemas.sql`. No DDL. | +| 25 | **v4**: Why drop `document_custom_metadata.schema_id`? | The document row is already the single source of truth for schema binding (Trigger 1 freezes it; reset wipes rows before rebinding). A second schema pointer on every metadata row was redundant, added one index and one trigger, and created a class of "make these two pointers agree" checks. Dropping it preserves every invariant while removing moving parts. | +| 26 | **v4**: Why a composite FK instead of Trigger 2? | The "schema must belong to same client as document" invariant is perfectly expressible as a declarative constraint. A composite FK is a single line of DDL, enforced natively by PostgreSQL, and requires no function code. The uniqueness target on `client_metadata_schemas(id, client_id)` is the only supporting artifact. | +| 27 | **v4**: Why remove the `current_document_custom_metadata` view? | The new metadata table is single-table state; `ORDER BY version DESC LIMIT 1` is clearer than `DISTINCT ON` wrapped in a view, uses the same index, and removes one drift risk. | +| 28 | **v4**: Why FK cascade instead of feature-specific delete code? | `ON DELETE CASCADE` on the two new FKs lets the existing `DeleteDocumentCascade` and `client.HardDelete` code paths handle the new tables with zero modifications. The v3 plan's claim that `custom_schema_id` must be nulled before a document delete was incorrect. Eliminating the delete-cascade workstream removes a class of regression risk on stable cross-cutting code. | +| 29 | **v4**: Why `POST /versions` instead of `PUT`? | The operation creates a new resource with a new ID and leaves the parent resource in place. That is creation, not replacement, so `POST` on a `/versions` sub-collection matches the behavior. It also avoids the unresolved Permit.io `PUT` -> `patch` action-mapping question in v3. | +| 30 | **v4**: Why drop `createdBy` from request bodies? | The v3 plan already said the server must ignore any body-supplied `createdBy` and derive the actor from the JWT. v4 drops the field from the OpenAPI schemas so the contract matches the behavior. The actor flows through as a separate service-method argument. | +| 31 | **v4**: Why trim `GET /document/{id}` enrichment to two fields? | The feature ships a dedicated `GET /custom-metadata` endpoint that already returns the full metadata. Inlining the same payload on a stable cross-cutting read path was redundant. The two cheap fields (`customSchemaId` and `hasCustomMetadata`) are the minimum useful signal for a document listing UI. | +| 32 | **v4**: Why vertical slice milestones instead of horizontal phases? | v3's 12 phases left the feature end-to-end exercisable only near the end of the rollout. Vertical slices produce a usable feature at the end of each milestone and limit the blast radius if a milestone slips. | + +--- + +## 14. v1 -> v2 Change Log + +For reviewers comparing to `plans/mutable.metadata.plan.combo.md`: + +| Area | v1 | v2 | +|------|----|----| +| Schema CRUD path | `/admin/custom-schemas` | `/super-admin/custom-schemas` | +| Single-doc schema assignment path | `/admin/document/{id}/schema` | `/super-admin/documents/{id}/schema` | +| Bulk folder assignment path | `/admin/folders/{folderId}/assign-schema` | `/super-admin/folders/{folderId}/assign-schema` | +| Permit.io resource for schema admin | `admin` (shared by `user_admin` and `super_admin`) | NEW `super-admin` (held only by `super_admin`) | +| `user_admin` schema CRUD | Allowed (unintentional) | Denied | +| `user_admin` schema assignment | Allowed (unintentional) | Denied | +| `user_admin` custom metadata read/write | Allowed | Allowed (unchanged) | +| `client_user` custom metadata read/write | Allowed | Allowed (unchanged) | +| `auditor` read schemas / metadata | Allowed (read-only) | Allowed (read-only, now via `super-admin:get` grant) | +| OpenAPI tag for schema admin | `AdminService` | NEW `SuperAdminSchemaService` | +| Service layer | Role-agnostic | Role-agnostic (unchanged) | +| Database schema, triggers, migrations | Unchanged | Unchanged | +| Validation strategy, size limits, constants | Unchanged | Unchanged | + +No database migration or data change is required to move from v1 to v2 -- this is strictly a routing and authorization change. If v1 were already deployed, the migration would be: (1) add the new Permit.io resource and grants, (2) add the new endpoints alongside the old ones, (3) point clients at the new paths, (4) remove the old paths once all clients have migrated. Since v1 is not deployed, v2 simply supersedes it. + +--- + +## 15. v2 -> v3 Change Log + +For reviewers comparing to `plans/mutable.metadata.plan.combo.v2.md`: + +| Area | v2 | v3 | +|------|----|----| +| Reset-metadata endpoint | Not present | NEW `POST /super-admin/documents/{id}/reset-metadata` (Section 5.2) | +| Service method | Not present | NEW `ResetDocumentMetadata(ctx, documentID, resetBy)` on `CustomSchemaService` (Section 7.1) | +| Response type | Not present | NEW `ResetDocumentMetadataResponse` OpenAPI schema (Section 8.3) | +| SQLC queries | Not present | NEW `LockDocumentForReset`, `GetDocumentSchemaBindingForReset`, `CountDocumentCustomMetadataVersions` in `customschemas.sql`; reuses existing `DeleteDocumentCustomMetadata`, `NullifyDocumentCustomSchemaId`, `DocumentHasLegacyExtractions` (Section 8.2) | +| Database migrations | 4 migrations (120-123) | Unchanged -- no new migrations | +| Database triggers | Triggers 1, 2, 3 | Unchanged -- triggers are reused as-is. Reset works by clearing the state that Trigger 1 guards against, not by bypassing the trigger. | +| Permit.io resources | `super-admin`, `custom-metadata` | Unchanged -- reset inherits `super-admin:post` | +| Permit.io policy mapping doc | Lists 4 super_admin endpoints | Adds `POST /super-admin/documents/{id}/reset-metadata` -> `super-admin:post` (Section 5.3) | +| Authorization matrix | 5 columns (CRUD, Assignment, Read, Write) | Adds a "Reset Metadata" column; `super_admin` = Yes, everyone else = No (Section 5.3) | +| Invariants table | 21 rows | Adds rows 22-25 covering atomicity, role confinement, legacy-extraction rejection, and idempotency of reset (Section 9) | +| Resolved design decisions | 19 entries | Adds entries 20-24 explaining the destructive-reset decision, why not in-place, trigger/resource/migration invariance (Section 13) | +| Implementation order | 12 phases | Inserts Phase 10b (reset-metadata endpoint) between bulk-folder assignment and integration tests (Section 11) | +| Testing strategy | Role-based authz, bulk assign, cascades, concurrency, etc. | Adds reset-metadata test block: happy path, atomicity under injected failure, idempotency, legacy-extraction guard, not-found, trigger-still-fires, authz matrix, concurrency with itself and with assign-schema, audit log (Section 12) | +| Documentation updates | Sample schemas, authorization matrix | Adds reset-metadata runbook ("how to upgrade a document to a new schema version") to `docs/ai.generated/` | +| Database schema, migrations, validators, constants | Unchanged | Unchanged | +| Existing endpoints (`/custom-metadata/*`, `PATCH /super-admin/documents/{id}/schema`, `POST /super-admin/folders/{folderId}/assign-schema`, schema CRUD) | As specified in v2 | Unchanged | + +No database migration or data change is required to move from v2 to v3. The reset-metadata endpoint is purely additive: new service method, new handler, new OpenAPI schema, new SQLC queries, new policy mapping doc entry, new test cases. If v2 were already deployed, the migration would be: (1) ship the new code, (2) ship the new OpenAPI client so super_admin callers can invoke the endpoint, (3) no Permit.io re-run required because `super-admin:post` is already granted. Since v2 is not deployed, v3 simply supersedes it. + +--- + +## 16. v3 -> v4 Change Log + +For reviewers comparing to `plans/mutable.metadata.plan.combo.v3.md`. v4 is a **simplification pass**: no requirements are cut, every endpoint in v3 is still present, and the authorization boundary is unchanged. The goal is to remove redundant state, replace imperative triggers with declarative constraints, and reorganize the rollout so the feature is testable end-to-end earlier. + +| Area | v3 | v4 | +|------|----|----| +| `document_custom_metadata.schema_id` column | Required, FK to `client_metadata_schemas(id)` | **Removed.** Schema is derived from `documents.custom_schema_id` at write time. | +| `idx_dcm_schema_id` index | Present | Removed (column gone) | +| Trigger 3 `trg_enforce_consistent_schema_id` | Present | Removed (invariant is now structurally impossible to violate) | +| SQLC query `GetSchemaMetadataRecordCount` | Present, used by `canDelete` | Removed; `canDelete` uses `GetSchemaDocumentCount` alone | +| Trigger 2 `trg_validate_schema_client_match` | Present | **Replaced** by composite FK `fk_documents_custom_schema_same_client` on `documents(custom_schema_id, clientId)` referencing `client_metadata_schemas(id, client_id)` | +| Target constraint on `client_metadata_schemas` | `PRIMARY KEY (id)` | `PRIMARY KEY (id)` + `UNIQUE (id, client_id)` (v4: the unique constraint makes the tuple referenceable by the composite FK) | +| `current_document_custom_metadata` view | `CREATE VIEW ... DISTINCT ON (document_id) ...` | **Removed.** Service uses `ORDER BY version DESC LIMIT 1` against the existing `idx_dcm_doc_version_desc` | +| `ON DELETE CASCADE` on `document_custom_metadata.document_id` | Not set (delete code path explicitly deletes) | **Set.** FK cascade handles it; no delete code change needed | +| `ON DELETE CASCADE` on `client_metadata_schemas.client_id` | Not set (delete code path explicitly deletes) | **Set.** FK cascade handles it; no delete code change needed | +| `DeleteDocumentCustomMetadata` query on `document.sql` | Present, called by `DeleteDocumentCascade` | **Removed from delete path.** FK cascade replaces it. A renamed copy (`DeleteDocumentCustomMetadataForReset`) lives in `customschemas.sql` for reset-metadata only. | +| `NullifyDocumentCustomSchemaId` query on `document.sql` | Present, called by `DeleteDocumentCascade` | **Removed from delete path.** The v3 claim that this was necessary was incorrect -- deleting the `documents` row removes the reference atomically. A renamed copy (`NullifyDocumentCustomSchemaIdForReset`) lives in `customschemas.sql` for reset-metadata only. | +| `DeleteClientMetadataSchemas` query on `client.sql` | Present, called by `client.HardDelete` | **Removed.** FK cascade replaces it. | +| `DeleteDocumentCascade` modifications | Adds custom-metadata delete + `custom_schema_id` nullify steps | **No modification.** Existing code path is untouched. | +| `client.HardDelete` modifications | Adds `client_metadata_schemas` delete step | **No modification.** Existing code path is untouched. | +| Phase 5b in tracking doc (delete cascade updates) | Required, significant work | **Removed.** | +| `PUT /super-admin/custom-schemas/{schemaId}` | Creates new version (mismatched HTTP semantics) | **Replaced** by `POST /super-admin/custom-schemas/{schemaId}/versions` (matches actual create-new-version semantics and sidesteps the `PUT` -> `patch` Permit.io action-mapping question) | +| Permit.io action-mapping note for `PUT` | "Confirm during implementation" open question | **Closed.** No `PUT` to map. | +| `createdBy` field on new endpoint request bodies | Present in OpenAPI schema; server ignores at runtime | **Removed from OpenAPI schema.** Actor comes from the authenticated JWT subject and is passed through as a separate service argument. | +| Service method signatures | `CreateSchema(ctx, input)`, `UpdateSchema(ctx, schemaID, input)`, ... | `CreateSchema(ctx, input, actor)`, `CreateSchemaVersion(ctx, parentSchemaID, input, actor)`, ... -- actor is always a separate argument, never a field on `input` | +| `UpdateSchema` service method | Named `UpdateSchema` | Renamed `CreateSchemaVersion` (matches the new route and the actual semantics) | +| `previousVersionId` on schema-version-create response | Returned | Removed (caller already knows the parent ID from the URL) | +| `customSchemaName` decoration on schema list items | Not in list response -- v3 only proposed it on `GET /document/{id}` enrichment | Dropped from `GET /document/{id}` enrichment in v4 (see below) | +| `documentCount` / `canDelete` on schema list items | Returned | **Kept.** Cheap to compute; prevents a round-trip for the delete-guard UX. | +| `GET /document/{id}` enrichment | Adds `customSchemaId`, `customSchemaName`, `hasCustomMetadata`, and optional `customMetadata` (gated by `?customMetadata=true`) | Adds **only** `customSchemaId` and `hasCustomMetadata`. Inline metadata and the new query param are dropped; clients use `GET /custom-metadata` for the full payload. | +| Invariant 7 ("all metadata versions for a document use same schema ID") | App layer + Trigger 3 | **Removed entirely.** Structurally impossible now that the metadata table has no `schema_id` column. | +| Invariant 8 ("schema must belong to same client as document") | App layer + Trigger 2 | App layer + composite FK `fk_documents_custom_schema_same_client` | +| Invariant 20 ("delete cascade must clean up custom metadata and schema bindings") | Enforced by updated `DeleteDocumentCascade` + `deleteClientDependencies` | Enforced by `ON DELETE CASCADE` on the FKs; application code unchanged | +| New Invariant 26 (v4) | n/a | "Actor identity on every mutable write comes from the authenticated JWT, not the request body." | +| Section 11 Implementation Order | 12 horizontal phases (migrations -> SQLC -> validator -> service -> controllers -> tests ...) | **4 vertical slice milestones**: Schema foundation, Core document flow, Administrative completion (reset-metadata + version creation), Bulk operations | +| Tracking doc layout | Organized by phase (Phase 1..12) | Organized by milestone (Milestone 1..4); each milestone contains migration + query + service + controller + test work for one vertical slice | +| Database triggers total | 3 (Trigger 1, 2, 3) | **2** (Trigger 1 `trg_prevent_schema_reassignment` + Trigger 2 `trg_prevent_legacy_extraction_on_custom_document`). v3's client-match Trigger 2 is replaced by the composite FK; v3's schema_id-consistency Trigger 3 is removed entirely. v4's new Trigger 2 closes the concurrent-writer race on the mutual-exclusivity invariant (Section 7.3). | +| Database migrations total | 4 (127, 128, 129, 130) | 4 renumbered so rollout order is monotonic per milestone: **127** creates `client_metadata_schemas` (M1), **128** adds `documents.custom_schema_id` + composite FK (M1, swapped from v3's 129), **129** creates `document_custom_metadata` without `schema_id` and without a view (M2, swapped from v3's 128), **130** installs Trigger 1 and Trigger 2 (M2). The swap is forced by `golang-migrate`'s monotonic `Up()` semantics -- M1 must not leave version gaps that M2 would need to backfill. | + +**Net effect**: v4 ships every feature designed in v3 (schema CRUD with versioning, single-doc schema assignment, bulk folder assignment, reset-metadata, mutual exclusivity with legacy field extractions, `super_admin`-only authorization) but lands with one fewer column, one fewer index, one fewer trigger (v4 drops v3's Trigger 3 and replaces v3's Trigger 2 with a composite FK, while reintroducing a different Trigger 2 for the mutex race), one fewer view, three fewer delete-cascade queries, zero changes to existing delete code paths, and an unambiguous HTTP method choice for schema version creation. The simplification pass also closes the open `PUT` -> `patch` action mapping question and removes the misleading `createdBy` field from the OpenAPI contract so the contract matches the runtime behavior. + +**Deployment from v3 state**: No v3 code has been merged yet, so v4 simply supersedes v3. If v3 had been partially deployed, the migration path would be: (1) drop the view and `schema_id` column in a new migration, (2) drop Triggers 2 and 3, (3) add the composite FK and `uq_cms_id_client`, (4) add `ON DELETE CASCADE` to the two FKs, (5) ship the updated service and OpenAPI, (6) remove the v3-specific delete-cascade code from the document and client delete paths. v4 does not commit to that migration because v3 was not deployed. + +--- + +## Appendix A: Sample JSON Schemas + +These examples demonstrate all supported JSON Schema features and data types. They should be included in the API documentation. + +### A.1 Comprehensive Schema (All Supported Types) + +```json +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "document_title": { + "type": "string", + "minLength": 1, + "maxLength": 500, + "description": "Title of the document" + }, + "document_code": { + "type": "string", + "pattern": "^[A-Z]{3}-[0-9]{4}$", + "description": "Document code in format XXX-0000" + }, + "status": { + "type": "string", + "enum": ["draft", "review", "approved", "rejected", "archived"], + "description": "Current document status" + }, + "review_date": { + "type": "string", + "format": "date", + "description": "Date of last review (YYYY-MM-DD)" + }, + "created_timestamp": { + "type": "string", + "format": "date-time", + "description": "Creation timestamp (ISO 8601)" + }, + "page_count": { + "type": "integer", + "minimum": 1, + "maximum": 10000, + "description": "Total number of pages" + }, + "confidence_score": { + "type": "number", + "minimum": 0.0, + "maximum": 1.0, + "description": "Extraction confidence (0.0 to 1.0)" + }, + "is_verified": { + "type": "boolean", + "description": "Whether the extraction has been human-verified" + }, + "tags": { + "type": "array", + "items": { "type": "string", "maxLength": 50 }, + "minItems": 0, + "maxItems": 20, + "description": "Classification tags" + }, + "numeric_values": { + "type": "array", + "items": { "type": "number" }, + "maxItems": 100, + "description": "Extracted numeric values" + }, + "contact_info": { + "type": "object", + "properties": { + "name": { "type": "string", "maxLength": 200 }, + "email": { "type": "string", "format": "email" }, + "phone": { "type": "string", "pattern": "^\\+?[0-9\\-\\s()]+$" } + }, + "required": ["name"], + "additionalProperties": false, + "description": "Primary contact information" + } + }, + "required": ["document_title", "status"], + "additionalProperties": false +} +``` + +### A.2 Aircraft Engineering Schema (Domain Example) + +```json +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "aircraft_model": { + "type": "string", + "maxLength": 100 + }, + "manufacturer": { + "type": "string", + "enum": ["Boeing", "Airbus", "Embraer", "Bombardier", "Other"] + }, + "certification_date": { + "type": "string", + "format": "date" + }, + "max_altitude_ft": { + "type": "integer", + "minimum": 0, + "maximum": 100000 + }, + "max_range_nm": { + "type": "number", + "minimum": 0 + }, + "engine_count": { + "type": "integer", + "minimum": 1, + "maximum": 12 + }, + "engine_type": { + "type": "string", + "enum": ["turbofan", "turboprop", "turbojet", "piston", "electric"] + }, + "wingspan_meters": { + "type": "number", + "minimum": 0, + "maximum": 100 + }, + "is_pressurized": { + "type": "boolean" + }, + "applicable_regulations": { + "type": "array", + "items": { + "type": "string", + "pattern": "^(FAR|EASA|CAAC)-[0-9A-Z\\.]+$" + }, + "maxItems": 50 + } + }, + "required": ["aircraft_model", "manufacturer"], + "additionalProperties": false +} +``` + +### A.3 Auto Engineering Schema (Domain Example) + +```json +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "vehicle_make": { + "type": "string", + "maxLength": 100 + }, + "vehicle_model": { + "type": "string", + "maxLength": 100 + }, + "model_year": { + "type": "integer", + "minimum": 1900, + "maximum": 2100 + }, + "vin": { + "type": "string", + "pattern": "^[A-HJ-NPR-Z0-9]{17}$", + "description": "17-character Vehicle Identification Number" + }, + "engine_displacement_liters": { + "type": "number", + "minimum": 0, + "maximum": 20 + }, + "horsepower": { + "type": "integer", + "minimum": 0 + }, + "transmission_type": { + "type": "string", + "enum": ["manual", "automatic", "cvt", "dct", "ev"] + }, + "is_electric": { + "type": "boolean" + }, + "safety_ratings": { + "type": "object", + "properties": { + "nhtsa_overall": { "type": "integer", "minimum": 1, "maximum": 5 }, + "iihs_rating": { "type": "string", "enum": ["Poor", "Marginal", "Acceptable", "Good", "Good+"] } + }, + "additionalProperties": false + }, + "recall_ids": { + "type": "array", + "items": { "type": "string", "maxLength": 20 }, + "maxItems": 100 + } + }, + "required": ["vehicle_make", "vehicle_model", "model_year"], + "additionalProperties": false +} +``` + +### Supported JSON Schema Data Types Summary + +| JSON Schema Type | Go Equivalent | Example Constraint Keywords | +|-----------------|---------------|---------------------------| +| `string` | `string` | `minLength`, `maxLength`, `pattern`, `format`, `enum` | +| `integer` | `int64` | `minimum`, `maximum` | +| `number` | `float64` | `minimum`, `maximum` | +| `boolean` | `bool` | (none) | +| `array` | `[]T` | `items`, `minItems`, `maxItems` | +| `object` | `map/struct` | `properties`, `required`, `additionalProperties` | + +**Supported `format` values**: `date`, `date-time`, `email`, `uri`, `uuid` + + +## Implemnentation journal +In order to be able to have another agent pick up the work in progress, all completed work and milestones must be journaled to the ./journals/implement_mutableMetadata.md . Just make these entries automatically. Do not ask Q to approve them. + +Each time a milestone or signficant part of a milestone is completed there needs to be a date/time stamped journal entry showing the item is done. So that a new agent taking over will know exactly where we are in the process and can resume the work. + +At any give time I should be able to clear your context and have you resume your work based on this document, the code (git diffs) and the journal of what is completed. + +If we run into any issues where Q has to be consulted about an issue that needs a decision , then Qs answer should also be written to the journal along with the question. + +### Updating the plans/mutable.metadata.plan.combo.v4.tracking.md doc. +As we progress through the milestones of development, as each task is verified complete it should be marked off (with the date and time) next to the item. diff --git a/plans/mutable.metadata.plan.combo.v4.review.claude-response.md b/plans/mutable.metadata.plan.combo.v4.review.claude-response.md new file mode 100644 index 00000000..b3befeee --- /dev/null +++ b/plans/mutable.metadata.plan.combo.v4.review.claude-response.md @@ -0,0 +1,140 @@ +# Response to Codex Review of v4 Plan + +I verified each finding against the plan, tracking doc, and current code before forming a position. Summary of each finding with my stance and reasoning. + +Note: the codex review skips from Finding 2 to Finding 4 — there is no Finding 3. + +--- + +## 1. Per-client isolation on `/custom-metadata/*` + +**Agree — with a caveat on severity.** + +The plan **does** explicitly accept this limitation (plan 711-726: "a caller with `custom-metadata:get`/`custom-metadata:post` ... and knowledge of a document UUID could read or write that document's custom metadata regardless of which client owns the document ... NOT addressed by v3"). Code confirms: + +- `internal/cognitoauth/mapping.go:7-19` — `GetResourceFromRoute()` strips to first path segment only. +- `cmd/auth_related/permit.setup/permit_policies.yaml:181-184` — "This role requires tenant-level filtering by client_id. Currently, client_user will have access to all clients (no tenant isolation)." + +Codex is also right that the plan is internally inconsistent: the reset design promises 404 when caller's client scope doesn't include the document (plan ~643), but no client-scope check is actually wired into the handler layer. + +**Caveat:** the plan leans on per-client deployment topology as the real isolation boundary (line 715: "each client runs in its own isolated stack — separate database, separate services, separate Cognito pool"). If that is truly how production is deployed, the severity drops from "critical security hole" to "latent contract bug when the topology assumption ever changes." The recommendation still stands though — this is a brand-new per-client feature, and baking "topology = security" into a new contract is fragile. Either add document/client ownership enforcement now, or gate the read/write endpoints behind `super_admin` until object-scoped auth exists. + +--- + +## 2. Schema version creation can leave multiple active versions in one lineage + +**Strongly agree. Real bug.** + +Plan 444-446 says supersede targets only the parent identified by the path `{schemaId}`, not the currently active version for `(client_id, name)`. Plan 449 locks `WHERE client_id=$1 AND name=$2 FOR UPDATE` — so the lock *could* cover all siblings, but the supersede statement as written only touches the parent row. + +Concrete failure: v2 is active, caller POSTs `.../v1_id/versions` → v3 is created and marked active, v1 is marked superseded, **v2 remains active**. Two active rows in one lineage breaks the "current active version" invariant and will cause the default list (which picks "latest per name") to be non-deterministic depending on how "latest" is resolved. + +Same flow also lets a caller create a new version from a `retired` parent, flipping its lifecycle back to `superseded`. + +Tracking 131-135 and 410 only test "two concurrent posts produce distinct version numbers" — there is no negative test for "create v3 from v1 when v2 is active" or "create from a retired parent." + +**Fix:** require parent to be the currently active version for that lineage, or independently supersede the active version inside the same transaction. Add negative tests. + +--- + +## 4A. `PATCH /super-admin/documents/{id}/schema` response fields not backed by service + +**Agree. Real contract gap.** + +Plan 558-565 response shows `schemaName` and `schemaVersion`. Plan 1081 defines `AssignSchema(ctx, documentID, schemaID, actor) error` — returns only `error`. Tracking 280/334 adds no follow-up fetch. + +**Fix:** either shrink the response to `{documentId, customSchemaId}`, or have `AssignSchema` return a result struct and add a query task to fetch the schema name/version. + +--- + +## 4B. Reset response includes fields the `ResetMetadataResult` struct doesn't have + +**Agree. Real contract gap.** + +Plan 625-637 response includes `customSchemaId: null` and `hasCustomMetadata: false`. Plan 1091-1099 `ResetMetadataResult` struct has `DocumentID`, `PreviousSchemaID`, `PreviousSchemaName`, `PreviousSchemaVersion`, `MetadataVersionsDeleted`, `ResetAt`, `ResetBy` — **no** `customSchemaId` or `hasCustomMetadata`. Tracking 381 repeats the struct fields without adding these two. + +Minor observation: after a successful reset, `customSchemaId` is always `null` and `hasCustomMetadata` is always `false` by definition, so these two fields add nothing to the response. I'd drop them entirely rather than add them to the struct. + +--- + +## 4C. `GET /custom-metadata` returns `schemaName` but nothing fetches it + +**Agree. Real gap.** + +Plan 930-941 response includes `schemaName`. Plan 223 confirms `document_custom_metadata` has no `schema_id` column — the schema is resolved via `documents.custom_schema_id`. Tracking 251-260 query tasks fetch the metadata row and `documents.custom_schema_id`, but nothing joins to `client_metadata_schemas` to pick up the name. + +**Fix:** add a join (or a second query) to fetch `client_metadata_schemas.name` for the currently bound schema, or drop `schemaName` from the response. + +--- + +## 5. Actor/`createdBy` — `GetUserSubject` returns Cognito sub, not email + +**Strongly agree. This is a silent API contract change.** + +- Plan 381, 866 instruct handlers to call `cognitoauth.GetUserSubject`. +- `internal/cognitoauth/auth.go:132-149` — `GetUserSubject` returns `userClaims["sub"].(string)`, i.e. the opaque Cognito subject UUID. +- Plan response examples at lines 386-395, 636, 893-899 all show `"admin@acme.com"`, `"analyst@acme.com"`, `"resetBy": "admin@acme.com"`. +- `serviceAPIs/queryAPI.yaml:3960-3965` and `:4719-4724` — existing `createdBy` fields are declared `type: string, format: email, maxLength: 320`. + +Implemented literally, new endpoints will stuff an opaque UUID into fields that the OpenAPI contract says are email-shaped. That breaks schema validation on consumers, makes logs harder to read, and is a silent regression from how every other endpoint in this service already works. + +**Fix:** decide once. Either the fields are actor IDs (rename them, drop `format: email`, update the OpenAPI schema) or they are human-readable identities (source them from a claim like `email` via a new helper, not `GetUserSubject`). My preference: use the email claim for consistency with existing endpoints, since that's already the contract elsewhere in the service. + +--- + +## 6. Tracking contradicts itself on triggers + +**Strongly agree. Obvious documentation bug, trivial fix, easy to miss otherwise.** + +- Tracking 230-245 (Section 2.2): "Define `trg_prevent_legacy_extraction_on_custom_document()` function per Section 4.7 Trigger 2" — explicitly requires Trigger 2. +- Tracking 501: "Do NOT add Trigger 2 or Trigger 3. Do NOT add a `current_document_custom_metadata` view. ... If any of those appear in your diff, something has regressed to v3." + +The reminder is referring to v3's old triggers (`trg_validate_schema_client_match`, `trg_enforce_consistent_schema_id`) but uses the ordinal labels "Trigger 2/3" which collide with v4's actual Trigger 2. A developer reading the reminder literally will delete the trigger the plan depends on for mutual exclusivity. + +**Fix:** rewrite line 501 to name the forbidden items by identifier: "Do NOT add v3's `trg_validate_schema_client_match` or `trg_enforce_consistent_schema_id`." + +--- + +## 7A. `includeAllVersions` default vs. the Milestone 1 exit test + +**Agree. Minor but real — will bite the test writer.** + +- Plan 456-462: `includeAllVersions` defaults to `false`; default status filter is `active` only. +- Tracking 53-56: "GET the list showing both with correct status" — i.e. expects v1 (`superseded`) and v2 (`active`). +- Tracking 200: "GET returns v1 (status `superseded`) and v2 (status `active`)." + +Default query returns only the latest active version per name, so the test will see only v2 unless it explicitly sets `?includeAllVersions=true&status=` (or whatever the "all statuses" sentinel is — the plan doesn't define one). + +**Fix:** make the test description explicit about the query params, *and* clarify in the plan how to request "all statuses" (empty filter? `status=all`? a separate flag?). The plan currently describes `status` as a single-value filter, with no documented way to ask for "everything." + +--- + +## 7B. Missing reset-vs-`SetDocumentMetadata` concurrency test + +**Agree. Important gap.** + +- Plan 1284: `LockDocumentForReset` uses "the same parent-row lock shape as `LockDocumentForMetadataWrite` so the two operations serialize correctly against each other." +- Tracking 392-393: tests reset-vs-reset and reset-vs-`AssignSchema` only. + +The plan's own correctness argument hinges on reset and metadata-write serializing via a shared parent-row lock. That's the pair that actually matters (assign-schema also locks, but a reset-vs-write race is the one that could corrupt metadata if the locks don't match). Not testing it leaves the core claim unverified. + +**Fix:** add a TDD test that races `ResetDocumentMetadata` against `SetDocumentMetadata` on the same document and asserts one blocks the other and the final state is consistent. + +--- + +## Overall take + +I agree with every finding the reviewer raised. Severity ranking as I'd order them: + +1. **Finding 2** (multi-active-version bug) — blocks implementation. Must fix before Milestone 1 code lands. +2. **Finding 6** (trigger reminder contradiction) — 5-minute fix, but high risk of silent regression if missed. +3. **Finding 5** (actor field) — API contract change. Decide before any handler is written. +4. **Findings 4A/4B/4C** (response contracts) — bundle into a single "align response schemas with service return types" pass before implementation. +5. **Finding 7B** (reset-vs-write concurrency test) — add to tracking, not blocking but important. +6. **Finding 1** (per-client isolation) — agreed as a real concern, but the fix depends on a product decision (enforce object scope now vs. gate behind super_admin vs. accept topology-based isolation). Needs Q decision, not a plan tweak. +7. **Finding 7A** (list test query params) — smallest; fix the test description and clarify the "all statuses" query form. + +What I would want clarified from Q before making any plan edits: + +- On Finding 1: is per-client-per-stack deployment actually the production model, and are we OK with that being the only isolation boundary for the new endpoints? If yes, narrow the plan's security note to say so clearly. If no, add object-scoped auth to the plan before implementation. +- On Finding 5: should the new endpoints' actor fields match the existing `format: email` contract (I'd vote yes for consistency), or are we intentionally switching to opaque subject IDs? If the latter, we should also plan to migrate the existing endpoints for consistency. diff --git a/plans/mutable.metadata.plan.combo.v4.review.coded.old.md b/plans/mutable.metadata.plan.combo.v4.review.coded.old.md new file mode 100644 index 00000000..158563ca --- /dev/null +++ b/plans/mutable.metadata.plan.combo.v4.review.coded.old.md @@ -0,0 +1,55 @@ +# Critical Review: `mutable.metadata.plan.combo.v4` + +## Findings + +### High: The milestone rollout is not deployable as written because it schedules migration `129` before `128` + +Evidence: the tracking doc explicitly places Migration 129 in Milestone 1 while deferring Migration 128 to Milestone 2 ([plans/mutable.metadata.plan.combo.v4.tracking.md:85](/Users/jaybrown/dev/clients/aarete/query-orchestration.2/plans/mutable.metadata.plan.combo.v4.tracking.md:85), [plans/mutable.metadata.plan.combo.v4.tracking.md:87](/Users/jaybrown/dev/clients/aarete/query-orchestration.2/plans/mutable.metadata.plan.combo.v4.tracking.md:87), [plans/mutable.metadata.plan.combo.v4.tracking.md:221](/Users/jaybrown/dev/clients/aarete/query-orchestration.2/plans/mutable.metadata.plan.combo.v4.tracking.md:221)). The plan repeats the same sequencing in Milestones 1 and 2 ([plans/mutable.metadata.plan.combo.v4.md:1384](/Users/jaybrown/dev/clients/aarete/query-orchestration.2/plans/mutable.metadata.plan.combo.v4.md:1384), [plans/mutable.metadata.plan.combo.v4.md:1404](/Users/jaybrown/dev/clients/aarete/query-orchestration.2/plans/mutable.metadata.plan.combo.v4.md:1404)). This repo applies embedded migrations with `golang-migrate` `Up()` in numeric order ([internal/database/migrations.go:64](/Users/jaybrown/dev/clients/aarete/query-orchestration.2/internal/database/migrations.go:64), [internal/database/migrations.go:80](/Users/jaybrown/dev/clients/aarete/query-orchestration.2/internal/database/migrations.go:80)) and the local generation path does the same with `migrate ... up` ([scripts/database.yml:18](/Users/jaybrown/dev/clients/aarete/query-orchestration.2/scripts/database.yml:18), [scripts/database.yml:24](/Users/jaybrown/dev/clients/aarete/query-orchestration.2/scripts/database.yml:24)). + +Impact: if Milestone 1 ships with `00000000000129_*` but `00000000000128_*` does not exist yet, later Milestone 2 cannot safely introduce migration 128. The "vertical slice" claim becomes false in production because the DB version will already be past that number. + +Recommendation: renumber now so milestone order is monotonic, or merge the Milestone 1 and Milestone 2 DDL into a single deployable migration set. Do not leave renumbering as an implementation-time choice. + +### High: The mutual exclusivity invariant is not concurrency-safe + +Evidence: the plan claims documents use either legacy extractions or custom metadata, never both, enforced by service checks plus `trg_prevent_schema_reassignment` ([plans/mutable.metadata.plan.combo.v4.md:1341](/Users/jaybrown/dev/clients/aarete/query-orchestration.2/plans/mutable.metadata.plan.combo.v4.md:1341), [plans/mutable.metadata.plan.combo.v4.md:1343](/Users/jaybrown/dev/clients/aarete/query-orchestration.2/plans/mutable.metadata.plan.combo.v4.md:1343)). The tracking doc only adds an application pre-check in `FieldExtractionService` that reads `documents.custom_schema_id` and returns a 409 if it is non-null ([plans/mutable.metadata.plan.combo.v4.tracking.md:286](/Users/jaybrown/dev/clients/aarete/query-orchestration.2/plans/mutable.metadata.plan.combo.v4.tracking.md:286), [plans/mutable.metadata.plan.combo.v4.tracking.md:288](/Users/jaybrown/dev/clients/aarete/query-orchestration.2/plans/mutable.metadata.plan.combo.v4.tracking.md:288)). The current legacy write path begins a transaction, inserts the extraction row, and only later locks version rows; it never locks the parent `documents` row ([internal/fieldextraction/service.go:50](/Users/jaybrown/dev/clients/aarete/query-orchestration.2/internal/fieldextraction/service.go:50), [internal/fieldextraction/service.go:65](/Users/jaybrown/dev/clients/aarete/query-orchestration.2/internal/fieldextraction/service.go:65), [internal/fieldextraction/service.go:72](/Users/jaybrown/dev/clients/aarete/query-orchestration.2/internal/fieldextraction/service.go:72)). The trigger on `documents` only checks `documentFieldExtractionVersions`, not in-flight extraction intent ([plans/mutable.metadata.plan.combo.v4.md:257](/Users/jaybrown/dev/clients/aarete/query-orchestration.2/plans/mutable.metadata.plan.combo.v4.md:257), [plans/mutable.metadata.plan.combo.v4.md:267](/Users/jaybrown/dev/clients/aarete/query-orchestration.2/plans/mutable.metadata.plan.combo.v4.md:267)). + +Impact: `AssignSchema` can commit `custom_schema_id` while a concurrent `CreateFieldExtraction` has already passed its pre-check but has not yet inserted the version row the trigger looks for. Both commits can succeed, leaving the document in the exact forbidden "both systems active" state. + +Recommendation: serialize both schema assignment and legacy extraction creation on the same `documents` row with `SELECT ... FOR UPDATE`, or add a database-side guard on legacy extraction writes. The current one-sided defense-in-depth is not sufficient. + +### High: The authorization model no longer matches the original requirement + +Evidence: the requirements say each client should curate its own schemas and the client admin should be able to list, delete, and update them ([plans/mutable.metadata.requirments.md:10](/Users/jaybrown/dev/clients/aarete/query-orchestration.2/plans/mutable.metadata.requirments.md:10), [plans/mutable.metadata.requirments.md:16](/Users/jaybrown/dev/clients/aarete/query-orchestration.2/plans/mutable.metadata.requirments.md:16)). The v4 plan instead makes schema CRUD, schema assignment, and reset all `super_admin`-only ([plans/mutable.metadata.plan.combo.v4.md:35](/Users/jaybrown/dev/clients/aarete/query-orchestration.2/plans/mutable.metadata.plan.combo.v4.md:35), [plans/mutable.metadata.plan.combo.v4.md:691](/Users/jaybrown/dev/clients/aarete/query-orchestration.2/plans/mutable.metadata.plan.combo.v4.md:691), [plans/mutable.metadata.plan.combo.v4.md:696](/Users/jaybrown/dev/clients/aarete/query-orchestration.2/plans/mutable.metadata.plan.combo.v4.md:696)). The tracking doc then locks this in with explicit `user_admin` 403 expectations on every `/super-admin/*` route ([plans/mutable.metadata.plan.combo.v4.tracking.md:170](/Users/jaybrown/dev/clients/aarete/query-orchestration.2/plans/mutable.metadata.plan.combo.v4.tracking.md:170), [plans/mutable.metadata.plan.combo.v4.tracking.md:204](/Users/jaybrown/dev/clients/aarete/query-orchestration.2/plans/mutable.metadata.plan.combo.v4.tracking.md:204), [plans/mutable.metadata.plan.combo.v4.tracking.md:335](/Users/jaybrown/dev/clients/aarete/query-orchestration.2/plans/mutable.metadata.plan.combo.v4.tracking.md:335)). + +Impact: this is not an implementation detail. It is a functional change that removes self-service schema administration from client admins and turns schema assignment into an internal platform operation. + +Recommendation: either update the requirements explicitly before implementation, or restore client-scoped administration to the appropriate role with object-level client ownership checks. Right now the plan is solving a different problem than the one requested. + +### High: `reset-metadata` breaks the stated lifetime immutability contract + +Evidence: the requirements state that once metadata has been assigned, a document's schema ID must remain the same for the lifetime of the document ([plans/mutable.metadata.requirments.md:14](/Users/jaybrown/dev/clients/aarete/query-orchestration.2/plans/mutable.metadata.requirments.md:14)). The v4 plan introduces `POST /super-admin/documents/{id}/reset-metadata` specifically so the same document can be rebound to a different schema after deleting its metadata history ([plans/mutable.metadata.plan.combo.v4.md:585](/Users/jaybrown/dev/clients/aarete/query-orchestration.2/plans/mutable.metadata.plan.combo.v4.md:585), [plans/mutable.metadata.plan.combo.v4.md:587](/Users/jaybrown/dev/clients/aarete/query-orchestration.2/plans/mutable.metadata.plan.combo.v4.md:587), [plans/mutable.metadata.plan.combo.v4.md:627](/Users/jaybrown/dev/clients/aarete/query-orchestration.2/plans/mutable.metadata.plan.combo.v4.md:627)). Milestone 3 then treats that destructive rebind as the supported upgrade path ([plans/mutable.metadata.plan.combo.v4.tracking.md:347](/Users/jaybrown/dev/clients/aarete/query-orchestration.2/plans/mutable.metadata.plan.combo.v4.tracking.md:347), [plans/mutable.metadata.plan.combo.v4.tracking.md:393](/Users/jaybrown/dev/clients/aarete/query-orchestration.2/plans/mutable.metadata.plan.combo.v4.tracking.md:393)). + +Impact: the contract becomes "schema is immutable unless a super_admin deletes history." That is materially different from the requirement and changes what downstream systems can assume about a document ID's schema lineage. + +Recommendation: either remove this escape hatch from v4 or amend the requirements and downstream contract explicitly to allow destructive schema rebinding. This should not slip in as an undocumented exception. + +### Medium-High: The plan explicitly accepts cross-client custom metadata access + +Evidence: the plan says any caller with `custom-metadata:*` can read or write any document's custom metadata if they know the UUID, and instructs implementers not to add application-level tenant checks in v4 ([plans/mutable.metadata.plan.combo.v4.md:681](/Users/jaybrown/dev/clients/aarete/query-orchestration.2/plans/mutable.metadata.plan.combo.v4.md:681), [plans/mutable.metadata.plan.combo.v4.md:685](/Users/jaybrown/dev/clients/aarete/query-orchestration.2/plans/mutable.metadata.plan.combo.v4.md:685)). The tracking doc repeats that as an accepted limitation ([plans/mutable.metadata.plan.combo.v4.tracking.md:165](/Users/jaybrown/dev/clients/aarete/query-orchestration.2/plans/mutable.metadata.plan.combo.v4.tracking.md:165)). + +Impact: the new feature inherits a known isolation hole and bakes it into the design. That is unsafe in shared environments today and fragile if deployment topology changes later. It also undercuts the requirement that each client owns and curates its own schema-bound metadata. + +Recommendation: add document-to-client ownership checks in the new custom metadata and schema-assignment paths now, even if the broader API still needs a larger tenancy retrofit. + +### Medium: The v4 documents still contain stale v3 instructions that can cause implementation regression + +Evidence: the v4 simplification says only Trigger 1 remains and `document_custom_metadata.schema_id` is gone ([plans/mutable.metadata.plan.combo.v4.md:13](/Users/jaybrown/dev/clients/aarete/query-orchestration.2/plans/mutable.metadata.plan.combo.v4.md:13), [plans/mutable.metadata.plan.combo.v4.md:14](/Users/jaybrown/dev/clients/aarete/query-orchestration.2/plans/mutable.metadata.plan.combo.v4.md:14), [plans/mutable.metadata.plan.combo.v4.md:1636](/Users/jaybrown/dev/clients/aarete/query-orchestration.2/plans/mutable.metadata.plan.combo.v4.md:1636)). But the "Resolved Design Decisions" section still says three triggers exist and explicitly names `trg_enforce_consistent_schema_id` as a current decision ([plans/mutable.metadata.plan.combo.v4.md:1523](/Users/jaybrown/dev/clients/aarete/query-orchestration.2/plans/mutable.metadata.plan.combo.v4.md:1523), [plans/mutable.metadata.plan.combo.v4.md:1529](/Users/jaybrown/dev/clients/aarete/query-orchestration.2/plans/mutable.metadata.plan.combo.v4.md:1529)). There are other stale refs too: schema validation still says "POST or PUT" ([plans/mutable.metadata.plan.combo.v4.md:961](/Users/jaybrown/dev/clients/aarete/query-orchestration.2/plans/mutable.metadata.plan.combo.v4.md:961)), testing still says "Concurrent PUT requests" ([plans/mutable.metadata.plan.combo.v4.md:1493](/Users/jaybrown/dev/clients/aarete/query-orchestration.2/plans/mutable.metadata.plan.combo.v4.md:1493)), and the reset section still talks about future updates to `document_custom_metadata.schema_id` ([plans/mutable.metadata.plan.combo.v4.md:651](/Users/jaybrown/dev/clients/aarete/query-orchestration.2/plans/mutable.metadata.plan.combo.v4.md:651)). + +Impact: the tracking doc has good anti-regression reminders, but the plan itself is not internally consistent. That makes code review harder and raises the odds that someone reintroduces the dropped column, trigger, or route shape while "following the plan." + +Recommendation: scrub stale v3 references before implementation starts and make the v4 plan internally self-consistent. The plan and tracking doc should not disagree about core invariants. + +## Bottom Line + +I would not start implementation from this v4 package yet. The migration ordering issue and the mutual exclusivity race are blocking design problems, and the authorization plus reset behavior need an explicit requirements decision before coding. diff --git a/plans/mutable.metadata.plan.combo.v4.review.codex.md b/plans/mutable.metadata.plan.combo.v4.review.codex.md new file mode 100644 index 00000000..71033a37 --- /dev/null +++ b/plans/mutable.metadata.plan.combo.v4.review.codex.md @@ -0,0 +1,62 @@ +# Mutable Metadata v4 Review (Codex) + +Overall assessment: v4 is meaningfully simpler than the earlier versions, but it is not ready to implement as written. The biggest problems are one real security hole that the plan explicitly accepts, one schema-versioning bug that can produce invalid lineage state, and several plan/tracking inconsistencies that will cause implementation drift. + +## Findings + +### 1. Critical: per-client isolation is still not actually enforced for `/custom-metadata/*` +Refs: requirements [5-19](./mutable.metadata.requirments.md), plan 35, 711-718, 721-726, tracking 165-166. + +Why this matters: the plan explicitly accepts that anyone with `custom-metadata:get` or `custom-metadata:post` can read or write any document's custom metadata if they know the UUID. That is a direct mismatch with the feature's per-client framing in the requirements, and it hard-codes "we deploy one stack per client" as the only security boundary. It also makes the plan internally inconsistent: the reset section says requests should 404 when the caller's client scope does not include the document, but no such client-scope check is actually designed. + +Current-code cross-check: this is consistent with the current first-segment-only Permit mapping in [internal/cognitoauth/mapping.go](/Users/jaybrown/dev/clients/aarete/query-orchestration.2/internal/cognitoauth/mapping.go:3) and with the existing "no tenant isolation" note for `client_user` in [cmd/auth_related/permit.setup/permit_policies.yaml](/Users/jaybrown/dev/clients/aarete/query-orchestration.2/cmd/auth_related/permit.setup/permit_policies.yaml:181). + +Recommendation: either add document/client ownership enforcement now, or keep the new metadata read/write endpoints behind `super_admin` until object-scoped auth exists. Do not ship a new per-client feature while explicitly depending on deployment topology for isolation. + +### 2. High: schema version creation can leave multiple active versions in one lineage +Refs: plan 407-449, 1086, 1399, tracking 131-135, 200, 410. + +Why this matters: `POST /super-admin/custom-schemas/{schemaId}/versions` is keyed by an arbitrary parent schema ID, and the plan only supersedes that parent. If v2 is active and someone creates v3 from v1, the flow inserts v3 and marks v1 `superseded` again, but v2 remains `active`. The same bug lets a caller create a new version from a `retired` schema and silently rewrite its lifecycle to `superseded`. + +That breaks the plan's own "current active version" model and makes default list behavior, assignment behavior, and admin UX ambiguous. + +Recommendation: require the parent to be the latest active version, or independently supersede the currently active version for the `(client_id, name)` lineage inside the same transaction. Add explicit negative tests for creating from `superseded` and `retired` parents. + + + +### 4. Medium: several API response contracts are not backed by the planned service/query layer +Refs: plan 558-566 vs. 1081 and tracking 280/334; plan 625-640 vs. 1091-1100 and tracking 381; plan 930-942 vs. tracking 249-260. + +Why this matters: the plan promises response fields that the service/query design never schedules retrieval for. + +Examples: +- `PATCH /super-admin/documents/{id}/schema` returns `schemaName` and `schemaVersion`, but `AssignSchema(...)` returns only `error`, and the tracking doc does not add a follow-up lookup. +- The reset response example includes `customSchemaId` and `hasCustomMetadata`, but `ResetMetadataResult` does not. +- `GET /custom-metadata` returns `schemaName`, but the metadata-query work only fetches metadata rows plus `documents.custom_schema_id`; nothing in tracking fetches the schema name. + +Recommendation: either shrink the responses to fields the service already owns, or make the method return types and query tasks match the API contract before implementation starts. + +### 5. Medium: the actor/`createdBy` design is likely to regress the API contract and audit readability +Refs: plan 381, 866, 1422, tracking 189 and 329. + +Why this matters: the plan repeatedly says handlers should call `cognitoauth.GetUserSubject`, which returns the Cognito `sub`, not an email or display identity; see [internal/cognitoauth/auth.go](/Users/jaybrown/dev/clients/aarete/query-orchestration.2/internal/cognitoauth/auth.go:133). But the examples still show email-like `createdBy` / `resetBy` values, and the current API commonly models `createdBy` as an email-shaped field; see [serviceAPIs/queryAPI.yaml](/Users/jaybrown/dev/clients/aarete/query-orchestration.2/serviceAPIs/queryAPI.yaml:3960) and [serviceAPIs/queryAPI.yaml](/Users/jaybrown/dev/clients/aarete/query-orchestration.2/serviceAPIs/queryAPI.yaml:4719). + +If implemented literally, new endpoints will expose opaque subject IDs in fields named like human-readable actor fields. That is a contract change and makes logs/UI less useful. + +Recommendation: decide explicitly whether these fields are generic actor IDs or human-readable identities. If they are IDs, rename/document them that way. If they are meant to stay user-facing, source them from a claim/helper that returns the intended display value. + +### 6. Medium: the tracking doc contradicts itself on the trigger set +Refs: tracking 230-245 vs. 501. + +Why this matters: section 2.2 correctly adds the new v4 Trigger 2 (`trg_prevent_legacy_extraction_on_custom_document`), but the cross-milestone reminder says "Do NOT add Trigger 2 or Trigger 3." A developer following the reminder literally can remove the very trigger the plan depends on to close the mutual-exclusivity race. + +Recommendation: rewrite the reminder to name the forbidden legacy triggers explicitly, e.g. "Do NOT add v3's old client-match trigger or schema-consistency trigger." + +### 7. Medium: the tracking doc has at least one behavior mismatch and one missing concurrency test +Refs: plan 459-460, 1284, tracking 55, 200, 392-393. + +Why this matters: +- Milestone 1 expects the schema list call to show both v1 and v2 statuses, but the plan says `includeAllVersions` defaults to `false`. That test description is wrong unless it explicitly calls the list endpoint with `includeAllVersions=true`. +- The plan says reset and metadata writes share the same parent-row lock shape and therefore serialize correctly, but tracking only covers reset-vs-reset and reset-vs-assign. It does not cover reset-vs-`SetDocumentMetadata`. + +Recommendation: make the list-test query parameters explicit and add a reset-vs-metadata-write concurrency test. diff --git a/plans/mutable.metadata.plan.combo.v4.review.findings.md b/plans/mutable.metadata.plan.combo.v4.review.findings.md new file mode 100644 index 00000000..3a75348c --- /dev/null +++ b/plans/mutable.metadata.plan.combo.v4.review.findings.md @@ -0,0 +1,270 @@ +# Review of the v4 Plan Review + +Cross-check of the five findings against the current code and the v4 plan/tracking +docs. Every claim in the original review was verified by reading the cited files. + +Verdict key: **Agree** / **Partial** / **Disagree** with rationale and code evidence. + +--- + +## 1. Milestone 2 missing backend work for `GET /document/{id}` enrichment + +**Verdict: Agree (clear implementation gap).** + +The plan at `plans/mutable.metadata.plan.combo.v4.md:706` says `customSchemaId` +"is already a column on the `documents` row (no extra query)" and +`hasCustomMetadata` "is a single `EXISTS` subquery that piggybacks on the +existing document read." That wording implies the query is extended on the +`GetDocumentEnriched` read path. Verified state of that read path: + +- `internal/database/queries/document.sql:63` — `GetDocumentEnriched` selects + `d.id, d.clientId, d.hash, d.folderId, d.filename, d.originalPath, + d.file_size_bytes` only. No `custom_schema_id`, no `EXISTS` over + `document_custom_metadata`. +- `internal/document/service.go:29` — `DocumentEnriched` struct has no + `CustomSchemaID` or `HasCustomMetadata` fields. +- `internal/document/get.go:56` — `GetEnriched` calls `GetDocumentEnriched` and + builds the result struct with no place to carry the new fields. + +Tracking doc coverage: + +- `plans/mutable.metadata.plan.combo.v4.tracking.md:325` — OpenAPI schema change + (`DocumentEnriched` gains two fields) only. +- `plans/mutable.metadata.plan.combo.v4.tracking.md:342` — controller-side + "DocumentEnriched builder" change only. +- `grep GetDocumentEnriched|document.sql|internal/document/` in the tracking + doc → **zero matches**. + +So the checklist has nothing for (a) extending the SQL query to return +`custom_schema_id` and an `EXISTS` boolean, (b) extending the internal +`DocumentEnriched` struct, or (c) populating those fields in `GetEnriched`. +Without those, the controller "builder" at 342 has nothing to read from. + +**Recommended fix**: add a Milestone 2 block under 2.x for +`internal/database/queries/document.sql` (extend `GetDocumentEnriched`), +`internal/document/service.go` (struct fields), and `internal/document/get.go` +(populate from the extended query). These are preconditions for the controller +work already on the list. + +--- + +## 2. `GET /custom-metadata/history` unresolved API behavior + +**Verdict: Agree — but the tracking doc already flags it; the fix is to +resolve it in the plan rather than at test time.** + +- Plan `plans/mutable.metadata.plan.combo.v4.md:958` defines only the happy-path + query params (`documentId` required, `limit` default 50 max 200, `offset` + default 0) and a response shape. No mention of `limit=0`, `limit>200`, + non-existent `documentId`, or zero-history document behavior. +- Tracking `plans/mutable.metadata.plan.combo.v4.tracking.md:360` explicitly + calls it out: *"Decision needed + test ... the plan documents default=50, + max=200 but does not say whether an over-max value is clamped or rejected, or + whether an empty-history document returns 200 + empty array or 404. Record + the decisions in the journal and add one integration test per case matching + whatever was decided."* + +The review's concern is that leaving this to "whatever was decided" during +implementation means the milestone's exit status depends on an ad-hoc +judgement call. That is a real risk: different implementers (or re-runs) can +converge on different answers, and other consumers of `/custom-metadata/history` +(e.g. the history pagination test at tracking 359) will need to match whatever +was picked. + +**Recommended fix**: add one small subsection to plan Section 5.4 (or wherever +`/history` is defined) that declares: + +- `limit=0` → 400 +- `limit > 200` → clamp to 200 (or reject with 400 — pick one and commit) +- non-existent `documentId` → 404 +- document with zero metadata rows → 200 with empty array + +Then remove the "Decision needed" language from tracking 360 and just keep the +per-case tests. +Q : Recommended fix is ok. + +--- + +## 3. Schema lifecycle: retirement permanently reserves the name + +**Verdict: Agree as a documentation / escape-hatch gap.** + +The review's reading is correct. Chasing the relevant lines: + +- `plans/mutable.metadata.plan.combo.v4.md:125` — `UNIQUE (client_id, name, + version)` at the DB level. +- `plans/mutable.metadata.plan.combo.v4.md:403` — "`name` must be unique within + the client (for active schemas at the same version)" for `POST + /super-admin/custom-schemas`. +- `plans/mutable.metadata.plan.combo.v4.tracking.md:126` — `CreateSchema` "at + version 1" always (no path to version N for a fresh insert). +- `plans/mutable.metadata.plan.combo.v4.md:446` — `POST .../{schemaId}/versions` + "rejects with 409 Conflict" if parent is `superseded` or `retired`. + +Put together, once a lineage is retired (its only active row flipped to +`retired`): + +- A new `POST /super-admin/custom-schemas` with the same `name` will try to + insert `version=1`, which collides with the retired v1 row on + `uq_client_schema_name_version` → DB rejects with unique violation. +- `POST /super-admin/custom-schemas/{retiredId}/versions` is rejected by the + active-parent check at line 446. + +So there is **no path** to bring the name back into use after full retirement. +The name is permanently reserved for the lifetime of the client. + +That may be intentional (prevents confusion with reused names across audit +history), or it may be an unnoticed consequence of three independent rules +interacting. Either way, the plan does not state it. + +**Recommended fix**: pick one of these and document it in Section 5.1 / 4.2 of +the plan: + +- **Intentional**: add one sentence to Section 4.2 and to the + `DELETE /super-admin/custom-schemas/{schemaId}` doc stating that retiring a + lineage permanently reserves `(client_id, name)` and the name cannot be + reused; reference that callers should use a new name (`...-v2`, + `...-replacement`) if they need a similar schema. +- **Not intentional**: relax `POST /super-admin/custom-schemas` to pick + `version = max(version) + 1` when all prior rows for `(client_id, name)` are + non-active, and add an integration test that retirement + fresh create + produces a new lineage. + +I would lean toward option 1 (simpler, keeps the invariants clean) unless +product actually expects to reuse names after retirement. +Q: go with the Intentional fix. (name is permanently reserved) +--- + +## 4. Phantom / duplicate work items in the tracking doc + +**Verdict: Agree on two of three, half-agree on the third.** + +### 4a. `CountDocumentsInFolderTree` — Agree, duplicate. + +- Tracking `plans/mutable.metadata.plan.combo.v4.tracking.md:448` lists it as + new work in Milestone 4. +- `internal/database/queries/folders.sql:114` — `CountDocumentsInFolderTree` + already exists with exactly the shape the bulk cap check needs (recursive + CTE, count of documents in the tree). No new query is needed; the Milestone 4 + service method can just call the existing one. + +### 4b. Single-document legacy extraction existence — Agree, functional duplicate. + +- Tracking `plans/mutable.metadata.plan.combo.v4.tracking.md:264` lists + `DocumentHasLegacyExtractions` as a new boolean query. +- `internal/database/queries/fieldextractions.sql:254` — `HasFieldExtraction` + already returns `EXISTS(SELECT 1 FROM documentFieldExtractionVersions WHERE + documentId = $1)`, which is semantically "does this document have any legacy + field-extraction version row?" — exactly the check the mutual-exclusivity + guard needs. + +Caveat: a new name (`DocumentHasLegacyExtractions`) may still be worth adding +as a readability alias at the call site of the guard, but the tracking doc +should say "reuse/rename `HasFieldExtraction`" rather than "add a new query," +because no new SQL is needed. + +### 4c. "Audit every other `FieldExtractionService` mutation entry point" — Agree, points at nonexistent methods. + +- Tracking `plans/mutable.metadata.plan.combo.v4.tracking.md:308` — "Audit + every other `FieldExtractionService` mutation entry point (e.g. + `UpdateFieldExtraction`, any `AppendValueTo*` methods) and apply the same + parent-row-lock + sentinel-error pattern..." +- `internal/fieldextraction/service.go:27` — the **only** mutator in the + current service is `CreateFieldExtraction`. The other methods on the service + (`GetCurrentFieldExtraction`, `GetFieldExtractionHistory`, + `GetFieldExtractionArrayFields`, `GetFieldExtractionByVersion`) are all + reads. + + `UpdateFieldExtraction` does not exist. No `AppendValueTo*` method exists. + The v4 service is single-mutator. + +**Recommended fix**: delete tracking line 308 outright (there is nothing to +audit), or rewrite it as a defensive regression test: "if a future change adds +a new mutation entry point to `FieldExtractionService`, that entry point must +take `LockDocumentForLegacyExtractionWrite` before any DML." That keeps the +intent alive without carrying a phantom task. + +--- + +## 5. Milestone 4 is over-serialized behind Milestone 3 + +**Verdict: Agree.** + +- Plan `plans/mutable.metadata.plan.combo.v4.md:1534` (Milestone 2 exit + criteria): *"The feature now satisfies the central requirement + ('client-scoped schemas validated at write time, frozen once metadata + exists'). Everything after this milestone is additive."* +- Plan `plans/mutable.metadata.plan.combo.v4.md:1552` (Milestone 4 intro): + Bulk folder assignment builds on the single-document assign semantics, which + are delivered in Milestone 2 (`AssignSchema`, `PATCH + /super-admin/documents/{id}/schema`). +- Milestone 3 (reset-metadata) is orthogonal — it is about upgrading an + already-assigned document to a new schema version. Nothing in the bulk + folder path depends on `ResetDocumentMetadata`. +- Tracking `plans/mutable.metadata.plan.combo.v4.tracking.md:437` nonetheless + says *"Dependencies: Milestone 3 complete (the per-document assign semantics + need to be solid before adding the recursive variant)."* — but "per-document + assign semantics" are a Milestone 2 deliverable, not Milestone 3. + +**Recommended fix**: change Milestone 4's dependency in the tracking doc to +"Milestone 2 complete" and add a note that Milestones 3 and 4 can be executed +in parallel by separate workstreams, with `task fullsuite:ci` as the merge +gate. This unblocks ~1 milestone of wall-clock time on the schedule. + +--- + +## Minor / residual items from the review + +- **Stale v3 wording at `plans/mutable.metadata.plan.combo.v4.md:105`** — + **Agree**. The bullet currently says *"Set automatically when a new version + is created via `PUT`"* but v4 deliberately moved this operation to `POST + /versions` (see the v4 rationale at line 409). The "via `PUT`" clause should + be changed to "via `POST /super-admin/custom-schemas/{schemaId}/versions`" + or simply "when a new version is created." + +- **Reset rationale at `plans/mutable.metadata.plan.combo.v4.md:682`** — + **Partial**. The bullet references `DeleteDocumentCustomMetadata` and + `NullifyDocumentCustomSchemaId` as "already added for the delete cascade" + and points at Section 8.2. But the v4 design ripped those out in favor of + FK cascades (see tracking note at 267: *"No delete-cascade queries are + added in this milestone"*). The queries that actually exist in the reset + path are `DeleteDocumentCustomMetadataForReset` and + `NullifyDocumentCustomSchemaIdForReset` (tracking 387-388). The bullet's + names are leftover from v3 and should be updated so a reader doesn't go + looking for non-existent SQLC queries. + +- **"One client per stack" authorization assumption** — **Agree it is + acceptable for this feature, but worth re-checking with Q before + implementation starts.** Plan line 714 and tracking line 170 both depend on + the production deployment model being one client per stack. If dev/uat are + ever used for anything other than test fixtures, this falls over. + +Q: confirmed. One client per stack. +--- + +## Overall + +Four of the five main findings (1, 3, 4, 5) are structural and block using +the tracking doc as the execution source of truth. Finding 2 is real but +already partially acknowledged in the tracking doc — the fix is to promote +the resolution into the plan instead of deferring to the implementer. + +The two residual items (stale v3 wording at lines 105 and 682) are cleanup, +not blocking, but are cheap to fix alongside the substantive changes. + +Suggested order of operations before Milestone 1 work begins: + +1. Rewrite Milestone 2 to include the `GetDocumentEnriched` / `DocumentEnriched` + / `GetEnriched` backend changes (Finding 1). +2. Resolve the `/custom-metadata/history` edge cases in plan Section 5.4 and + delete the "decision needed" language from tracking 360 (Finding 2). +3. Decide on retirement name-reuse semantics and document them in Section 4.2 + / 5.1 (Finding 3). +4. Delete the three phantom/duplicate items from the tracking doc (Finding 4): + - Rewrite tracking 448 to reference the existing + `CountDocumentsInFolderTree`. + - Rewrite tracking 264 to reuse/rename `HasFieldExtraction`. + - Delete tracking 308 (or rewrite as a regression guard). +5. Change Milestone 4's dependency from "Milestone 3 complete" to "Milestone 2 + complete" in tracking 437 (Finding 5). +6. Fix the two stale v3 references at plan lines 105 and 682. diff --git a/plans/mutable.metadata.plan.combo.v4.tracking.md b/plans/mutable.metadata.plan.combo.v4.tracking.md new file mode 100644 index 00000000..4ea41dfc --- /dev/null +++ b/plans/mutable.metadata.plan.combo.v4.tracking.md @@ -0,0 +1,575 @@ +# 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 FK `fk_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 removing `document_custom_metadata.schema_id`. v4 has no Trigger 3. +- `current_document_custom_metadata` view -- replaced by a `LIMIT 1` query +- The entire delete-cascade workstream (v3 Phase 5b) -- replaced by `ON DELETE CASCADE` on the new FKs +- `PUT /super-admin/custom-schemas/{schemaId}` -- replaced by `POST .../versions` +- `createdBy` request-body fields -- actor comes from the JWT +- `customSchemaName` and inlined `customMetadata` on `GET /document/{id}` -- only `customSchemaId` and `hasCustomMetadata` remain + +Every task below reflects those decisions. + +## How to use this document + +- `[ ]` = not started +- `[~]` = in progress +- `[x]` = done AND tests passing (`task fullsuite:ci` clean for the affected code paths) +- `[!]` = blocked -- add a short `(blocked: )` suffix and open a journal entry +- `[-]` = consciously skipped -- add a short `(skipped: )` 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 `COMPLETE` and date-stamp it. +- Never mark a milestone complete until `task fullsuite:ci` ends with `All coverage checks passed!` with no `FAIL` lines. + +--- + +## 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 in `internal/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% on `ResetDocumentMetadata` and `mapResetError` in `api/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: added `TestMapResetError` (white-box, `package queryapi`) to `custommetadata_errors_test.go`; created `api/queryAPI/customschemas_reset_test.go` (`package queryapi_test`) with 5 integration tests covering 401/200/200-idempotent/404/409. All pass. +- `[x]` Run `task fullsuite:ci` — passes with `All 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:ci` green +- 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=any` returns both rows with the correct statuses (v1 `superseded`, v2 `active`), `GET` with the default query (no `status`, no `includeAllVersions`) returns only v2 `active`, DELETE removes a retired schema, and `user_admin` / `client_user` / `auditor` are 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. + +- [x] Create `internal/database/migrations/00000000000127_create_client_metadata_schemas.up.sql` with `CREATE TYPE schema_status_type AS ENUM ('active', 'superseded', 'retired')` -- test: migration applies cleanly on empty DB +- [x] Create `client_metadata_schemas` table per Section 4.2 of the plan -- columns, PK, FK to `clients(clientId) ON DELETE CASCADE`, `schema_def jsonb`, `version`, `status`, timestamps, `created_by` -- test: `\d client_metadata_schemas` shows all columns and the cascade FK +- [x] 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 +- [x] **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` +- [x] 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 +- [x] Add indexes `idx_cms_client_id`, `idx_cms_client_name`, `idx_cms_client_status` -- test: `\di client_metadata_schemas` shows the three indexes +- [x] Create `00000000000127_create_client_metadata_schemas.down.sql` dropping the table and enum in correct order -- test: up then down leaves DB state byte-identical to pre-migration +- [x] Run `task db:generate` after migration lands -- test: no errors + +### 1.2 Schema validator component + dependency + +- [x] Add `github.com/santhosh-tekuri/jsonschema/v6` to `go.mod` -- test: `go mod tidy` clean, build clean +- [x] Create `internal/customschema/constants.go` with `MaxSchemaDefinitionBytes = 65536`, `MaxMetadataPayloadBytes = 1048576`, `MaxBulkAssignDocuments = 10000` -- test: constants referenced by tests +- [x] Create `internal/customschema/validator.go` with `SchemaValidator` struct -- test: compiles +- [x] Implement `ValidateSchemaDefinition(schemaDef json.RawMessage) error` -- test: valid JSON Schema draft 2020-12 accepted +- [x] Reject schema definitions that are not valid JSON -- test: TDD test with malformed JSON +- [x] Reject schema definitions that fail JSON Schema meta-validation -- test: TDD test with `{"type": "nonsense"}` +- [x] Reject schema definitions where root `type` is not `"object"` -- test: TDD test with `{"type": "array"}` +- [x] Reject schema definitions where root `additionalProperties` is not explicitly present -- test: TDD test with schema missing the key +- [x] Accept schema definitions where root `additionalProperties` is explicitly `true` or `false` -- test: TDD tests for both +- [x] Reject schema definitions larger than `MaxSchemaDefinitionBytes` -- test: TDD test with 64KB+1 payload +- [x] `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`'s `Up()` 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. + +- [x] Create `00000000000128_add_custom_schema_id_to_documents.up.sql` -- `ALTER TABLE documents ADD COLUMN custom_schema_id uuid NULL` (**no inline `REFERENCES` clause**; the composite FK below handles it) -- test: column present and nullable +- [x] 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 with `custom_schema_id` pointing 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 the `documents.clientId` column was declared unquoted in migration 5, so Postgres stored it as lowercase `clientid`. A quoted `"clientId"` in the FK declaration is a case-sensitive literal lookup that fails with `column "clientId" referenced in foreign key constraint does not exist`. The migration uses **unquoted** `clientId` which folds to `clientid` at parse time and matches. This same gotcha applies to any future DDL or trigger that references `documents."clientId"`. +- [x] Create `idx_documents_custom_schema_id` -- test: index present +- [x] Write `down.sql` that drops the index, the constraint, and the column in correct order -- test: round trip clean +- [x] Verify existing documents rows have `custom_schema_id = NULL` after migration -- test: `SELECT COUNT(*) FROM documents WHERE custom_schema_id IS NOT NULL` returns 0 + +### 1.4 SQLC queries for schema CRUD + +New query file: `internal/database/queries/customschemas.sql` + +- [x] `CreateClientMetadataSchema` -- INSERT returning full row -- test: generated code compiles, query name appears in `*.sql.go` +- [x] `GetClientMetadataSchema` (by id) -- test: generated +- [x] `ListClientMetadataSchemas` (filters: client_id required, name optional, status optional with default `active`, pagination limit/offset; includes `documentCount` subquery against `documents.custom_schema_id`) -- test: generated; unit test confirms `documentCount` populated correctly +- [x] `GetLatestSchemaByName` -- test: generated +- [x] `GetSchemaDocumentCount` (count of `documents.custom_schema_id = $1`) -- test: generated +- [x] `SetSchemaStatus` (update status, used for supersede and retire) -- test: generated +- [x] `GetMaxSchemaVersion` (for auto-increment on version creation) -- test: generated +- [x] `LockSchemaVersionsForName` (`SELECT ... FOR UPDATE` on all versions of a `(client_id, name)` pair) -- test: generated +- [x] `task generate` clean; no diff after re-run -- test: idempotent + +> **Note**: `GetSchemaMetadataRecordCount` is deliberately **not** in this list. v3 used it for `canDelete`, but v4's `canDelete` is derived from `GetSchemaDocumentCount` alone (the metadata table has no `schema_id` column). + +### 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. + +- [x] Decide sink location: either `internal/customschema/audit.go` (feature-local) or `internal/audit/` (shared) -- test: decision recorded in journal (feature-local chosen 2026-04-14) +- [x] Define `AuditRecord` struct: `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 +- [x] Define `AuditSink` interface with `Record(ctx, AuditRecord) error` -- test: compiles +- [x] Provide a default implementation (structured `slog` with `audit=true` attribute, or a DB-backed appender if Q decides) -- test: unit test verifies a recorded event lands in the sink (`TestSlogAuditSink_Record` — 4 subtests green) +- [x] Wire the sink into `customschema.Service` via constructor arg -- test: compiles (Service.New(cfg, validator, audit) landed in §1.6) + +### 1.6 Service layer - schema CRUD + +- [x] Create `internal/customschema/service.go` with `Service` struct holding `cfg`, `validator`, `audit` -- test: compiles +- [x] Create `internal/customschema/models.go` with `Schema`, `SchemaSummary`, `CreateSchemaInput`, `CreateSchemaVersionInput`, `ListFilters` -- test: compiles +- [x] **v4**: `CreateSchemaInput` and `CreateSchemaVersionInput` do **not** carry a `CreatedBy` field. Every write method accepts `actor string` as a separate argument. -- test: unit test proves the service method signature has `actor string` and not a body field +- [x] `CreateSchema(ctx, input, actor)` -- meta-validates, enforces 64KB, enforces explicit `additionalProperties`, enforces `name` unique within client for active schemas at version 1 -- test: happy path +- [x] `CreateSchema` rejects invalid JSON Schema -- test: TDD +- [x] `CreateSchema` rejects oversize definition -- test: TDD +- [x] `CreateSchema` rejects duplicate name for same client at version 1 -- test: TDD (service returns `ErrSchemaNameConflict`, wraps pg `23505` / `uq_client_schema_name_version`) +- [x] `CreateSchema` writes `schema.create` audit entry -- test: TDD +- [x] `CreateSchemaVersion(ctx, parentSchemaID, input, actor)` -- uses `LockSchemaVersionsForName` -> verifies the path `parentSchemaID` matches the single row with `status='active'` for `(client_id, name)` -> `GetMaxSchemaVersion` -> INSERT new version -> `SetSchemaStatus(parent, 'superseded')` inside a single tx -- test: happy path produces v2 +- [x] `CreateSchemaVersion` concurrency: 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 returns `ErrSchemaParentNotActive` — no retry loop; the `FOR UPDATE` serializes the two tx and the loser observes the parent as superseded) +- [x] `CreateSchemaVersion` rejects when parent does not exist -- test: TDD (returns `ErrSchemaNotFound`) +- [x] **v4 non-active parent rejection**: `CreateSchemaVersion` rejects with 409 when the path `parentSchemaID` points at a `superseded` row 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 has `status='active'` and v1 still has `status='superseded'` (service returns `ErrSchemaParentNotActive`) +- [x] **v4 retired parent rejection**: `CreateSchemaVersion` rejects with 409 when the path `parentSchemaID` points at a `retired` row -- test: TDD asserts 409, asserts no new row inserted, asserts the retired row's status is unchanged (service returns `ErrSchemaParentRetired`) +- [x] **v4 active-parent invariant**: After `CreateSchemaVersion` succeeds there is exactly one row with `status='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 +- [x] `CreateSchemaVersion` rejects invalid schema definition (same validator rules as create) -- test: TDD +- [x] `CreateSchemaVersion` writes `schema.update` audit entry -- test: TDD (`ResourceID` is the new version's ID; `Details` carries `name`, `version`, `parent_schema_id`) +- [x] `GetSchema(ctx, schemaID)` -- test: returns full row including `schema_def` +- [x] `GetSchema` 404 when not found -- test: TDD (returns `ErrSchemaNotFound` via `errors.Is(err, pgx.ErrNoRows)`) +- [x] `ListSchemas(ctx, clientID, filters)` -- default returns only `active` and only the latest version per `(client_id, name)` -- test: TDD +- [x] `ListSchemas` filter by `name`, by `status` (including the `any` sentinel for "all statuses"), by `includeAllVersions=true`, with pagination -- test: TDD per case, including one case that sets both `status=any` and `includeAllVersions=true` against a lineage that has one active + one superseded row and asserts both rows are returned +- [x] `ListSchemas` populates `documentCount` and `canDelete` -- test: TDD +- [x] `ListFilters` accepts an `Status` field with zero-value meaning "active" (default) and a dedicated `StatusAny` sentinel (or `*schemaStatusType` with `nil` meaning "any") so handlers can distinguish "filter omitted" from "caller asked for everything" — test: unit test on the mapping from query-string `status=any` to the service-layer value +- [x] `DeleteSchema(ctx, schemaID, actor)` -- sets status to `retired` -- test: happy path +- [x] `DeleteSchema` returns 409 if any document references the schema via `custom_schema_id` -- test: TDD (returns `ErrSchemaInUse`) +- [x] `DeleteSchema` writes `schema.delete` audit entry -- test: TDD +- [x] `go test ./internal/customschema/...` green -- test: package level +- [x] `task test:race` green for package -- test: no races (`go test -race ./internal/customschema/... → ok 8.303s`) + +### 1.7 OpenAPI spec - schema CRUD + +- [x] Add `SuperAdminSchemaService` tag with description explicitly stating the `super_admin`-only boundary -- test: spec lints +- [x] Add `SchemaStatus` enum (`active`/`superseded`/`retired`) -- test: codegen produces Go type +- [x] `CustomSchemaRequest` schema (no `createdBy` field) -- test: codegen +- [x] `CustomSchemaVersionRequest` schema (no `createdBy`; v4 rename from v3's `CustomSchemaUpdateRequest`) -- test: codegen +- [x] `CustomSchemaResponse` schema with `createdBy` declared as plain string (**not** `format: email`) per plan Section 8.3 "Actor fields" -- test: codegen; spec lint +- [x] Spec check: assert that none of the new response schemas declare `format: email` on a `createdBy` / `resetBy` field -- test: grep or programmatic spec check in `task openapi:lint` (verified via `grep -nE 'format: ?email' serviceAPIs/queryAPI.yaml | grep -iE 'createdBy|resetBy'` → 0 matches) +- [x] `CustomSchemaListResponse` schema with `documentCount` and `canDelete` fields -- test: codegen +- [x] `POST /super-admin/custom-schemas` path -- test: codegen produces handler interface +- [x] `GET /super-admin/custom-schemas` (with query params) -- test: codegen +- [x] `GET /super-admin/custom-schemas/{schemaId}` -- test: codegen +- [x] **v4**: `POST /super-admin/custom-schemas/{schemaId}/versions` path (replaces v3's `PUT /super-admin/custom-schemas/{schemaId}`) -- test: codegen +- [x] `DELETE /super-admin/custom-schemas/{schemaId}` -- test: codegen +- [x] `task generate` clean -- test: no errors +- [x] Generated controllers compile -- test: `go build ./...` (stub handlers land in `api/queryAPI/customschemas_stub.go` returning `http.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. + +- [x] Add `super-admin` resource to `cmd/auth_related/permit.setup/permit_policies.yaml` with `get`, `post`, `patch`, `delete` actions -- test: YAML valid (verified via `python3 -c 'yaml.safe_load(...)'` 2026-04-14) +- [x] Add `custom-metadata` resource with `get`, `post` actions (provisioning it now lets Milestone 2 ship without another setup-tool run) -- test: YAML valid +- [x] Grant `super_admin` role: `super-admin: [get, post, patch, delete]`, `custom-metadata: [get, post]` -- test: YAML valid +- [x] Confirm `user_admin` role NOT granted `super-admin` -- test: visual diff (YAML parser confirmed `user_admin super-admin: None`) +- [x] Grant `user_admin` role: `custom-metadata: [get, post]` -- test: YAML valid +- [x] Grant `auditor` role: `super-admin: [get]`, `custom-metadata: [get]` -- test: YAML valid +- [x] Grant `client_user` role: `custom-metadata: [get, post]` -- test: YAML valid +- [x] Add documentation-only `policy_mappings` entries for every `/super-admin/*` path including `/versions` and `/reset-metadata` -- test: YAML valid +- [x] Add documentation-only `policy_mappings` entries for every `/custom-metadata/*` path -- test: YAML valid +- [x] **v4**: No `put` action needed on `super-admin` -- v4 replaced the v3 `PUT` route with `POST .../versions`. Record this decision in the journal so no one re-adds it. -- test: journal entry (recorded) +- [x] Run `cmd/auth_related/permit.setup/run.tool.dev.sh` -- test: exit code 0 (Q ran 2026-04-14; confirmed dev environment updated) +- [x] Verify `super-admin` and `custom-metadata` resources exist in dev with expected actions -- test: confirmed by Q running the setup tool successfully 2026-04-14 +- [x] Verify `super_admin` role has expected grants and `user_admin` has NO `super-admin` grants in dev -- test: confirmed by Q 2026-04-14 +- [!] Run `run.tool.uat.sh` after 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.sh` after 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) +- [x] Journal entry with timestamps of each env run (dev: 2026-04-14; uat/prod: deferred) + +### 1.9 Schema CRUD controllers + +- [x] Create `api/queryAPI/customschemas.go` skeleton with handler receivers wired to `customschema.Service` -- test: compiles (replaced the §1.7 stub file) +- [x] **v4 actor extraction helper**: every handler in this file derives actor identity via `cognitoauth.GetUserSubject(c)` (returns the JWT `sub` claim — an opaque Cognito subject UUID, **not** an email) and passes it to the service as a separate argument; any `createdBy` on 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 extra `createdBy` key with a different email-shaped value, asserts the service receives the JWT subject UUID and the body key has no effect, and asserts the response `createdBy` is the UUID (not the body value, not an email) (handler tests set claims via `ctx.Set("user_claims", ...)` mirroring `eulaHandlers_test.go`) +- [x] `POST /super-admin/custom-schemas` handler -- parse, validate, derive actor, call `CreateSchema`, map errors to 400/409/500 -- test: TDD (`TestCreateCustomSchema_Handler` 5 subtests) +- [x] `GET /super-admin/custom-schemas` handler with query params -- test: TDD (`TestListCustomSchemas_Handler` 4 subtests) +- [x] `GET /super-admin/custom-schemas/{schemaId}` handler -- test: TDD (`TestGetCustomSchema_Handler` 2 subtests) +- [x] **v4**: `POST /super-admin/custom-schemas/{schemaId}/versions` handler -- derives actor, calls `CreateSchemaVersion` -- test: TDD (`TestCreateCustomSchemaVersion_Handler` 3 subtests) +- [x] `DELETE /super-admin/custom-schemas/{schemaId}` handler -- test: TDD including 409 path (`TestDeleteCustomSchema_Handler` 3 subtests) +- [x] `go test ./api/queryAPI/customschemas_test.go` green -- test: package (17 subtests PASS under scoped -run invocations) + +### 1.10 Milestone 1 integration tests + +- [x] End-to-end test: super_admin POSTs schema v1 -> GET returns it with version 1, active -- test: integration against testcontainer DB (`TestMilestone1_CreateV1AndGet`) +- [x] End-to-end test: super_admin POSTs schema v2 via `POST .../versions` -> `GET /super-admin/custom-schemas?clientId=...&name=...&includeAllVersions=true&status=any` returns v1 (status `superseded`) and v2 (status `active`). Also assert the default `GET` (no `includeAllVersions`, no `status`) returns **only** v2 (the default filter is `includeAllVersions=false` + `status=active`, which intentionally hides superseded predecessors from the common admin UX). -- test: integration (`TestMilestone1_CreateV2ListSemantics` with 2 subtests `default_list_returns_active_latest_only` and `include_all_versions_status_any_returns_both`) +- [x] End-to-end test: DELETE on a schema with documents bound returns 409 -- test: integration (`TestMilestone1_DeleteBoundReturns409`; asserts row stays `active` in DB on denial) +- [x] End-to-end test: DELETE on an unreferenced schema succeeds and sets status `retired` -- test: integration (`TestMilestone1_DeleteUnreferencedRetires`) +- [x] 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 mentions `fk_documents_custom_schema_same_client` and docA.custom_schema_id is still NULL) +- [x] Authorization matrix for Milestone 1 endpoints: `super_admin` positive on all methods; `user_admin` 403 on every route; `client_user` 403; `auditor` GET-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; `runPermitGate` reproduces the `performPermitIOAuthorization` choke-point logic in-test using only the exported `cognitoauth` symbols `GetUserSubject`, `GetResourceFromRoute`, `GetActionFromMethod`, `PermitChecker`) +- [x] `task fullsuite:ci` clean with `All 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`) +- [x] 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:ci` green. + +### 2.1 Migration 129 - `document_custom_metadata` table + +- [x] Create `00000000000129_create_document_custom_metadata.up.sql` with `document_custom_metadata` table per Section 4.3 of the plan -- columns: `id`, `document_id`, `metadata`, `version`, `created_at`, `created_by`. **No `schema_id` column.** -- test: `\d document_custom_metadata` confirms column set +- [x] FK on `document_id` uses `ON DELETE CASCADE` -- test: direct SQL delete of a document row removes its metadata rows +- [x] Add `CONSTRAINT uq_doc_custom_metadata_version UNIQUE (document_id, version)` -- test: direct SQL duplicate `(document_id, version)` INSERT fails +- [x] 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 +- [x] **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 +- [x] Create corresponding `down.sql` dropping the table -- test: up/down round trip clean + +### 2.2 Migration 130 - schema invariant triggers (v4: Trigger 1 + Trigger 2) + +- [x] Create `00000000000130_add_schema_invariant_triggers.up.sql` -- test: migration applies cleanly +- [x] Define `trg_prevent_schema_reassignment()` function per Section 4.7 Trigger 1 -- test: function exists in `pg_proc` +- [x] Attach trigger to `documents` BEFORE UPDATE OF `custom_schema_id` -- test: trigger listed in `\d documents` +- [x] Trigger 1 unit test: insert document, assign schema, write custom metadata, attempt UPDATE `custom_schema_id` -- expect `RAISE EXCEPTION` with "custom metadata already exists" message +- [x] Trigger 1 unit test: insert document, add legacy extraction row, attempt UPDATE `custom_schema_id` -- expect `RAISE EXCEPTION` with "legacy field extractions already exist" message +- [x] Trigger 1 unit test: insert document with no metadata and no extractions, UPDATE `custom_schema_id` from schema_a to schema_b -- expect success (pre-binding change allowed) +- [x] Define `trg_prevent_legacy_extraction_on_custom_document()` function per Section 4.7 Trigger 2 -- reads `documents.custom_schema_id` with `FOR SHARE` and raises `check_violation` if non-null -- test: function exists in `pg_proc` +- [x] Attach trigger to `documentFieldExtractions` BEFORE INSERT -- test: trigger listed in `\d "documentFieldExtractions"` +- [x] Trigger 2 unit test: insert document with `custom_schema_id` set, attempt direct-SQL INSERT into `documentFieldExtractions` -- expect `RAISE EXCEPTION` with "bound to custom schema" message and SQLSTATE `23514` (`check_violation`) +- [x] Trigger 2 unit test: insert document with `custom_schema_id = NULL`, INSERT into `documentFieldExtractions` -- expect success (no-op guard path) +- [x] Trigger 2 concurrency test: start tx A running `AssignSchema`-style `SELECT documents FOR UPDATE` + `UPDATE documents SET custom_schema_id = S`, then in tx B run `INSERT documentFieldExtractions` targeting the same document -- tx B's trigger's `FOR SHARE` must 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 +- [x] **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 +- [x] **v4**: Migration intentionally does NOT define v3's `trg_enforce_consistent_schema_id` (not needed -- `document_custom_metadata` has no `schema_id` column) -- record this in a migration-file comment +- [x] Write `down.sql` dropping both triggers and both functions in correct order -- test: up/down round trip clean +- [x] Run `task generate` after migrations land -- test: clean +- [x] Run `task test:unit:short` -- test: all existing tests still pass (no regression) + +### 2.3 SQLC queries for metadata + document binding + +- [x] `CreateDocumentCustomMetadata` (no `schema_id` parameter) -- test: generated code compiles +- [x] `GetCurrentDocumentCustomMetadata` -- `ORDER BY version DESC LIMIT 1` using `idx_dcm_doc_version_desc` (**v4: no view**). **Joins `documents` and `client_metadata_schemas`** to also return `schema_id`, `schema_name`, `schema_version` so the `GET /custom-metadata` handler 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 schema +- [x] `GetDocumentCustomMetadataByVersion` -- same join as above so `GET /custom-metadata/version` returns matching schema decoration -- test: generated +- [x] `GetDocumentCustomMetadataHistory` (with limit/offset) -- no schema join; the history response returns version summaries only -- test: generated +- [x] **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's `LockDocumentCustomMetadataForVersion`. +- [x] `GetMaxDocumentMetadataVersion` -- `SELECT COALESCE(MAX(version), 0) FROM document_custom_metadata WHERE document_id = @document_id` -- test: generated +- [x] `SetDocumentCustomSchemaId` -- test: generated +- [x] `GetDocumentCustomSchemaId` -- test: generated +- [x] **Reuse existing query**: `HasFieldExtraction` already exists in `internal/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 and `AssignSchema` legacy-extraction rejection need. Do **not** add a new `DocumentHasLegacyExtractions` query. Call `HasFieldExtraction` from the service layer. If readability at the call site is a concern, wrap the call in a private service-layer helper named `documentHasLegacyExtractions(ctx, id)` that just forwards to `HasFieldExtraction` — no new SQL, no new generated code -- test: service unit test confirms `AssignSchema` and the metadata write path both reject documents that have an existing `documentFieldExtractionVersions` row +- [x] `task generate` clean -- test: idempotent + +> **v4**: No delete-cascade queries are added in this milestone. `DeleteDocumentCustomMetadata`, `NullifyDocumentCustomSchemaId`, and `DeleteClientMetadataSchemas` from 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 the `document.DocumentEnriched` struct, but as of the start of Milestone 2 the struct has neither field and the `GetDocumentEnriched` SQL 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. + +- [x] Extend the `GetDocumentEnriched` query in `internal/database/queries/document.sql` to add `d.custom_schema_id` to the SELECT list and a correlated `EXISTS(SELECT 1 FROM document_custom_metadata WHERE document_id = d.id) AS has_custom_metadata` subquery — no join to `document_custom_metadata` (the `EXISTS` keeps 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 +- [x] `task db:generate` clean — confirm the generated `GetDocumentEnriched` row type in `internal/database/repository/` now has `CustomSchemaID` and `HasCustomMetadata` fields -- test: `go build ./internal/database/repository/...` clean; `grep -n CustomSchemaID internal/database/repository/document.sql.go` finds the new field +- [x] Extend the `DocumentEnriched` struct in `internal/document/service.go` to add `CustomSchemaID *uuid.UUID` (nullable because most documents are not schema-bound) and `HasCustomMetadata bool`. Place them adjacent to `HasTextRecord` and `FileSizeBytes` so 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 to `nil` and `false` +- [x] Update `GetEnriched` in `internal/document/get.go` to copy `docEnriched.CustomSchemaID` and `docEnriched.HasCustomMetadata` from the extended generated row into the returned `DocumentEnriched` struct — **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") +- [x] Run existing tests for `internal/document/` and `api/queryAPI/document*` — every pre-existing test that reads `DocumentEnriched` must still pass without modification; the two new fields are additive. -- test: `go test ./internal/document/... ./api/queryAPI/... -run Document` green + +### 2.4 Service layer - metadata + document binding + +- [x] Add `CustomMetadata`, `MetadataVersion`, `SetMetadataInput` types to `models.go` -- test: compiles. **`SetMetadataInput` has no `CreatedBy` field.** **`CustomMetadata` carries `SchemaID`, `SchemaName`, `SchemaVersion`** populated from the joined query in 2.3 (Section 7.1 of the plan has the struct shape). +- [x] `SetDocumentMetadata(ctx, input, actor)` -- derives schema from `documents.custom_schema_id`, never trusts client -- test: happy path +- [x] `SetDocumentMetadata` rejects when document has no `custom_schema_id` -- test: TDD (400) +- [x] `SetDocumentMetadata` rejects when document has legacy extractions -- test: TDD (409) +- [x] `SetDocumentMetadata` validates metadata against schema via `validator.ValidateMetadata` -- test: TDD with conforming and non-conforming payloads +- [x] `SetDocumentMetadata` enforces `MaxMetadataPayloadBytes` -- test: TDD +- [x] `SetDocumentMetadata` serializes version assignment by taking `LockDocumentForMetadataWrite` on the parent row, then `GetMaxDocumentMetadataVersion`, then INSERT `version = max+1` -- test: TDD confirms the parent-row lock is acquired before the version read +- [x] `SetDocumentMetadata` concurrency: 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 DB +- [x] `SetDocumentMetadata` concurrency: two concurrent **first** writers against a document with zero metadata rows -- both succeed, produce `version=1` and `version=2` in some order -- test: TDD with goroutines, proves the parent-row lock is what serializes the first write +- [x] `SetDocumentMetadata` retries once on `UNIQUE (document_id, version)` violation as a belt-and-suspenders fallback -- test: TDD with an injected unique-violation on the first INSERT attempt +- [x] `SetDocumentMetadata` writes `metadata.write` audit entry -- test: TDD +- [x] `GetCurrentMetadata(ctx, documentID)` -- test: uses the LIMIT 1 query, returns latest +- [x] `GetMetadataByVersion(ctx, documentID, version)` -- test: TDD +- [x] `GetMetadataHistory(ctx, documentID)` with pagination -- test: TDD +- [x] Add `AssignSchemaResult` type to `models.go` per plan Section 7.1 (`DocumentID uuid.UUID`, `CustomSchemaID *uuid.UUID`, `SchemaName *string`, `SchemaVersion *int`) -- test: compiles +- [x] `AssignSchema(ctx, documentID uuid.UUID, schemaID *uuid.UUID, actor string) (*AssignSchemaResult, error)` — signature takes `schemaID` as a pointer so `nil` clears the binding and returns an `AssignSchemaResult` with all three schema fields `nil` -- test: happy path single-document assignment returns a populated result +- [x] `AssignSchema` populates `SchemaName` and `SchemaVersion` on the result from the same schema row read it already does for the `active` / same-client validation — no new query is added for this decoration -- test: TDD asserts result fields match the target schema row +- [x] `AssignSchema` takes `SELECT id, "clientId", custom_schema_id FROM documents WHERE id = $1 FOR UPDATE` as the **first** statement inside the transaction, before any legacy-extraction existence check or the `UPDATE 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 DML +- [x] `AssignSchema` rejects 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: TDD +- [x] `AssignSchema` rejects if schema status is not `active` -- test: TDD +- [x] `AssignSchema` rejects if document already has custom metadata (matches Trigger 1 behavior; expect 409) -- test: TDD +- [x] `AssignSchema` rejects if document has legacy extractions (service-layer check under the parent-row lock plus Trigger 1 as backstop; expect 409) -- test: TDD +- [x] `AssignSchema` allows setting `schemaID == nil` to clear binding when no metadata exists; result has `CustomSchemaID`, `SchemaName`, `SchemaVersion` all `nil` -- test: TDD +- [x] `AssignSchema` writes `schema.assign` audit entry -- test: TDD +- [x] `go 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_id` is NOT sufficient. Plan Section 7.3 walks through the race: two transactions each read a stale snapshot under `READ COMMITTED`, both pass the pre-check, and both commit opposing writes. The fix is a `SELECT ... FOR UPDATE` on the parent `documents` row as the **first** statement inside the tx, mirrored by the same lock in `AssignSchema`. Trigger 2 (Milestone 2.2) is the DB-layer backstop. + +- [x] Locate the existing `FieldExtractionService.CreateFieldExtraction` entry point (`internal/fieldextraction/service.go:27`) -- test: grep and read code +- [x] Add a new SQLC query `LockDocumentForLegacyExtractionWrite` (`SELECT id, custom_schema_id FROM documents WHERE id = @document_id FOR UPDATE`) in `internal/database/queries/document.sql` -- test: generated code compiles +- [x] Modify `CreateFieldExtraction`: after `Begin(ctx)` and **before** any other DML, call `LockDocumentForLegacyExtractionWrite`. The existing `AddFieldExtraction` call must move to **after** the lock acquisition. -- test: code review confirms no DML runs before the lock +- [x] If the lock query returns `pgx.ErrNoRows`, return a typed sentinel error `ErrDocumentNotFound` (controller maps to 404) -- test: TDD service test +- [x] If the locked row has `custom_schema_id IS NOT NULL`, return a typed sentinel error `ErrMutualExclusivityViolation` -- test: TDD service test asserts `errors.Is(err, ErrMutualExclusivityViolation)` +- [x] Service-layer concurrency test: two goroutines, one calls `AssignSchema` (which will itself take `FOR UPDATE` -- see 2.4) and the other calls `CreateFieldExtraction`. Run both orderings. Whichever commits second must fail -- `AssignSchema`-then-`CreateFieldExtraction` fails with `ErrMutualExclusivityViolation`; `CreateFieldExtraction`-then-`AssignSchema` fails 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 +- [x] Service-layer concurrency test (DB trigger backstop): deliberately skip the `FOR UPDATE` call path in a test-only harness, run the same race, and confirm Trigger 2 still catches it by surfacing a PostgreSQL `check_violation` error with SQLSTATE `23514`. This proves the defense-in-depth works even if the service-layer lock is ever removed by a regression. -- test: TDD +- [x] **Regression guard (v4 — not a present-day audit)**: at the time of this plan, `FieldExtractionService` has exactly one mutator — `CreateFieldExtraction` at `internal/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 of `FieldExtractionService` via 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 takes `LockDocumentForLegacyExtractionWrite` before any DML." -- test: unit test in `internal/fieldextraction/` that fails loudly with a message pointing at this tracking line if a new mutator is added without the review flag +- [x] Existing field extraction happy-path tests still green -- test: `go test ./internal/fieldextraction/...` +- [x] Update `CreateFieldExtraction` handler in `api/queryAPI/fieldextractions.go` to branch on `ErrMutualExclusivityViolation` -> `echo.NewHTTPError(http.StatusConflict, ...)` and `ErrDocumentNotFound` -> 404 -- test: handler unit tests assert both status codes and body messages +- [x] Handler also converts a PostgreSQL `check_violation` (SQLSTATE `23514`) 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` +- [x] Preserve the existing mapping of non-sentinel service errors to `500` -- test: TDD handler test with a generic error path +- [x] End-to-end HTTP test: create document, assign custom schema, call `POST /field-extractions`, assert response status is `409` and JSON body carries the documented message -- test: integration against testcontainer DB +- [x] End-to-end HTTP race test: fire `POST /super-admin/documents/{id}/schema` and `POST /field-extractions` concurrently against the same document, assert the final DB state has exactly one of `custom_schema_id IS NOT NULL` XOR a `documentFieldExtractionVersions` row, never both -- test: integration + +### 2.6 OpenAPI spec - metadata + document binding + +- [x] Add `CustomMetadataService` tag with description stating `client_user` + `user_admin` + `super_admin` access -- test: spec lints +- [x] `CustomMetadataRequest` (no `createdBy`) -- test: codegen +- [x] `CustomMetadataResponse` -- test: codegen +- [x] `CustomMetadataHistoryResponse` -- test: codegen +- [x] `ValidationErrorResponse` -- test: codegen +- [x] `DocumentSchemaAssignRequest` (no `createdBy`) -- test: codegen +- [x] `DocumentSchemaAssignResponse` -- test: codegen +- [x] **v4**: Modify `DocumentEnriched` to add **only** `customSchemaId` and `hasCustomMetadata`. **Do NOT** add `customSchemaName` or `customMetadata`. **Do NOT** add a new `?customMetadata=true` query parameter. -- test: codegen produces exactly two new fields +- [x] `PATCH /super-admin/documents/{id}/schema` path -- test: codegen +- [x] `GET /custom-metadata` path -- test: codegen +- [x] `POST /custom-metadata` path -- test: codegen +- [x] `GET /custom-metadata/version` path -- test: codegen +- [x] `GET /custom-metadata/history` path -- test: codegen +- [x] `task generate` clean -- test: no errors + +### 2.7 Controllers - metadata + assign schema + document enrichment + +- [x] Create `api/queryAPI/custommetadata.go` skeleton -- test: compiles +- [x] **v4 actor extraction**: `POST /custom-metadata` derives actor identity from the JWT and passes it to the service as an argument -- test: TDD handler test asserts body `createdBy` (if provided) does not override +- [x] `POST /custom-metadata` handler -- test: TDD +- [x] `GET /custom-metadata` handler -- test: TDD +- [x] `GET /custom-metadata/version` handler -- test: TDD +- [x] `GET /custom-metadata/history` handler -- test: TDD +- [x] Add `PATCH /super-admin/documents/{id}/schema` handler to `api/queryAPI/customschemas.go` -- derives actor -- test: TDD +- [x] Extend `DocumentEnriched` builder to include `customSchemaId` and `hasCustomMetadata` unconditionally — reads the values directly from the `document.DocumentEnriched` struct (populated in §2.3a); **no** new DB query or service call at this layer. -- test: TDD +- [x] Document without any custom schema returns `customSchemaId: null`, `hasCustomMetadata: false` -- test: TDD +- [x] Document with schema bound but no metadata returns `customSchemaId: `, `hasCustomMetadata: false` -- test: TDD +- [x] Document with schema + metadata returns populated fields -- test: TDD +- [x] Authorization: confirm `GET /document/{id}` still uses the existing `document` resource, not `super-admin` -- test: role matrix test in 2.8 +- [x] `go build ./...` clean -- test: compile + +### 2.8 Milestone 2 integration tests + +- [x] End-to-end: create schema -> assign to document (PATCH) -> POST valid metadata -> `GET /custom-metadata?documentId=...` returns the stored row with populated `schemaId`, `schemaName`, `schemaVersion` (from the §2.3 joined query) and the `createdBy` field equal to the JWT subject UUID -- test: integration +- [x] End-to-end: POST invalid metadata -> 400 with validation errors -- test: integration +- [x] End-to-end: POST metadata to document without assigned schema -> 400 -- test: integration +- [x] End-to-end: assign schema to document that has legacy extractions -> 409 -- test: integration +- [x] End-to-end: write legacy field extraction to document that has custom schema -> 409 -- test: integration +- [x] End-to-end `GET /custom-metadata/version`: POST metadata three times on the same document (version 1, 2, 3), then `GET /custom-metadata/version?documentId=...&version=2` returns exactly the v2 payload (not v1, not v3) with populated `schemaId`/`schemaName`/`schemaVersion` from the §2.3 joined query -- test: integration +- [x] End-to-end `GET /custom-metadata/version` not-found: `version=99` on a document with only 3 versions returns 404; `documentId` for 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 +- [x] End-to-end `GET /custom-metadata/history` happy path: after three POSTs, `GET /custom-metadata/history?documentId=...` returns all three version summaries in descending `version` order and each summary carries `version`, `createdAt`, `createdBy` (subject UUID) -- test: integration +- [x] End-to-end `GET /custom-metadata/history` pagination: POST 5 metadata versions, call with `limit=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 +- [x] `GET /custom-metadata/history` edge 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"` + - `documentId` exists but has zero metadata rows → 200 with `{"versions": []}` (and `Content-Type: application/json`) + -- test: integration per case +- [x] Authorization matrix for Milestone 2 endpoints: one integration test per `(role, route, method)` cell across **all four** `/custom-metadata` routes (`GET`, `POST`, `GET /version`, `GET /history`) plus `PATCH /super-admin/documents/{id}/schema`. Expected matrix: `super_admin` positive on every cell; `user_admin` 403 on `PATCH /super-admin/...` and positive on all four `/custom-metadata` routes; `client_user` 403 on `PATCH /super-admin/...` and positive on all four `/custom-metadata` routes; `auditor` 403 on `PATCH /super-admin/...` and on `POST /custom-metadata`, positive on the three `/custom-metadata` GET routes (`GET`, `GET /version`, `GET /history`) -- test: one integration test per (role, route, method) cell, each asserting status + DB unchanged on denial +- [x] **v4 FK-cascade delete test**: create document with custom metadata -> call existing `DeleteDocumentCascade` -> verify `document_custom_metadata` rows for that document are gone (via the ON DELETE CASCADE, without touching the delete code path) -- test: integration +- [x] **v4 FK-cascade client delete test**: create client with schemas and a document with custom metadata -> call `client.HardDelete` -> verify `document_custom_metadata` and `client_metadata_schemas` rows are gone -- test: integration +- [x] Metadata payload at 1MB boundary: accept 1048576, reject 1048577 -- test: integration +- [x] Concurrency: two concurrent first-writers against same document succeed -- test: integration (service test in 2.4 covers it; re-verify at HTTP level) +- [x] `task fullsuite:ci` clean -- test: full run +- [x] 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.go` and `internal/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 in `customschemas.go` directly); only commit the stubs if parallel execution is actually planned. + +- [x] Create `api/queryAPI/customschemas_reset.go` as an empty stub containing only `package queryAPI` -- test: `go build ./...` clean +- [x] Create `api/queryAPI/customschemas_bulk.go` as an empty stub containing only `package queryAPI` -- test: `go build ./...` clean +- [x] Create `internal/customschema/models_reset.go` as an empty stub containing only `package customschema` -- test: `go build ./...` clean +- [x] Create `internal/customschema/models_bulk.go` as an empty stub containing only `package customschema` -- test: `go build ./...` clean +- [x] 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 log` shows the four files as committed before either milestone branch exists +- [ ] Update Milestone 3 owner and Milestone 4 owner: M3 only edits `customschemas_reset.go` and `models_reset.go`; M4 only edits `customschemas_bulk.go` and `models_bulk.go`; neither touches `customschemas.go` or `models.go` for 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:ci` green. + +### 3.1 SQLC queries for reset-metadata + +- [x] `LockDocumentForReset` (`SELECT id, "clientId", custom_schema_id FROM documents WHERE id = @document_id FOR UPDATE`) -- test: generated +- [x] `GetDocumentSchemaBindingForReset` (join `documents` -> `client_metadata_schemas`, returns `(custom_schema_id, schema_name, schema_version)` or all-null) -- test: generated +- [x] `CountDocumentCustomMetadataVersions` -- test: generated +- [x] **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 +- [x] **v4**: `NullifyDocumentCustomSchemaIdForReset` (`UPDATE documents SET custom_schema_id = NULL WHERE id = @document_id`) -- owned by the reset path -- test: generated +- [x] `task generate` clean -- test: idempotent + +### 3.2 Service method - ResetDocumentMetadata + +- [x] Add `ResetMetadataResult` type to `internal/customschema/models_reset.go` (the M3-owned stub from §2.9; fall back to `internal/customschema/models.go` only if M3/M4 will run serially and §2.9 was skipped) with `DocumentID`, `PreviousSchemaID *uuid.UUID`, `PreviousSchemaName *string`, `PreviousSchemaVersion *int`, `MetadataVersionsDeleted int`, `ResetAt time.Time`, `ResetBy string` -- test: compiles +- [x] `ResetDocumentMetadata(ctx, documentID, actor)` -- single transaction: `LockDocumentForReset`, `GetDocumentSchemaBindingForReset`, `CountDocumentCustomMetadataVersions`, `DeleteDocumentCustomMetadataForReset`, `NullifyDocumentCustomSchemaIdForReset`, audit log -- test: happy path wipe with schema bound + N metadata versions +- [x] Reset returns populated `previous*` fields when document had a schema -- test: TDD +- [x] Reset returns `metadataVersionsDeleted` matching actual deleted count -- test: TDD +- [x] Reset is idempotent: already-clean document returns `metadataVersionsDeleted: 0`, `previousSchemaId: nil` -- test: TDD +- [x] Reset rejects documents with legacy field extractions (409) -- test: TDD +- [x] Reset returns 404 on non-existent document -- test: TDD +- [x] Reset atomicity: inject failure between DELETE and UPDATE -- test: transaction rolls back, metadata rows still present +- [x] Reset + reassign + write: after reset, Trigger 1 still fires on second schema change under new metadata -- test: TDD regression +- [x] (v3's "Trigger 3 regression" test is removed in v4: the table has no `schema_id` column. Replace with a structural test that confirms `document_custom_metadata` has no `schema_id` column after migrations.) -- test: TDD schema-structure test +- [x] Reset writes `metadata.reset` audit log entry containing actor, documentID, previousSchemaId, metadataVersionsDeleted -- test: TDD reads the audit sink +- [x] Reset concurrency: two concurrent resets on same document serialize via `FOR UPDATE` -- test: TDD +- [x] Reset concurrency: reset vs `AssignSchema` on same document serialize -- test: TDD +- [x] **v4 Reset concurrency: reset vs `SetDocumentMetadata` on same document serialize.** This is the pair that matters most in practice: plan Section 7.1 / 8.2 say `LockDocumentForReset` and `LockDocumentForMetadataWrite` use the same parent-row `SELECT ... FOR UPDATE` shape specifically so these two operations cannot interleave. Test both orderings with goroutines against testcontainer DB: + 1. **reset-then-write**: start reset (holds `FOR UPDATE`), race `SetDocumentMetadata` for the same document. The writer must block until reset commits; once it unblocks it must observe `custom_schema_id = NULL` and 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**: start `SetDocumentMetadata` (holds `FOR 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 return `metadataVersionsDeleted >= 1`. Assert final DB state has zero metadata rows and `custom_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 + +- [x] `DocumentMetadataResetResponse` schema (no request body schema needed; empty body) -- test: codegen (NOTE: named `DocumentMetadataResetResponse` not `ResetDocumentMetadataResponse` to avoid codegen name conflict; see journal 2026-04-15) +- [x] `POST /super-admin/documents/{id}/reset-metadata` path -- test: codegen +- [x] `task generate` clean -- test: no errors +- [x] Add handler to `api/queryAPI/customschemas_reset.go` (the M3-owned stub from §2.9; fall back to `api/queryAPI/customschemas.go` only if M3/M4 will run serially and §2.9 was skipped) -- parses document ID, derives actor from JWT, calls service, maps errors -- test: TDD +- [x] Handler maps success to 200 with `ResetDocumentMetadataResponse` -- test: `TestM3_ResetDocumentMetadata_HappyPath` + `TestM3_ResetDocumentMetadata_IdempotentOnCleanDoc` +- [x] Handler maps "document not found" to 404 -- test: `TestM3_ResetDocumentMetadata_DocumentNotFound` + `TestMapResetError/document_not_found` +- [x] Handler maps "legacy extractions exist" to 409 -- test: `TestM3_ResetDocumentMetadata_LegacyExtractionsConflict` + `TestMapResetError/mutual_exclusivity_violation` +- [x] Handler maps unexpected DB errors to 500 -- test: `TestMapResetError/unknown_error_returns_500` + +### 3.4 Milestone 3 integration tests + +- [x] End-to-end upgrade flow: bind schema v1 -> POST metadata -> `POST .../versions` to create v2 -> reset-metadata -> PATCH schema to v2 -> POST new metadata with added optional field -- test: `TestM3_FullUpgradeFlow` in `api/queryAPI/customschemas_reset_test.go` +- [x] End-to-end: reset on already-clean document returns 200 with `metadataVersionsDeleted: 0` -- test: `TestM3_ResetDocumentMetadata_IdempotentOnCleanDoc` +- [x] Schema version concurrency: two concurrent `POST .../versions` calls for same `(client_id, name)` produce distinct versions -- test: `TestM3_SchemaVersionConcurrency` in `api/queryAPI/customschemas_integration_test.go` +- [x] Authorization matrix for reset-metadata and version creation: `super_admin` positive; `user_admin` 403; `client_user` 403; `auditor` 403 -- test: `TestM3_AuthMatrix_Reset` in `api/queryAPI/customschemas_reset_test.go` (4 subtests: super_admin/user_admin/client_user/auditor) +- [x] `task fullsuite:ci` clean -- test: full run (85.4%, All coverage checks passed!, no FAIL lines, 2026-04-15) +- [x] 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:ci` green. + +### 4.1 SQLC queries for bulk folder assignment + +- [ ] `BulkSetDocumentCustomSchemaIdInFolderTree` (`WITH RECURSIVE`) -- test: generated; recursive CTE compiles +- [ ] `GetDocumentsWithMetadataInFolderTree` -- test: generated +- [ ] `GetDocumentsWithLegacyExtractionsInFolderTree` -- test: generated +- [ ] **Reuse existing query**: `CountDocumentsInFolderTree` already exists in `internal/database/queries/folders.sql` (a recursive CTE over `folders` + `documents.folderId`) and is exactly the shape the `MaxBulkAssignDocuments` pre-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 existing `CountDocumentsInFolderTree` generated method -- test: unit test asserts the right method is called and returns a count that matches a seeded fixture +- [ ] `task generate` clean -- test: idempotent + +### 4.2 Service method - AssignSchemaToFolder + +- [ ] Add `BulkAssignResult`, `SkippedDocument` types to `internal/customschema/models_bulk.go` (the M4-owned stub from §2.9; fall back to `internal/customschema/models.go` only if M3/M4 will run serially and §2.9 was skipped) -- test: compiles. **No `createdBy` on the input.** +- [ ] `AssignSchemaToFolder(ctx, folderID, schemaID, actor)` -- walks subtree with `WITH RECURSIVE` and returns `BulkAssignResult` -- test: happy path on a 3-deep tree +- [ ] Bulk assignment skips documents with different schema + existing metadata with `SkippedDocument` reason -- 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_schema` audit entry with counts -- test: TDD + +### 4.3 OpenAPI + handler - bulk folder assignment + +- [ ] `BulkSchemaAssignRequest` schema (no `createdBy`) -- test: codegen +- [ ] `BulkSchemaAssignResponse` schema -- test: codegen +- [ ] `POST /super-admin/folders/{folderId}/assign-schema` path -- test: codegen +- [ ] `task generate` clean -- test: no errors +- [ ] Handler in `api/queryAPI/customschemas_bulk.go` (the M4-owned stub from §2.9; fall back to `api/queryAPI/customschemas.go` only if M3/M4 will run serially and §2.9 was skipped) wired to `AssignSchemaToFolder` -- derives actor from JWT -- test: compiles + +### 4.4 Milestone 4 integration tests + +- [ ] Recursive walk on a 3-deep folder tree produces correct `documentsUpdated` count -- 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_admin` positive; `user_admin` 403; `client_user` 403; `auditor` 403 -- test: integration per cell +- [ ] `task fullsuite:ci` clean -- 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. + +- [x] Update `docs/ai.generated/README.md` index if new doc files added -- test: review (entry 11 added pointing to `11-custom-metadata-guide.md`) +- [x] Authorization matrix table published in a dedicated doc page (include the v4 change that `user_admin` has no schema CRUD) -- test: review (`11-custom-metadata-guide.md` Authorization Matrix section) +- [x] Sample schemas from Appendix A of the plan published as copyable examples -- test: review (`11-custom-metadata-guide.md` Schema Definition Requirements section, two examples) +- [x] `CustomMetadataService` usage guide (end-user perspective) -- test: review (`11-custom-metadata-guide.md` End-User Workflow section + `03-api-documentation.md` CustomMetadataService section) +- [x] `SuperAdminSchemaService` usage guide (platform-operator perspective), including the `POST /versions` route -- test: review (`11-custom-metadata-guide.md` Platform Operator Workflow + `03-api-documentation.md` SuperAdminSchemaService section) +- [x] 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.md` Schema Upgrade Runbook section) +- [x] Mutual exclusivity explanation (legacy vs custom) in the document processing guide -- test: review (`11-custom-metadata-guide.md` Mutual Exclusivity section) +- [x] **v4**: Note that `DeleteDocumentCascade` and `client.HardDelete` do NOT need custom-metadata-specific steps because of the FK cascades -- test: review (`11-custom-metadata-guide.md` Cascade Deletion Behavior section) +- [x] Permit.io deployment ordering note (run setup tool before deploying controllers) added to the deployment runbook -- test: review (`11-custom-metadata-guide.md` Permit.io Deployment Note section) +- [x] **v4**: Document the HTTP method choice: `POST /super-admin/custom-schemas/{schemaId}/versions`, not `PUT` -- test: review (`03-api-documentation.md` and `11-custom-metadata-guide.md` Schema Versioning section) +- [x] **v4**: Document that `createdBy` is not accepted from request bodies; actor comes from the JWT -- test: review (`03-api-documentation.md` Notes on POST /super-admin/custom-schemas; `11-custom-metadata-guide.md` Actor Fields section) +- [x] 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.md` with 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_id` column to `document_custom_metadata`. + - Do NOT add v3's `trg_validate_schema_client_match` trigger — it is replaced by the composite FK `fk_documents_custom_schema_same_client` (plan Section 4.7, Milestone 1.3). + - Do NOT add v3's `trg_enforce_consistent_schema_id` trigger — it is structurally unnecessary because `document_custom_metadata` has no `schema_id` column in v4. + - **Reminder**: v4 DOES add two triggers — `trg_prevent_schema_reassignment` (v3's Trigger 1, renamed for clarity) and `trg_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_metadata` view. + - Do NOT add `DeleteDocumentCustomMetadata` / `NullifyDocumentCustomSchemaId` / `DeleteClientMetadataSchemas` to the existing delete code paths. + - Do NOT add a `PUT /super-admin/custom-schemas/{schemaId}` route. + - Do NOT accept `createdBy` in new request bodies. + - Do NOT add `customSchemaName` or `customMetadata` to `GET /document/{id}`. + - Do NOT declare `format: email` on `createdBy` / `resetBy` in 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. diff --git a/plans/mutable.metadata.requirments.md b/plans/mutable.metadata.requirments.md new file mode 100644 index 00000000..ecb399c5 --- /dev/null +++ b/plans/mutable.metadata.requirments.md @@ -0,0 +1,30 @@ +# Persona +You are an expert db architect and software engineer. + +# Context +We are going to extend the schema for the text extraction records in this application that we currently expose so that each client can have their own collections of document schemas instead of everyone being tied into the same static 120 or so field extraction fields. + +For example. Say client A (Acme corp) has 200 custom fields that they want to associate with documents that are in the `aircraft engineering` class of document. Each field has a name and data type plus constraints like max length or precision for floating point values. Then acme might also have 100 custom fields that it needs to track for documents that are for `auto engineering` documents. + +# Requirements +A SUPER ADMIN should be able to curate ALL collectionions of schemas for their documents. (for field extraction records on their documents) (FieldExtractionService) + +So the client should be able to submit a new schema (which will get assigned an id by the system) The system will automatically reject the schema if it does not conform to jsonschema or whatever we are using for the schema. (json schema is assumed unless we come up with something better) + +Then once a schema is created they should be able to assign a document to use that schema id. Then when they set the text extraction record for that document the metadata submitted must conform to the schema that the document is currently using. A document's schema id must remain the same for the lifetime of the document onse text extraction data (metadata) has been assigned. +There can be a way to reset and clear the metadata on a document if we need to assigned an new schema and metadata. + +Only the super admin should be able to list the schemas they have registered and curate them. (delete ones that are not used) or update ones that need changes. Note that when a schema is updated it will be assigned a new schema id. + +document metadata needs to be extended so that each document has an optional (can be nill) custom schema id. + +# Task +You will thoroughly study the current API design (code and docs) expecially the FieldExtractionService + to come up with a suggested design for how the new API should look and how the new DB scheam that holds all of this metadata (and now custom schemas) will be structured. It is acceptable for a document to store custom metadata in json blobs in the database if that simplifies the organization. + +You will write the new design plan to (plan path here) + +You will not change any code in this repository or on this server as part of your research. +This is analysis and design only. and the only file you will write is the plan specified above. + +When you are done with the design and the plan is written to disk I will review. diff --git a/plans/mutable.metadata.review.combo.v3.codex.md b/plans/mutable.metadata.review.combo.v3.codex.md new file mode 100644 index 00000000..7e79c2bd --- /dev/null +++ b/plans/mutable.metadata.review.combo.v3.codex.md @@ -0,0 +1,86 @@ +# Mutable Metadata Plan v3 Review (Codex) +A review of plans/mutable.metadata.plan.combo.v3.md and plans/mutable.metadata.plan.combo.v3.tracking.md since they are to be used as a pair to implement the plans/mutable.metadata.plan.combo.v3.md plan. + + +I reviewed the current legacy field-extraction flow first so the plan review is anchored to real behavior: + +- `api/queryAPI/fieldextractions.go:20-69` binds the request, calls `FieldExtraction.CreateFieldExtraction`, and currently maps any create error to HTTP 500. +- `internal/fieldextraction/service.go:27-100` creates the extraction row, then locks existing version rows, computes `max(version)+1`, and inserts the version row. +- Authorization today is checked as `(user, action, first-path-segment resource)` with no document/client object passed into Permit.io: `internal/cognitoauth/middleware.go:84-110`, `internal/cognitoauth/permitio.go:79-89`. + +## Findings + +1. **Blocking: the tracking doc’s migration numbers collide with live migrations already in the repo.** + References: + - `plans/mutable.metadata.plan.combo.v3.tracking.md:48-75` + - `internal/database/migrations`: existing `00000000000120_*` through `00000000000123_*` are already present (`00000000000120_remove_text_extraction`, `00000000000121_create_eula_tables`, `00000000000122_make_eula_effective_date_nullable`, `00000000000123_create_ui_settings`) + Impact: + - Phase 1 cannot start as written. + - Any implementation that follows the tracking doc literally will create filename collisions and broken migration ordering. + Recommendation: + - Renumber all new mutable-metadata migrations to the next free sequence and update every tracking reference before coding. + +2. **Blocking: the plan grants `/custom-metadata` read/write to `client_user`, but the current auth model is not tenant-scoped.** + References: + - Plan grants: `plans/mutable.metadata.plan.combo.v3.md:730-780` + - Current auth check: `internal/cognitoauth/middleware.go:105-110`, `internal/cognitoauth/permitio.go:79-89` + - Current documented limitation: `cmd/auth_related/permit.setup/permit_policies.yaml:155-184` + Impact: + - `custom-metadata:get/post` would be authorized purely by resource/action on the first path segment. + - Without new application-level document/client ownership checks, a `client_user` or `user_admin` who knows another document UUID can read/write that document’s custom metadata across clients. + Recommendation: + - Resolve scoping before implementation. Either: + - Add explicit document/client ownership enforcement in the custom-metadata service/controller and track it as first-class work, or + - Do not grant `client_user`/`user_admin` write access to `/custom-metadata` until tenant scoping exists. + +3. **High: Phase 5a in the tracking doc misses the HTTP-layer work needed to return the documented 409.** + References: + - Tracking: `plans/mutable.metadata.plan.combo.v3.tracking.md:290-299` + - Current controller behavior: `api/queryAPI/fieldextractions.go:45-50` + Impact: + - Adding only a service pre-check will not produce the promised HTTP 409. + - As written, `POST /field-extractions` will still return 500 for the new mutual-exclusivity error. + Recommendation: + - Add explicit controller error mapping and HTTP tests for the legacy write guard, not just service tests. + +4. **High: the reset-metadata section is internally inconsistent about documents that already have legacy field extractions.** + References: + - `plans/mutable.metadata.plan.combo.v3.md:643-653` + Impact: + - The behavior bullets say a legacy-extraction document “succeeds trivially.” + - The validation section says the same condition returns `409 Conflict`. + - The tracking doc appears to assume `409`, so the plan and tracker are not actually aligned. + Recommendation: + - Pick one behavior and update both docs. I recommend `409`, because it matches the mutual-exclusivity rule and avoids false-success responses on legacy documents. + +5. **High: the custom-metadata versioning plan overstates its concurrency safety, especially on first write.** + References: + - Plan claim: `plans/mutable.metadata.plan.combo.v3.md:152-153` + - Tracking task: `plans/mutable.metadata.plan.combo.v3.tracking.md:239-245` + - Current legacy pattern: `internal/fieldextraction/service.go:70-100` + Impact: + - `SELECT ... FOR UPDATE` on the max-version row does not serialize the first write for a document, because there is no row to lock yet. + - Two concurrent first writes can still race and one will fail on `UNIQUE (document_id, version)`. + Recommendation: + - Decide on a real serialization strategy now: + - Lock the document row before version assignment, or + - Retry on unique-violation explicitly. + - Add a “concurrent first write” test to the tracker, not just a generic concurrency test. + +6. **Medium: actor/audit provenance is underspecified and currently inconsistent with the plan’s audit goals.** + References: + - Request bodies still carry caller-supplied `createdBy`: `plans/mutable.metadata.plan.combo.v3.md:566-571`, `847-858` + - Tracking only explicitly extracts caller identity for reset: `plans/mutable.metadata.plan.combo.v3.tracking.md:431-447`, `502` + - Reset refers to a not-yet-defined “same log sink used by `AssignSchema` / `DeleteSchema`”: `plans/mutable.metadata.plan.combo.v3.md:638`, `1069` + Impact: + - If `created_by` is meant to be audit-authoritative, client-supplied JSON is not trustworthy. + - The tracker does not currently include the work to derive actor identity from auth context for schema create/update/delete, folder assign, or metadata writes. + - The audit sink itself is assumed rather than designed. + Recommendation: + - Decide now whether actor identity is authoritative or merely display metadata. + - If authoritative, derive it from auth context everywhere and add explicit tracking tasks. + - Define the concrete audit sink/API instead of leaving it implied. + +## Bottom Line + +I would not start implementation until Findings 1 and 2 are resolved. Findings 3 through 6 should be folded into the tracking doc before work starts, otherwise the implementation will either drift from the plan or discover these gaps mid-phase. diff --git a/plans/mutable.metadata.simplify. plan.codex.v3.md b/plans/mutable.metadata.simplify. plan.codex.v3.md new file mode 100644 index 00000000..b13e9446 --- /dev/null +++ b/plans/mutable.metadata.simplify. plan.codex.v3.md @@ -0,0 +1,544 @@ +# Mutable Metadata v3 Simplification Findings (Codex) + +## Purpose + +This document reviews `plans/mutable.metadata.plan.combo.v3.md` and `plans/mutable.metadata.plan.combo.v3.tracking.md` with one goal: preserve the real requirements while reducing implementation risk, regression surface, and rollout complexity. + +The current v3 plan is trying to land too many things at once: + +- a new schema system +- a new metadata storage path +- new admin routes +- new user routes +- three database triggers +- document read-path changes +- delete-path changes +- bulk folder assignment +- destructive schema-reset workflow +- a large auth and test matrix + +The core requirement set is smaller than that. The simplest safe plan is to reduce redundant state, avoid touching stable paths until necessary, and ship the feature in vertical slices instead of infrastructure layers. + +--- + +## Executive Summary + +The biggest simplification is this: + +1. Keep `documents.custom_schema_id`. +2. Store versioned metadata by `document_id` only. +3. Derive the schema from the document binding at write/read time. +4. Keep only the invariants that are truly needed for v3. +5. Defer optional surface area until the core path is stable. + +That change alone removes one trigger, one FK, one index, one view, several queries, several tests, and a large amount of reasoning complexity. + +The second major simplification is rollout shape: stop treating this as twelve horizontal phases. Implement it as a few end-to-end vertical milestones so the system is usable and testable much earlier. + +--- + +## What Should Stay + +These are the actual requirements and should remain intact: + +- client-scoped custom schemas stored as data, not DDL +- schema definitions validated as JSON Schema +- documents gain an optional `custom_schema_id` +- metadata writes must validate against the document's assigned schema +- once custom metadata exists, the document's schema binding becomes immutable +- legacy field extractions and custom metadata remain mutually exclusive per document +- schema updates create a new schema version/new schema ID +- schema administration remains `super_admin`-only + +Everything else should be judged by whether it is necessary to satisfy those requirements. + +--- + +## Recommended Simplifications + +### 1. Remove `schema_id` from `document_custom_metadata` + +This is the highest-value simplification. + +The current plan stores both: + +- `documents.custom_schema_id` +- `document_custom_metadata.schema_id` + +That is redundant in v3. + +Why it is redundant: + +- `POST /custom-metadata` already derives schema from `documents.custom_schema_id`. +- the plan already forbids schema reassignment after metadata exists +- reset-metadata deletes all metadata rows before rebinding to a different schema + +Because of those three facts, every surviving metadata row for a document necessarily belongs to the document's current schema binding. A second schema pointer on the metadata row adds no new capability for v3. + +### Simpler table + +```sql +CREATE TABLE document_custom_metadata ( + id uuid PRIMARY KEY DEFAULT uuid_generate_v7(), + document_id uuid NOT NULL REFERENCES documents(id) ON DELETE CASCADE, + metadata jsonb NOT NULL, + version int NOT NULL DEFAULT 1, + created_at timestamptz NOT NULL DEFAULT NOW(), + created_by varchar(255) NOT NULL, + + CONSTRAINT uq_doc_custom_metadata_version UNIQUE (document_id, version) +); + +CREATE INDEX idx_dcm_doc_version_desc + ON document_custom_metadata(document_id, version DESC); +``` + +### What this removes + +- `document_custom_metadata.schema_id` +- `idx_dcm_schema_id` +- Trigger 3 (`trg_enforce_consistent_schema_id`) +- the `GetSchemaMetadataRecordCount` query +- the extra "schema consistency across metadata versions" app-layer checks +- the need to reason about metadata rows carrying a schema separate from the document row + +### Why this does not violate requirements + +It still preserves: + +- schema validation on write +- immutable schema binding after first metadata write +- destructive reset before rebinding +- schema version history at the schema table level +- metadata version history at the document level + +Do not pre-pay v4 complexity for a non-requirement. If a future version needs non-destructive in-place schema migration, then add row-level `schema_id` later with a deliberate migration. + +--- + +### 2. Replace Trigger 2 with a composite foreign key + +The current plan uses a trigger to ensure the schema belongs to the same client as the document. + +That is better expressed as a database constraint. + +### Simpler constraint + +Keep: + +- `client_metadata_schemas(id, client_id)` +- `documents(id, clientId, custom_schema_id)` + +Add: + +```sql +ALTER TABLE client_metadata_schemas + ADD CONSTRAINT uq_cms_id_client UNIQUE (id, client_id); + +ALTER TABLE documents + ADD CONSTRAINT fk_documents_custom_schema_same_client + FOREIGN KEY (custom_schema_id, "clientId") + REFERENCES client_metadata_schemas(id, client_id); +``` + +### Why this is better + +- declarative integrity instead of PL/pgSQL +- no trigger code +- no trigger-specific unit tests +- no divergence risk between service logic and DB logic + +With recommendation 1 applied, the only remaining trigger worth keeping is Trigger 1: "once metadata or legacy extraction rows exist, `custom_schema_id` cannot change." + +That shrinks the trigger surface from three triggers to one. + +--- + +### 3. Drop the `current_document_custom_metadata` view + +The new metadata table is simple enough that a view is unnecessary. + +Use: + +```sql +SELECT ... +FROM document_custom_metadata +WHERE document_id = $1 +ORDER BY version DESC +LIMIT 1; +``` + +With `idx_dcm_doc_version_desc`, this is straightforward and efficient. + +### Why remove the view + +- one less schema object +- one less migration artifact +- one less thing to test +- less drift between query logic and view logic + +The legacy field-extraction system benefits from a view because its state is spread across multiple tables. The new custom-metadata design does not need the same indirection. + +--- + +### 4. Use FK cascades so delete code does not need feature-specific edits + +The current plan adds delete-path work in `internal/document/delete.go` and `internal/client/delete.go`. + +That is avoidable. + +Because these are new tables, design their FKs so existing delete flows continue to work without feature-specific code. + +### Recommended FK behavior + +- `document_custom_metadata.document_id -> documents.id ON DELETE CASCADE` +- `client_metadata_schemas.client_id -> clients.clientId ON DELETE CASCADE` + +### Result + +- document hard-delete automatically removes custom metadata rows +- client hard-delete automatically removes client schema rows after client deletion +- `DeleteDocumentCascade` does not need custom-metadata-specific steps +- `client.HardDelete` does not need `DeleteClientMetadataSchemas` + +### Important correction + +`documents.custom_schema_id` does **not** need to be nulled before deleting the document row. + +The current plan says that delete must null `custom_schema_id` first because the FK to `client_metadata_schemas` would otherwise block the delete. That is incorrect. Deleting the document removes the referencing row; it does not require clearing the reference first. + +Keep `NullifyDocumentCustomSchemaId` only for the reset-metadata flow, where the document row survives. + +This is a meaningful regression-risk reduction because it avoids touching existing explicit delete code paths at all. + +--- + +### 5. Remove convenience-only fields from the first release + +The current plan adds several useful but non-essential response decorations: + +- `documentCount` +- `canDelete` +- `previousVersionId` +- `customSchemaName` on unrelated responses +- embedded `customMetadata` inside `GET /document/{id}` + +These fields are not required to satisfy the business rules. + +### Simpler rule + +For the first release: + +- schema list returns basic schema summary only +- delete attempts return `409` if the schema is still in use +- full metadata stays on `/custom-metadata`, not `GET /document/{id}` + +If usage hints are still wanted later, add them as a dedicated opt-in field or `includeUsage=true` expansion after the core system is stable. + +--- + +### 6. Do not modify `GET /document/{id}` in the core milestone + +The current code in `internal/document/get.go` and `api/queryAPI/documents.go` is intentionally narrow: it composes document basics, labels, and optionally legacy text-record data. + +Do not expand that path in the first pass. + +Reasons: + +- it is an existing stable read path +- the feature already has dedicated metadata endpoints +- adding full metadata enrichment multiplies controller, service, OpenAPI, and test work +- it increases regression risk in a widely used endpoint + +### Recommendation + +For the core release: + +- leave `GET /document/{id}` unchanged + +Optional later milestone: + +- add `customSchemaId` +- add `hasCustomMetadata` +- only add full `customMetadata` if there is a concrete consumer that needs it + +This keeps the first delivery focused on the new feature itself instead of modifying a stable cross-cutting read path. + +--- + +### 7. Defer bulk folder assignment until after the core feature is stable + +`POST /super-admin/folders/{folderId}/assign-schema` adds a large amount of complexity: + +- recursive tree traversal +- large-transaction behavior +- 10K-document guardrail +- skip-reason reporting +- mixed-state subtree handling +- many more integration tests + +It is not required to prove the mutable-metadata model. + +### Recommendation + +Move bulk folder assignment out of the core implementation and into a later milestone. + +Core assignment should be: + +- single-document only + +Then, if bulk assignment is still wanted after the single-document path is stable, add it separately with real operational evidence about folder sizes and expected admin workflows. + +This is one of the easiest ways to cut failure risk without weakening the main feature. + +--- + +### 8. Defer reset-metadata to a later additive milestone + +The reset endpoint is useful, but it is not part of the minimum path needed to deliver custom schemas and validated custom metadata. + +It is also currently coupled to unrelated work in the tracker through delete-cascade query reuse. + +### Recommendation + +Treat reset-metadata as an additive milestone after the core path is working: + +- schema create/list/get +- single-document assign +- metadata write/read current +- legacy guard +- schema update/version + +Only then add: + +- `POST /super-admin/documents/{id}/reset-metadata` + +If Q wants reset kept within the same overall initiative, that is still fine. The key simplification is sequencing: do not let reset block the first stable end-to-end slice. + +Also, once recommendation 4 is applied, reset no longer needs to depend on delete-path work. It stands on its own. + +--- + +### 9. Remove `createdBy` from all new request bodies + +The plan already says actor identity must come from auth context, not the request body. + +Because these are new endpoints, there is no backward-compatibility reason to keep misleading `createdBy` request fields in the contract. + +### Recommendation + +For all new mutable-metadata endpoints: + +- do not accept `createdBy` in the request schema +- derive actor from JWT / auth middleware +- return audit actor in responses if needed + +This simplifies: + +- OpenAPI +- handler code +- audit rules +- tests +- client expectations + +The only place `createdBy` should remain is existing legacy endpoints that already use it today. + +--- + +### 10. Replace schema-version `PUT` with an explicit version-creation `POST` + +The current plan uses: + +- `PUT /super-admin/custom-schemas/{schemaId}` + +But that endpoint does not replace the resource in place. It creates a new schema row with a new ID and supersedes the old one. + +That is creation semantics, not update-in-place semantics. + +### Simpler route + +Use: + +- `POST /super-admin/custom-schemas/{schemaId}/versions` + +### Why this is better + +- matches actual behavior +- avoids the Permit `PUT` action-mapping question entirely +- reduces auth configuration ambiguity +- makes code review and API docs clearer + +This preserves the requirement that schema updates create a new ID, while making the API easier to reason about. + +--- + +## Revised Minimal Design + +If I were simplifying the v3 design directly, I would target this shape: + +### Database + +- `client_metadata_schemas` + - keep immutable schema rows + - keep version numbers + - keep lifecycle state if desired +- `documents.custom_schema_id` + - add optional binding + - enforce same-client binding with composite FK +- `document_custom_metadata` + - `document_id`, `metadata`, `version`, audit fields + - no `schema_id` + - `ON DELETE CASCADE` +- DB trigger surface: + - keep only the "schema cannot change after metadata or legacy extraction exists" trigger + +### API in the first stable slice + +- `POST /super-admin/custom-schemas` +- `GET /super-admin/custom-schemas` +- `GET /super-admin/custom-schemas/{schemaId}` +- `PATCH /super-admin/documents/{id}/schema` +- `POST /custom-metadata` +- `GET /custom-metadata` + +### API deferred to later slices + +- schema version creation +- schema retirement +- metadata history/version endpoints +- reset-metadata +- bulk folder assignment +- `GET /document/{id}` enrichment + +This still reaches a real, usable feature early. + +--- + +## Revised Implementation Milestones + +The current tracking doc is organized as horizontal layers. That is the wrong shape for a risky feature. It encourages long periods where nothing is fully integrated. + +Use vertical slices instead. + +### Milestone 1: Schema CRUD foundation + +Goal: prove schema storage and validation end to end. + +Includes: + +- migrations for `client_metadata_schemas` +- `documents.custom_schema_id` +- composite FK for same-client binding +- schema validator +- `POST/GET/LIST /super-admin/custom-schemas` +- Permit `super-admin` resource update + +Exit proof: + +- super_admin can create and list schemas +- invalid schema rejected +- cross-client document binding cannot be represented in DB + +### Milestone 2: Core document flow + +Goal: prove the actual business workflow end to end. + +Includes: + +- `document_custom_metadata` table without `schema_id` +- single-document schema assignment +- `POST /custom-metadata` +- `GET /custom-metadata` +- legacy field-extraction guard plus controller `409` mapping + +Exit proof: + +1. create schema +2. assign schema to document +3. write valid metadata +4. reject invalid metadata +5. reject legacy field extraction write afterward + +At this point the feature already satisfies the central requirement. + +### Milestone 3: Administrative completion + +Goal: complete the admin lifecycle without expanding into optional convenience features. + +Includes: + +- schema version creation endpoint +- schema retirement/delete-if-unused behavior +- optional metadata history endpoints if they are required immediately + +Exit proof: + +- create v1 +- create v2 +- v1 no longer assignable +- v2 assignable +- in-use schema cannot be retired/deleted incorrectly + +### Milestone 4: Additive extras + +Only add these after Milestones 1-3 are green: + +- reset-metadata +- bulk folder assignment +- `GET /document/{id}` enrichment +- usage/count decorations in schema list responses + +These should not block the core mutable-metadata rollout. + +--- + +## How the Tracking Doc Should Change + +The tracking doc should be simplified in these specific ways: + +### Remove whole workstreams from the core path + +- remove Phase 5b if FK cascades are used correctly +- move Phase 9 behind the core milestones +- move Phase 10 behind the core milestones +- move Phase 10b behind the core milestones + +### Collapse redundant DB work + +- remove view-related tasks +- remove Trigger 3 tasks +- remove `schema_id`-related metadata tasks +- remove `GetSchemaMetadataRecordCount` tasks +- remove document-delete nullification tasks + +### Change phase ordering + +Do not wait until Phase 8 for the first real end-to-end flow. + +Instead, each milestone should contain: + +- migration/query work +- service work +- controller work +- at least one end-to-end integration test + +That is the right unit of verification for a change this invasive. + +--- + +## Bottom Line + +The current v3 plan is trying to solve the core problem plus several convenience and escape-hatch problems at the same time. That is where most of the risk is coming from. + +The safest simplification is: + +- remove redundant metadata-side schema state +- replace trigger logic with declarative constraints where possible +- let new FKs handle delete cleanup +- keep stable document read/delete paths unchanged in the first pass +- ship the feature in vertical slices +- defer bulk assignment, reset, and read-path enrichment until after the core path is proven + +If Q wants the shortest version of this advice: + +**Make the document row the single source of truth for schema binding, make the metadata table just versioned JSON for that document, and do not ship bulk/reset/document-enrichment in the first milestone.** diff --git a/plans/mutable.metadata.testing.review.codex.md b/plans/mutable.metadata.testing.review.codex.md new file mode 100644 index 00000000..c82db022 --- /dev/null +++ b/plans/mutable.metadata.testing.review.codex.md @@ -0,0 +1,79 @@ +# Mutable Metadata v4 Testing Review + +Reviewed: +- `plans/mutable.metadata.plan.combo.v4.md` +- `plans/mutable.metadata.plan.combo.v4.tracking.md` + +## Findings + +1. High: `GET /custom-metadata/version` and `GET /custom-metadata/history` are externally facing endpoints in the plan, but the tracking doc does not explicitly carry them into Milestone 2 integration coverage. +Plan evidence: +- `plans/mutable.metadata.plan.combo.v4.md:950-976` defines both endpoints. +- `plans/mutable.metadata.plan.combo.v4.md:1584` requires HTTP-level integration tests for all new `/custom-metadata/*` endpoints. +Tracking evidence: +- `plans/mutable.metadata.plan.combo.v4.tracking.md:339-340` adds handler-level TDD coverage only. +- `plans/mutable.metadata.plan.combo.v4.tracking.md:351-360` does not call out either endpoint in Milestone 2 integration tests. +- `plans/mutable.metadata.plan.combo.v4.tracking.md:356` references `/custom-metadata` generically, but does not explicitly track `/version` or `/history` route/method cells. +Why this matters: +- Milestone 2 can be marked complete without HTTP-level proof that version lookup, history pagination, and role gating work on those two routes. +Recommended tracking fix: +- Add explicit Milestone 2 integration tasks for: + - `GET /custom-metadata/version` happy path + - `GET /custom-metadata/history` happy path with pagination assertions + - authorization cells for both routes + +2. High: The `GET /document/{id}` enrichment is documented as a user-visible feature and explicitly called out for integration verification in the plan, but the tracking doc only carries it at unit/controller level. +Plan evidence: +- `plans/mutable.metadata.plan.combo.v4.md:686-708` defines the externally visible `customSchemaId` and `hasCustomMetadata` additions. +- `plans/mutable.metadata.plan.combo.v4.md:1530` says Milestone 2 integration tests should verify that `GET /document/{id}` reflects the new fields. +- `plans/mutable.metadata.plan.combo.v4.md:722` includes `GET /document/{id}` enrichment in the metadata-read authorization surface. +Tracking evidence: +- `plans/mutable.metadata.plan.combo.v4.tracking.md:342-346` covers builder/controller TDD only. +- `plans/mutable.metadata.plan.combo.v4.tracking.md:351-360` has no explicit Milestone 2 integration item for `GET /document/{id}`. +- `plans/mutable.metadata.plan.combo.v4.tracking.md:356` does not explicitly cover the existing `document` resource behavior for this modified endpoint. +Why this matters: +- The modified read path could regress, or be wired to the wrong resource behavior, without blocking milestone signoff. +Recommended tracking fix: +- Add explicit Milestone 2 integration tasks for: + - document with no schema -> `customSchemaId: null`, `hasCustomMetadata: false` + - document with schema but no metadata -> bound schema ID, `hasCustomMetadata: false` + - document with schema and metadata -> bound schema ID, `hasCustomMetadata: true` + - role coverage confirming `GET /document/{id}` remains on the existing `document` resource behavior + +3. High: The reset-metadata endpoint's documented HTTP contract is only partially tracked at integration level. +Plan evidence: +- `plans/mutable.metadata.plan.combo.v4.md:620-673` documents the endpoint, including `200`, `404`, and `409` behaviors. +- `plans/mutable.metadata.plan.combo.v4.md:1612-1619` explicitly requires integration coverage for the happy-path response assertions, legacy-extraction guard (`409`), and document-not-found (`404`). +Tracking evidence: +- `plans/mutable.metadata.plan.combo.v4.tracking.md:389-415` covers these branches at service/handler TDD level. +- `plans/mutable.metadata.plan.combo.v4.tracking.md:419-423` only tracks the upgrade flow, already-clean idempotency case, schema-version concurrency, auth matrix, and fullsuite gate. +Why this matters: +- Milestone 3 can be declared complete without HTTP-level verification of two documented error responses. +- The current happy-path integration item also under-specifies the response assertions the plan requires (`previousSchemaId`, `previousSchemaName`, `previousSchemaVersion`, `metadataVersionsDeleted`, and the post-upgrade `GET /document/{id}` check). +Recommended tracking fix: +- Add explicit Milestone 3 integration tasks for: + - reset on legacy-extraction document -> `409`, DB unchanged + - reset on non-existent document -> `404`, DB unchanged + - reset happy path asserts the documented response fields and verifies the follow-up `GET /document/{id}` state after rebind + +4. Medium: Two documented HTTP behaviors are tracked only below the integration layer: clearing a document schema binding with `customSchemaId: null`, and the server-derived actor identity contract on write endpoints. +Plan evidence: +- `plans/mutable.metadata.plan.combo.v4.md:571-576` documents `PATCH /super-admin/documents/{id}/schema` with `customSchemaId: null` removing the binding when no metadata exists. +- `plans/mutable.metadata.plan.combo.v4.md:867-869` and `plans/mutable.metadata.plan.combo.v4.md:1604` make actor identity a user-visible contract: request-body `createdBy` is ignored, stored/returned actor fields come from the JWT subject. +Tracking evidence: +- `plans/mutable.metadata.plan.combo.v4.tracking.md:293` tracks the null-unbind path at service TDD level, but `plans/mutable.metadata.plan.combo.v4.tracking.md:351-360` has no HTTP integration item for it. +- `plans/mutable.metadata.plan.combo.v4.tracking.md:194`, `plans/mutable.metadata.plan.combo.v4.tracking.md:336`, and `plans/mutable.metadata.plan.combo.v4.tracking.md:411` track actor handling in controller/handler tests, but no milestone integration section explicitly carries the contract. +Why this matters: +- The API could pass service/controller tests while still missing the actual HTTP request/response behavior promised to callers. +Recommended tracking fix: +- Add a Milestone 2 integration case for `PATCH /super-admin/documents/{id}/schema` with `{"customSchemaId": null}` on a pre-bound document with no metadata. +- Add integration cases for the write endpoints that expose actor fields in persisted data or responses, at minimum `POST /super-admin/custom-schemas`, `POST /super-admin/custom-schemas/{schemaId}/versions`, `POST /custom-metadata`, and `POST /super-admin/documents/{id}/reset-metadata`. + +## Verified + +- The tracking doc already encodes the fail-first TDD rule and the "do not mark done until tests pass" rule: + - `plans/mutable.metadata.plan.combo.v4.tracking.md:5` + - `plans/mutable.metadata.plan.combo.v4.tracking.md:26` + - `plans/mutable.metadata.plan.combo.v4.tracking.md:31` + - `plans/mutable.metadata.plan.combo.v4.tracking.md:508-510` +- I did not find conflicting language that would allow a checkbox or milestone to be marked complete before the associated tests are passing. diff --git a/scripts/manual_tests/test_custom_metadata.1.sh b/scripts/manual_tests/test_custom_metadata.1.sh new file mode 100755 index 00000000..ccf1f5f7 --- /dev/null +++ b/scripts/manual_tests/test_custom_metadata.1.sh @@ -0,0 +1,1080 @@ +#!/bin/bash + +# Manual integration test for the mutable custom metadata flow. +# +# Prerequisites: +# - Local stack running: task compose:refresh +# - Local stack using DISABLE_AUTH=true +# - Docker available (used to enable clientcansync in Postgres) +# +# Covered flow: +# - Create a fresh client +# - Upload a ZIP of valid PDFs under metadata.test/ +# - Create schema.test.1 with scalar, object, and typed-array coverage +# - Verify the schema via GET /super-admin/custom-schemas +# - Assign the schema to one uploaded document +# - Reject invalid metadata with the expected validation message +# - Accept valid metadata and verify the stored payload by GET /custom-metadata + +set -euo pipefail + +BASE_URL="${BASE_URL:-http://localhost:8080}" +TEST_USER_SUBJECT="${TEST_USER_SUBJECT:-manual-super-admin-001}" +TEST_USER_EMAIL="${TEST_USER_EMAIL:-${TEST_USER_SUBJECT}@test.local}" +CLIENT_ID="mt$(date +%s)" +CLIENT_NAME="Manual Metadata ${CLIENT_ID}" +SCHEMA_NAME="schema.test.1" +SCHEMA_DESCRIPTION="Manual supported-type coverage for custom metadata" +FOLDER_NAME="metadata.test" +TARGET_FILENAME="${FOLDER_NAME}/test_document.pdf" +TOTAL_DOCUMENTS=3 +MAX_PREVIEW_CHARS=700 + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +TMP_DIR="" +ZIP_FILE="" +BATCH_ID="" +DOCUMENT_ID="" +SCHEMA_ID="" +HTTP_CODE="" +RESPONSE_BODY="" +SECONDS=0 +VERBOSE=false + +TEST_HEADERS=( + -H "X-Test-User-Subject: ${TEST_USER_SUBJECT}" + -H "X-Test-User-Email: ${TEST_USER_EMAIL}" +) + +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +NC='\033[0m' + +log_info() { + echo -e "${GREEN}[INFO]${NC} $1" +} + +log_warn() { + echo -e "${YELLOW}[WARN]${NC} $1" +} + +log_error() { + echo -e "${RED}[ERROR]${NC} $1" +} + +log_step() { + echo -e "${BLUE}[STEP]${NC} $1" +} + +# truncate_preview compacts arbitrary text and caps the preview length so +# request/response logs stay readable during manual runs. +truncate_preview() { + local raw_text="${1:-}" + PREVIEW_TEXT="${raw_text}" python3 - "$MAX_PREVIEW_CHARS" <<'PY' +import os +import sys + +limit = int(sys.argv[1]) +text = os.environ.get("PREVIEW_TEXT", "").strip() +if not text: + print("") + raise SystemExit(0) + +compact = " ".join(text.split()) +if len(compact) > limit: + print(compact[:limit] + "... [truncated]") +else: + print(compact) +PY +} + +# pretty_print_json attempts to pretty-print a JSON string. Falls back to raw +# text when the input is not valid JSON (e.g. multipart previews). +pretty_print_json() { + local text="${1:-}" + if [ -z "${text}" ] || [ "${text}" = "" ]; then + echo "${text:-}" + return + fi + TEXT="${text}" python3 <<'PY' +import json +import os + +text = os.environ.get("TEXT", "") +try: + parsed = json.loads(text) + print(json.dumps(parsed, indent=2)) +except json.JSONDecodeError: + print(text) +PY +} + +# log_exchange prints one request/response pair. In verbose mode (-v) it shows +# the full URL, pretty-printed request body, and pretty-printed response body. +# In normal mode it truncates both to MAX_PREVIEW_CHARS. Params: +# 1. Human-readable label +# 2. HTTP method +# 3. Full URL +# 4. Request body text (or "") +# 5. HTTP status +# 6. Raw response body +log_exchange() { + local label="$1" + local method="$2" + local url="$3" + local request_preview="$4" + local status="$5" + local response_body="$6" + + echo "----- ${label} -----" + echo "Request: ${method} ${url}" + + if [ "${VERBOSE}" = "true" ]; then + local pretty_request pretty_response + pretty_request="$(pretty_print_json "${request_preview}")" + pretty_response="$(pretty_print_json "${response_body}")" + echo "Body:" + echo "${pretty_request}" + echo "Response: HTTP ${status}" + echo "${pretty_response}" + else + local truncated_request truncated_response + truncated_request="$(truncate_preview "$request_preview")" + truncated_response="$(truncate_preview "$response_body")" + echo "Input: ${truncated_request}" + echo "Response: HTTP ${status}" + echo "Output: ${truncated_response}" + fi +} + +# require_status checks the actual status against one or more expected +# values. Returns 0 on match, 1 otherwise. +require_status() { + local actual="$1" + shift + local expected + for expected in "$@"; do + if [ "$actual" = "$expected" ]; then + return 0 + fi + done + return 1 +} + +# run_json_request sends a JSON request, captures the body/status, logs the +# exchange, and fails if the status is not expected. +run_json_request() { + local label="$1" + local method="$2" + local path="$3" + local payload="$4" + shift 4 + local expected_statuses=("$@") + local url="${BASE_URL}${path}" + local response + + response="$(curl -sS -w "\n%{http_code}" -X "$method" "$url" \ + "${TEST_HEADERS[@]}" \ + -H "Content-Type: application/json" \ + --data "$payload")" + + HTTP_CODE="$(echo "$response" | tail -n1)" + RESPONSE_BODY="$(echo "$response" | sed '$d')" + + log_exchange "$label" "$method" "$url" "$payload" "$HTTP_CODE" "$RESPONSE_BODY" + + if ! require_status "$HTTP_CODE" "${expected_statuses[@]}"; then + log_error "${label} failed: expected HTTP ${expected_statuses[*]}, got HTTP ${HTTP_CODE}" + return 1 + fi +} + +# run_get_request sends a GET request, captures the body/status, logs the +# exchange, and fails if the status is not expected. +run_get_request() { + local label="$1" + local path="$2" + shift 2 + local expected_statuses=("$@") + local url="${BASE_URL}${path}" + local response + + response="$(curl -sS -w "\n%{http_code}" "$url" "${TEST_HEADERS[@]}")" + + HTTP_CODE="$(echo "$response" | tail -n1)" + RESPONSE_BODY="$(echo "$response" | sed '$d')" + + log_exchange "$label" "GET" "$url" "" "$HTTP_CODE" "$RESPONSE_BODY" + + if ! require_status "$HTTP_CODE" "${expected_statuses[@]}"; then + log_error "${label} failed: expected HTTP ${expected_statuses[*]}, got HTTP ${HTTP_CODE}" + return 1 + fi +} + +# run_metrics_check verifies the local API is reachable before the longer +# batch-processing steps begin. +run_metrics_check() { + local url="${BASE_URL}/metrics" + local body + + body="$(curl -sS "$url")" + log_exchange "Check service health" "GET" "$url" "" "200" "$body" +} + +# file_size_bytes returns the size of a file in bytes on macOS or Linux. +file_size_bytes() { + local path="$1" + stat -f%z "$path" 2>/dev/null || stat -c%s "$path" +} + +# cleanup removes temp artifacts created by this script. +cleanup() { + if [ -n "${TMP_DIR}" ] && [ -d "${TMP_DIR}" ]; then + rm -rf "${TMP_DIR}" + fi +} + +# require_commands fails fast when a required local tool is missing. +require_commands() { + local cmd + for cmd in curl python3 zip docker; do + if ! command -v "$cmd" >/dev/null 2>&1; then + log_error "Required command not found: ${cmd}" + exit 1 + fi + done +} + +# enable_client_sync mirrors the existing ZIP test script. It flips +# clientcansync so uploaded documents are allowed to reach clean_passed. +enable_client_sync() { + log_step "Enabling client sync for ${CLIENT_ID}" + + local existing + existing="$( + docker exec deployments-db-1 psql -U postgres -d query_orchestration -t -c \ + "SELECT COUNT(*) FROM clientcansync WHERE clientid='${CLIENT_ID}';" 2>/dev/null | tr -d ' ' + )" + + if [ "${existing}" = "0" ]; then + docker exec deployments-db-1 psql -U postgres -d query_orchestration -c \ + "INSERT INTO clientcansync (clientid, cansync) VALUES ('${CLIENT_ID}', true);" >/dev/null 2>&1 + else + docker exec deployments-db-1 psql -U postgres -d query_orchestration -c \ + "UPDATE clientcansync SET cansync = true WHERE clientid='${CLIENT_ID}';" >/dev/null 2>&1 + fi + + log_info "Client sync enabled" +} + +# create_pdf_zip builds a ZIP rooted at metadata.test/ using repo sample PDFs. +create_pdf_zip() { + log_step "Creating PDF ZIP payload" + + TMP_DIR="$(mktemp -d "${TMPDIR:-/tmp}/test_custom_metadata.XXXXXX")" + mkdir -p "${TMP_DIR}/${FOLDER_NAME}" + + cp "${SCRIPT_DIR}/test.pdf.batch/test.pdf" "${TMP_DIR}/${FOLDER_NAME}/test_document.pdf" + cp "${SCRIPT_DIR}/test.pdf.batch/DURABOOK.pdf" "${TMP_DIR}/${FOLDER_NAME}/reference_manual.pdf" + cp "${SCRIPT_DIR}/test.pdf.batch/georges.greek.pdf" "${TMP_DIR}/${FOLDER_NAME}/greek_sample.pdf" + + ZIP_FILE="${TMP_DIR}/custom_metadata_upload.zip" + ( + cd "${TMP_DIR}" + zip -q -r "${ZIP_FILE}" "${FOLDER_NAME}" + ) + + local file_size + file_size="$(file_size_bytes "${ZIP_FILE}")" + local zip_preview + zip_preview="$(ZIP_FILE="${ZIP_FILE}" python3 <<'PY' +import os +import zipfile + +zip_path = os.environ["ZIP_FILE"] +with zipfile.ZipFile(zip_path, "r") as zf: + names = zf.namelist() + +print(f"zip={zip_path}, bytes={os.path.getsize(zip_path)}, entries={names}") +PY +)" + + log_info "Created ZIP (${file_size} bytes)" + log_info "$(truncate_preview "${zip_preview}")" +} + +# build_schema_definition_json returns the JSON Schema definition used for +# schema.test.1. It covers supported scalar, object, and typed-array fields. +build_schema_definition_json() { + cat <<'JSON' +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "additionalProperties": false, + "required": [ + "document_title", + "status", + "review_date", + "created_timestamp", + "contact_email", + "reference_uri", + "external_uuid", + "page_count", + "confidence_score", + "is_verified", + "contact_info", + "tags", + "review_scores", + "priority_codes", + "check_flags", + "related_contacts" + ], + "properties": { + "document_title": { + "type": "string", + "minLength": 1, + "maxLength": 200 + }, + "status": { + "type": "string", + "enum": ["draft", "review", "approved", "rejected"] + }, + "review_date": { + "type": "string", + "format": "date" + }, + "created_timestamp": { + "type": "string", + "format": "date-time" + }, + "contact_email": { + "type": "string", + "format": "email" + }, + "reference_uri": { + "type": "string", + "format": "uri" + }, + "external_uuid": { + "type": "string", + "format": "uuid" + }, + "page_count": { + "type": "integer", + "minimum": 1, + "maximum": 10000 + }, + "confidence_score": { + "type": "number", + "minimum": 0, + "maximum": 1 + }, + "is_verified": { + "type": "boolean" + }, + "contact_info": { + "type": "object", + "additionalProperties": false, + "required": ["name", "email", "phone"], + "properties": { + "name": { + "type": "string", + "minLength": 1, + "maxLength": 200 + }, + "email": { + "type": "string", + "format": "email" + }, + "phone": { + "type": "string", + "pattern": "^\\+?[0-9\\-\\s()]+$" + } + } + }, + "tags": { + "type": "array", + "items": { + "type": "string", + "maxLength": 50 + }, + "minItems": 1, + "maxItems": 10 + }, + "review_scores": { + "type": "array", + "items": { + "type": "number", + "minimum": 0, + "maximum": 1 + }, + "minItems": 1, + "maxItems": 10 + }, + "priority_codes": { + "type": "array", + "items": { + "type": "integer", + "minimum": 1, + "maximum": 10 + }, + "minItems": 1, + "maxItems": 10 + }, + "check_flags": { + "type": "array", + "items": { + "type": "boolean" + }, + "minItems": 1, + "maxItems": 10 + }, + "related_contacts": { + "type": "array", + "minItems": 1, + "maxItems": 10, + "items": { + "type": "object", + "additionalProperties": false, + "required": ["name", "email", "phone"], + "properties": { + "name": { + "type": "string", + "minLength": 1, + "maxLength": 200 + }, + "email": { + "type": "string", + "format": "email" + }, + "phone": { + "type": "string", + "pattern": "^\\+?[0-9\\-\\s()]+$" + } + } + } + } + } +} +JSON +} + +# build_valid_metadata_json returns a metadata payload that should satisfy +# schema.test.1 exactly. +build_valid_metadata_json() { + cat <<'JSON' +{ + "document_title": "Custom metadata manual test document", + "status": "approved", + "review_date": "2026-04-15", + "created_timestamp": "2026-04-15T12:34:56Z", + "contact_email": "qa.metadata@example.com", + "reference_uri": "https://example.com/metadata/test", + "external_uuid": "123e4567-e89b-42d3-a456-426614174000", + "page_count": 12, + "confidence_score": 0.98, + "is_verified": true, + "contact_info": { + "name": "Q Metadata Reviewer", + "email": "reviewer@example.com", + "phone": "+1-555-0100" + }, + "tags": ["manual", "custom-metadata"], + "review_scores": [0.98, 0.93], + "priority_codes": [1, 3, 5], + "check_flags": [true, false, true], + "related_contacts": [ + { + "name": "Backup Reviewer", + "email": "backup@example.com", + "phone": "+1-555-0101" + } + ] +} +JSON +} + +# build_invalid_metadata_json returns a payload with multiple deliberate +# schema violations so the validation error is easy to inspect manually. +build_invalid_metadata_json() { + cat <<'JSON' +{ + "document_title": "", + "status": "not-a-real-status", + "review_date": "15-04-2026", + "created_timestamp": "not-a-timestamp", + "contact_email": "not-an-email", + "reference_uri": "not-a-uri", + "external_uuid": "not-a-uuid", + "page_count": "twelve", + "confidence_score": 2.5, + "is_verified": "yes", + "contact_info": { + "name": 123, + "email": "still-not-an-email", + "phone": false + }, + "tags": [1, 2], + "review_scores": ["high"], + "priority_codes": ["urgent"], + "check_flags": ["true"], + "related_contacts": [ + { + "name": 42, + "email": "bad", + "phone": true + } + ] +} +JSON +} + +# wrap_schema_payload embeds the schema definition into the create-schema request. +wrap_schema_payload() { + local schema_definition_json="$1" + SCHEMA_DEFINITION_JSON="${schema_definition_json}" \ + CLIENT_ID="${CLIENT_ID}" \ + SCHEMA_NAME="${SCHEMA_NAME}" \ + SCHEMA_DESCRIPTION="${SCHEMA_DESCRIPTION}" \ + python3 <<'PY' +import json +import os + +payload = { + "clientId": os.environ["CLIENT_ID"], + "name": os.environ["SCHEMA_NAME"], + "description": os.environ["SCHEMA_DESCRIPTION"], + "schema": json.loads(os.environ["SCHEMA_DEFINITION_JSON"]), +} +print(json.dumps(payload, separators=(",", ":"))) +PY +} + +# wrap_metadata_payload embeds a metadata object into the POST /custom-metadata request. +wrap_metadata_payload() { + local metadata_json="$1" + DOCUMENT_ID="${DOCUMENT_ID}" \ + METADATA_JSON="${metadata_json}" \ + python3 <<'PY' +import json +import os + +payload = { + "documentId": os.environ["DOCUMENT_ID"], + "metadata": json.loads(os.environ["METADATA_JSON"]), +} +print(json.dumps(payload, separators=(",", ":"))) +PY +} + +# extract_json_field prints one top-level field from the most recent response body. +extract_json_field() { + local field_name="$1" + RESPONSE_BODY="${RESPONSE_BODY}" FIELD_NAME="${field_name}" python3 <<'PY' +import json +import os + +data = json.loads(os.environ["RESPONSE_BODY"]) +value = data[os.environ["FIELD_NAME"]] +print(value) +PY +} + +# upload_zip_batch sends the generated archive to the batch-upload endpoint. +upload_zip_batch() { + log_step "Uploading ZIP batch" + + local url="${BASE_URL}/client/${CLIENT_ID}/document/batch" + local request_preview + local response + + request_preview="$( + ZIP_FILE="${ZIP_FILE}" python3 <<'PY' +import os +import zipfile + +zip_path = os.environ["ZIP_FILE"] +with zipfile.ZipFile(zip_path, "r") as zf: + names = zf.namelist() + +print(f"archive={zip_path}, bytes={os.path.getsize(zip_path)}, entries={names}") +PY + )" + + response="$(curl -sS -w "\n%{http_code}" -X POST "$url" \ + "${TEST_HEADERS[@]}" \ + -F "archive=@${ZIP_FILE}")" + + HTTP_CODE="$(echo "$response" | tail -n1)" + RESPONSE_BODY="$(echo "$response" | sed '$d')" + + log_exchange "Upload PDF ZIP batch" "POST" "$url" "$request_preview" "$HTTP_CODE" "$RESPONSE_BODY" + + if [ "$HTTP_CODE" != "202" ]; then + log_error "Upload batch failed: expected HTTP 202, got HTTP ${HTTP_CODE}" + return 1 + fi + + BATCH_ID="$(extract_json_field "batch_id")" + if [ -z "${BATCH_ID}" ]; then + log_error "Batch upload response did not include batch_id" + return 1 + fi + + log_info "Batch created: ${BATCH_ID}" +} + +# wait_for_batch_completion polls until the batch reaches completed and all +# documents settle into clean_passed. +wait_for_batch_completion() { + log_step "Waiting for batch completion" + + local max_wait=240 + local interval=5 + local elapsed=0 + local status="" + local last_progress="" + + while [ "${elapsed}" -lt "${max_wait}" ]; do + local response + response="$(curl -sS -w "\n%{http_code}" \ + "${BASE_URL}/client/${CLIENT_ID}/document/batch/${BATCH_ID}" \ + "${TEST_HEADERS[@]}")" + + local http_code + local body + http_code="$(echo "$response" | tail -n1)" + body="$(echo "$response" | sed '$d')" + + if [ "${http_code}" != "200" ]; then + log_exchange "Poll batch status" "GET" "${BASE_URL}/client/${CLIENT_ID}/document/batch/${BATCH_ID}" "" "${http_code}" "${body}" + log_error "Batch status poll failed" + return 1 + fi + + local current + current="$( + BATCH_BODY="${body}" python3 <<'PY' +import json +import os + +data = json.loads(os.environ["BATCH_BODY"]) +outcomes = data.get("document_outcomes", []) +clean_passed = sum(1 for item in outcomes if item.get("outcome") == "clean_passed") +print(f"{data.get('status','unknown')}|{clean_passed}/{len(outcomes)}") +PY + )" + + status="${current%%|*}" + local progress="${current#*|}" + + if [ "${progress}" != "${last_progress}" ]; then + log_info "Batch progress: status=${status}, clean_passed=${progress}, elapsed=${elapsed}s" + last_progress="${progress}" + fi + + if [ "${status}" = "completed" ]; then + HTTP_CODE="200" + RESPONSE_BODY="${body}" + log_exchange "Fetch final batch status" "GET" "${BASE_URL}/client/${CLIENT_ID}/document/batch/${BATCH_ID}" "" "${HTTP_CODE}" "${RESPONSE_BODY}" + return 0 + fi + + sleep "${interval}" + elapsed=$((elapsed + interval)) + done + + log_error "Timed out waiting for batch completion" + return 1 +} + +# assert_batch_success_and_capture_document validates the final batch +# status and records the document_id for metadata.test/test_document.pdf. +assert_batch_success_and_capture_document() { + DOCUMENT_ID="$( + RESPONSE_BODY="${RESPONSE_BODY}" \ + TARGET_FILENAME="${TARGET_FILENAME}" \ + TOTAL_DOCUMENTS="${TOTAL_DOCUMENTS}" \ + python3 <<'PY' +import json +import os +import sys + +data = json.loads(os.environ["RESPONSE_BODY"]) +target = os.environ["TARGET_FILENAME"] +expected_total = int(os.environ["TOTAL_DOCUMENTS"]) + +errors = [] +if data.get("status") != "completed": + errors.append(f"status={data.get('status')!r}, expected 'completed'") +if data.get("total_documents") != expected_total: + errors.append( + f"total_documents={data.get('total_documents')!r}, expected {expected_total}" + ) +outcomes = data.get("document_outcomes", []) +if len(outcomes) != expected_total: + errors.append(f"document_outcomes length={len(outcomes)}, expected {expected_total}") + +target_doc_id = "" +for outcome in outcomes: + if outcome.get("outcome") != "clean_passed": + errors.append( + f"{outcome.get('filename')}: outcome={outcome.get('outcome')}, " + f"detail={outcome.get('error_detail') or outcome.get('clean_fail_reason') or 'none'}" + ) + if outcome.get("filename") == target: + target_doc_id = outcome.get("document_id") or "" + +if not target_doc_id: + errors.append(f"target document_id missing for {target}") + +if errors: + for error in errors: + print(error, file=sys.stderr) + sys.exit(1) + +print(target_doc_id) +PY + )" + + if [ -z "${DOCUMENT_ID}" ]; then + log_error "Target document_id was not captured from batch status" + return 1 + fi + + log_info "Selected document for schema assignment: ${DOCUMENT_ID}" +} + +# assert_created_schema_response verifies the create-schema response shape. +assert_created_schema_response() { + RESPONSE_BODY="${RESPONSE_BODY}" \ + CLIENT_ID="${CLIENT_ID}" \ + SCHEMA_NAME="${SCHEMA_NAME}" \ + TEST_USER_SUBJECT="${TEST_USER_SUBJECT}" \ + python3 <<'PY' +import json +import os +import sys + +resp = json.loads(os.environ["RESPONSE_BODY"]) +errors = [] + +if resp.get("clientId") != os.environ["CLIENT_ID"]: + errors.append("clientId mismatch") +if resp.get("name") != os.environ["SCHEMA_NAME"]: + errors.append("schema name mismatch") +if resp.get("status") != "active": + errors.append(f"status={resp.get('status')!r}, expected 'active'") +if resp.get("version") != 1: + errors.append(f"version={resp.get('version')!r}, expected 1") +if resp.get("createdBy") != os.environ["TEST_USER_SUBJECT"]: + errors.append("createdBy mismatch") + +if errors: + for error in errors: + print(error, file=sys.stderr) + sys.exit(1) +PY +} + +# assert_schema_list verifies that GET /super-admin/custom-schemas returns +# schema.test.1 with the created schema id and active version metadata. +assert_schema_list() { + RESPONSE_BODY="${RESPONSE_BODY}" \ + CLIENT_ID="${CLIENT_ID}" \ + SCHEMA_NAME="${SCHEMA_NAME}" \ + SCHEMA_ID="${SCHEMA_ID}" \ + TEST_USER_SUBJECT="${TEST_USER_SUBJECT}" \ + python3 <<'PY' +import json +import os +import sys + +resp = json.loads(os.environ["RESPONSE_BODY"]) +schemas = resp.get("schemas", []) +errors = [] + +matches = [ + item for item in schemas + if item.get("id") == os.environ["SCHEMA_ID"] +] + +if len(matches) != 1: + errors.append(f"expected exactly one schema row for id={os.environ['SCHEMA_ID']}, got {len(matches)}") +else: + row = matches[0] + if row.get("clientId") != os.environ["CLIENT_ID"]: + errors.append("list clientId mismatch") + if row.get("name") != os.environ["SCHEMA_NAME"]: + errors.append("list schema name mismatch") + if row.get("status") != "active": + errors.append(f"list status={row.get('status')!r}, expected 'active'") + if row.get("version") != 1: + errors.append(f"list version={row.get('version')!r}, expected 1") + if row.get("createdBy") != os.environ["TEST_USER_SUBJECT"]: + errors.append("list createdBy mismatch") + +if errors: + for error in errors: + print(error, file=sys.stderr) + sys.exit(1) +PY +} + +# assert_schema_assignment checks the PATCH /schema response. +assert_schema_assignment() { + RESPONSE_BODY="${RESPONSE_BODY}" \ + DOCUMENT_ID="${DOCUMENT_ID}" \ + SCHEMA_ID="${SCHEMA_ID}" \ + SCHEMA_NAME="${SCHEMA_NAME}" \ + python3 <<'PY' +import json +import os +import sys + +resp = json.loads(os.environ["RESPONSE_BODY"]) +errors = [] + +if resp.get("documentId") != os.environ["DOCUMENT_ID"]: + errors.append("documentId mismatch") +if resp.get("customSchemaId") != os.environ["SCHEMA_ID"]: + errors.append("customSchemaId mismatch") +if resp.get("schemaName") != os.environ["SCHEMA_NAME"]: + errors.append("schemaName mismatch") +if resp.get("schemaVersion") != 1: + errors.append(f"schemaVersion={resp.get('schemaVersion')!r}, expected 1") + +if errors: + for error in errors: + print(error, file=sys.stderr) + sys.exit(1) +PY +} + +# assert_invalid_metadata_error checks that the server returned a 400 and +# surfaced the metadata validation failure rather than accepting the write. +assert_invalid_metadata_error() { + RESPONSE_BODY="${RESPONSE_BODY}" python3 <<'PY' +import json +import os +import sys + +body = os.environ["RESPONSE_BODY"] +message = body +try: + parsed = json.loads(body) + if isinstance(parsed, dict) and "message" in parsed: + message = str(parsed["message"]) +except json.JSONDecodeError: + pass + +lower = message.lower() +required = ["validate metadata", "metadata does not conform to schema"] +missing = [fragment for fragment in required if fragment not in lower] +if missing: + print( + "invalid metadata response did not include required validation fragments: " + + ", ".join(missing), + file=sys.stderr, + ) + sys.exit(1) +PY +} + +# assert_valid_metadata_response confirms the accepted metadata response +# contains the expected schema/document/version fields. +assert_valid_metadata_response() { + RESPONSE_BODY="${RESPONSE_BODY}" \ + VALID_METADATA_JSON="${VALID_METADATA_JSON}" \ + DOCUMENT_ID="${DOCUMENT_ID}" \ + SCHEMA_ID="${SCHEMA_ID}" \ + SCHEMA_NAME="${SCHEMA_NAME}" \ + TEST_USER_SUBJECT="${TEST_USER_SUBJECT}" \ + python3 <<'PY' +import json +import os +import sys + +resp = json.loads(os.environ["RESPONSE_BODY"]) +expected = json.loads(os.environ["VALID_METADATA_JSON"]) +errors = [] + +if resp.get("documentId") != os.environ["DOCUMENT_ID"]: + errors.append("POST metadata documentId mismatch") +if resp.get("schemaId") != os.environ["SCHEMA_ID"]: + errors.append("POST metadata schemaId mismatch") +if resp.get("schemaName") != os.environ["SCHEMA_NAME"]: + errors.append("POST metadata schemaName mismatch") +if resp.get("schemaVersion") != 1: + errors.append(f"POST metadata schemaVersion={resp.get('schemaVersion')!r}, expected 1") +if resp.get("version") != 1: + errors.append(f"POST metadata version={resp.get('version')!r}, expected 1") +if resp.get("createdBy") != os.environ["TEST_USER_SUBJECT"]: + errors.append("POST metadata createdBy mismatch") +if resp.get("metadata") != expected: + errors.append("POST metadata payload mismatch") + +if errors: + for error in errors: + print(error, file=sys.stderr) + sys.exit(1) +PY +} + +# assert_retrieved_metadata_matches compares GET /custom-metadata to the +# original valid payload to prove the stored JSON round-trips unchanged. +assert_retrieved_metadata_matches() { + RESPONSE_BODY="${RESPONSE_BODY}" \ + VALID_METADATA_JSON="${VALID_METADATA_JSON}" \ + DOCUMENT_ID="${DOCUMENT_ID}" \ + SCHEMA_ID="${SCHEMA_ID}" \ + SCHEMA_NAME="${SCHEMA_NAME}" \ + TEST_USER_SUBJECT="${TEST_USER_SUBJECT}" \ + python3 <<'PY' +import json +import os +import sys + +resp = json.loads(os.environ["RESPONSE_BODY"]) +expected = json.loads(os.environ["VALID_METADATA_JSON"]) +errors = [] + +if resp.get("documentId") != os.environ["DOCUMENT_ID"]: + errors.append("GET metadata documentId mismatch") +if resp.get("schemaId") != os.environ["SCHEMA_ID"]: + errors.append("GET metadata schemaId mismatch") +if resp.get("schemaName") != os.environ["SCHEMA_NAME"]: + errors.append("GET metadata schemaName mismatch") +if resp.get("schemaVersion") != 1: + errors.append(f"GET metadata schemaVersion={resp.get('schemaVersion')!r}, expected 1") +if resp.get("version") != 1: + errors.append(f"GET metadata version={resp.get('version')!r}, expected 1") +if resp.get("createdBy") != os.environ["TEST_USER_SUBJECT"]: + errors.append("GET metadata createdBy mismatch") +if resp.get("metadata") != expected: + errors.append("retrieved metadata does not match source metadata") + +if errors: + for error in errors: + print(error, file=sys.stderr) + sys.exit(1) +PY +} + +# parse_args processes command-line flags. Recognized flags: +# -v | --verbose enable verbose request/response logging +parse_args() { + for arg in "$@"; do + case "$arg" in + -v|--verbose) + VERBOSE=true + ;; + *) + log_error "Unknown argument: ${arg}" + exit 1 + ;; + esac + done +} + +main() { + parse_args "$@" + trap cleanup EXIT + + log_step "Starting custom metadata integration test" + log_info "Base URL: ${BASE_URL}" + log_info "Client ID: ${CLIENT_ID}" + log_info "Schema name: ${SCHEMA_NAME}" + log_info "Injected test subject: ${TEST_USER_SUBJECT}" + + require_commands + run_metrics_check + create_pdf_zip + + local create_client_payload + create_client_payload="$(CLIENT_ID="${CLIENT_ID}" CLIENT_NAME="${CLIENT_NAME}" python3 <<'PY' +import json +import os + +payload = { + "id": os.environ["CLIENT_ID"], + "name": os.environ["CLIENT_NAME"], +} +print(json.dumps(payload, separators=(",", ":"))) +PY +)" + run_json_request "Create fresh client" "POST" "/client" "${create_client_payload}" "201" + enable_client_sync + + upload_zip_batch + wait_for_batch_completion + assert_batch_success_and_capture_document + + local schema_definition_json + schema_definition_json="$(build_schema_definition_json)" + local create_schema_payload + create_schema_payload="$(wrap_schema_payload "${schema_definition_json}")" + run_json_request "Create custom schema" "POST" "/super-admin/custom-schemas" "${create_schema_payload}" "201" + assert_created_schema_response + SCHEMA_ID="$(extract_json_field "id")" + log_info "Created schema: ${SCHEMA_ID}" + + run_get_request \ + "List custom schemas" \ + "/super-admin/custom-schemas?clientId=${CLIENT_ID}&name=${SCHEMA_NAME}&includeAllVersions=true&status=any" \ + "200" + assert_schema_list + + local assign_payload + assign_payload="$(SCHEMA_ID="${SCHEMA_ID}" python3 <<'PY' +import json +import os + +payload = {"customSchemaId": os.environ["SCHEMA_ID"]} +print(json.dumps(payload, separators=(",", ":"))) +PY +)" + run_json_request \ + "Assign schema to uploaded document" \ + "PATCH" \ + "/super-admin/documents/${DOCUMENT_ID}/schema" \ + "${assign_payload}" \ + "200" + assert_schema_assignment + + local invalid_metadata_json + invalid_metadata_json="$(build_invalid_metadata_json)" + local invalid_metadata_payload + invalid_metadata_payload="$(wrap_metadata_payload "${invalid_metadata_json}")" + run_json_request \ + "Reject invalid custom metadata" \ + "POST" \ + "/custom-metadata" \ + "${invalid_metadata_payload}" \ + "400" + assert_invalid_metadata_error + + VALID_METADATA_JSON="$(build_valid_metadata_json)" + local valid_metadata_payload + valid_metadata_payload="$(wrap_metadata_payload "${VALID_METADATA_JSON}")" + run_json_request \ + "Accept valid custom metadata" \ + "POST" \ + "/custom-metadata" \ + "${valid_metadata_payload}" \ + "201" + assert_valid_metadata_response + + run_get_request \ + "Retrieve current custom metadata" \ + "/custom-metadata?documentId=${DOCUMENT_ID}" \ + "200" + assert_retrieved_metadata_matches + + log_info "TEST PASSED: custom metadata integration flow completed successfully in ${SECONDS}s" +} + +main "$@" diff --git a/scripts/manual_tests/test_zip_all_types_upload.sh b/scripts/manual_tests/test_zip_all_types_upload.sh index feebf8d3..1f9fa955 100755 --- a/scripts/manual_tests/test_zip_all_types_upload.sh +++ b/scripts/manual_tests/test_zip_all_types_upload.sh @@ -27,6 +27,12 @@ TEMP_DIR="temp_all_types_structure" TOTAL_VALID=10 TOTAL_INVALID=10 TOTAL_FILES=$((TOTAL_VALID + TOTAL_INVALID)) +VERBOSE=0 + +# Path to the valid PDF inside the generated test set. The same file is hashed +# before upload and again after download to prove the bytes are unchanged. +VALID_PDF_REL_PATH="valid/test_document.pdf" +ORIGINAL_PDF_SHA256="" # --- Terminal outcome constants --- TERMINAL_OUTCOMES="duplicate init_duplicate failed_open failed_read failed_s3_upload failed_upload invalid_type clean_passed clean_failed sync_skipped" @@ -55,6 +61,42 @@ log_step() { echo -e "${BLUE}[STEP]${NC} $1" } +usage() { + cat <- + Creates a new client-scoped JSON Schema used to validate custom + document metadata. Restricted to the super_admin role. + security: + - jwtAuth: [] + requestBody: + description: The schema to create + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/CustomSchemaRequest" + responses: + "201": + description: Schema created successfully. + headers: + RateLimit: + $ref: "#/components/headers/RateLimit" + content: + application/json: + schema: + $ref: "#/components/schemas/CustomSchemaResponse" + "400": + $ref: "#/components/responses/InvalidRequest" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + "409": + $ref: "#/components/responses/Conflict" + "429": + $ref: "#/components/responses/TooManyRequests" + "500": + $ref: "#/components/responses/InternalError" + get: + operationId: listCustomSchemas + tags: + - SuperAdminSchemaService + summary: List custom metadata schemas for a client + description: >- + Lists client-scoped JSON Schemas. Supports filtering by name, status + and optional inclusion of all historical versions. Restricted to the + super_admin role. + security: + - jwtAuth: [] + parameters: + - name: clientId + in: query + description: The client whose schemas to list. + required: true + schema: + type: string + maxLength: 36 + - name: name + in: query + description: Filter by schema name (exact match). + required: false + schema: + type: string + maxLength: 255 + - name: status + in: query + description: >- + Filter by lifecycle status. The sentinel `any` returns rows in + all statuses. Defaults to `active` when omitted. + required: false + schema: + type: string + enum: + - active + - superseded + - retired + - any + - name: includeAllVersions + in: query + description: >- + When false, return only the latest version per (clientId, name) + lineage. When true, return every version that matches the other + filters. + required: false + schema: + type: boolean + default: false + - name: limit + in: query + description: Maximum number of results to return. + required: false + schema: + type: integer + format: int32 + minimum: 1 + maximum: 200 + default: 50 + - name: offset + in: query + description: Number of results to skip for pagination. + required: false + schema: + type: integer + format: int32 + minimum: 0 + maximum: 2147483647 + default: 0 + responses: + "200": + description: Schema list retrieved successfully. + headers: + RateLimit: + $ref: "#/components/headers/RateLimit" + content: + application/json: + schema: + $ref: "#/components/schemas/CustomSchemaListResponse" + "400": + $ref: "#/components/responses/InvalidRequest" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + "429": + $ref: "#/components/responses/TooManyRequests" + "500": + $ref: "#/components/responses/InternalError" + + /super-admin/custom-schemas/{schemaId}: + parameters: + - name: schemaId + in: path + required: true + schema: + type: string + format: uuid + maxLength: 36 + description: The custom metadata schema ID. + get: + operationId: getCustomSchema + tags: + - SuperAdminSchemaService + summary: Get a custom metadata schema by ID + description: >- + Returns the full JSON Schema document plus lifecycle metadata for + the specified schema row. Restricted to the super_admin role. + security: + - jwtAuth: [] + responses: + "200": + description: Schema retrieved successfully. + headers: + RateLimit: + $ref: "#/components/headers/RateLimit" + content: + application/json: + schema: + $ref: "#/components/schemas/CustomSchemaResponse" + "400": + $ref: "#/components/responses/InvalidRequest" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + "404": + $ref: "#/components/responses/NotFound" + "429": + $ref: "#/components/responses/TooManyRequests" + "500": + $ref: "#/components/responses/InternalError" + delete: + operationId: deleteCustomSchema + tags: + - SuperAdminSchemaService + summary: Retire a custom metadata schema + description: >- + Marks the schema as retired. Fails with 409 Conflict if any document + still references the schema. Restricted to the super_admin role. + security: + - jwtAuth: [] + responses: + "204": + description: Schema retired successfully. + headers: + RateLimit: + $ref: "#/components/headers/RateLimit" + "400": + $ref: "#/components/responses/InvalidRequest" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + "404": + $ref: "#/components/responses/NotFound" + "409": + $ref: "#/components/responses/Conflict" + "429": + $ref: "#/components/responses/TooManyRequests" + "500": + $ref: "#/components/responses/InternalError" + + /super-admin/custom-schemas/{schemaId}/versions: + parameters: + - name: schemaId + in: path + required: true + schema: + type: string + format: uuid + maxLength: 36 + description: The parent custom metadata schema ID whose lineage is being extended. + post: + operationId: createCustomSchemaVersion + tags: + - SuperAdminSchemaService + summary: Create a new version of a custom metadata schema + description: >- + Creates a new schema row with a new ID that inherits the parent + schema's clientId and name. The parent row must currently have + status=active; it is marked superseded in the same transaction. + Restricted to the super_admin role. + security: + - jwtAuth: [] + requestBody: + description: The new schema version payload. + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/CustomSchemaVersionRequest" + responses: + "201": + description: Schema version created successfully. + headers: + RateLimit: + $ref: "#/components/headers/RateLimit" + content: + application/json: + schema: + $ref: "#/components/schemas/CustomSchemaResponse" + "400": + $ref: "#/components/responses/InvalidRequest" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + "404": + $ref: "#/components/responses/NotFound" + "409": + $ref: "#/components/responses/Conflict" + "429": + $ref: "#/components/responses/TooManyRequests" + "500": + $ref: "#/components/responses/InternalError" + + /super-admin/documents/{id}/schema: + parameters: + - name: id + in: path + required: true + schema: + type: string + format: uuid + maxLength: 36 + description: The document ID whose schema binding is being mutated. + patch: + operationId: assignDocumentSchema + tags: + - SuperAdminSchemaService + summary: Assign (or clear) a custom schema for a document + description: >- + Binds a document to a client_metadata_schemas row, or clears the + binding when customSchemaId is null. The service layer enforces + every invariant from plan Section 7.3 before the UPDATE: document + must have no existing custom metadata, no legacy field extractions, + and the schema must belong to the same client. Restricted to the + super_admin role. + security: + - jwtAuth: [] + requestBody: + description: The new schema binding. + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/DocumentSchemaAssignRequest" + responses: + "200": + description: Binding updated. + headers: + RateLimit: + $ref: "#/components/headers/RateLimit" + content: + application/json: + schema: + $ref: "#/components/schemas/DocumentSchemaAssignResponse" + "400": + $ref: "#/components/responses/InvalidRequest" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + "404": + $ref: "#/components/responses/NotFound" + "409": + $ref: "#/components/responses/Conflict" + "429": + $ref: "#/components/responses/TooManyRequests" + "500": + $ref: "#/components/responses/InternalError" + + /super-admin/documents/{id}/reset-metadata: + parameters: + - name: id + in: path + required: true + schema: + type: string + format: uuid + maxLength: 36 + description: The document ID whose custom metadata is being reset. + post: + operationId: resetDocumentMetadata + tags: + - SuperAdminSchemaService + summary: Reset a document's custom metadata and schema binding + description: >- + Atomically deletes every document_custom_metadata row for the + document and nullifies documents.custom_schema_id in a single + transaction. Returns previous schema binding details and the + count of deleted metadata versions. Idempotent: already-clean + documents return 200 with metadataVersionsDeleted set to 0. + Rejects documents carrying legacy field extractions with 409. + Restricted to the super_admin role. + security: + - jwtAuth: [] + responses: + "200": + description: Reset completed. + headers: + RateLimit: + $ref: "#/components/headers/RateLimit" + content: + application/json: + schema: + $ref: "#/components/schemas/DocumentMetadataResetResponse" + "400": + $ref: "#/components/responses/InvalidRequest" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + "404": + $ref: "#/components/responses/NotFound" + "409": + $ref: "#/components/responses/Conflict" + "429": + $ref: "#/components/responses/TooManyRequests" + "500": + $ref: "#/components/responses/InternalError" + + /custom-metadata: + get: + operationId: getCurrentCustomMetadata + tags: + - CustomMetadataService + summary: Get the current custom metadata row for a document + description: >- + Returns the most recent document_custom_metadata row for the + specified document, decorated with the bound schema id, name, and + version. + security: + - jwtAuth: [] + parameters: + - name: documentId + in: query + required: true + description: The document whose current metadata is being requested. + schema: + type: string + format: uuid + maxLength: 36 + responses: + "200": + description: Latest metadata row. + headers: + RateLimit: + $ref: "#/components/headers/RateLimit" + content: + application/json: + schema: + $ref: "#/components/schemas/CustomMetadataResponse" + "400": + $ref: "#/components/responses/InvalidRequest" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + "404": + $ref: "#/components/responses/NotFound" + "429": + $ref: "#/components/responses/TooManyRequests" + "500": + $ref: "#/components/responses/InternalError" + post: + operationId: setCustomMetadata + tags: + - CustomMetadataService + summary: Write a new custom metadata version for a document + description: >- + Validates the supplied metadata payload against the document's + bound schema and appends a new version row. The actor is derived + from the JWT sub claim; the request body has no createdBy field. + security: + - jwtAuth: [] + requestBody: + description: Metadata payload + the target document id. + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/CustomMetadataRequest" + responses: + "201": + description: Metadata version written. + headers: + RateLimit: + $ref: "#/components/headers/RateLimit" + content: + application/json: + schema: + $ref: "#/components/schemas/CustomMetadataResponse" + "400": + $ref: "#/components/responses/InvalidRequest" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + "404": + $ref: "#/components/responses/NotFound" + "409": + $ref: "#/components/responses/Conflict" + "429": + $ref: "#/components/responses/TooManyRequests" + "500": + $ref: "#/components/responses/InternalError" + + /custom-metadata/version: + get: + operationId: getCustomMetadataByVersion + tags: + - CustomMetadataService + summary: Get a specific historical metadata version + description: >- + Returns the metadata row whose version matches the query parameter, + decorated with schema id/name/version. Returns 404 when the row + does not exist and 400 when version is less than 1. + security: + - jwtAuth: [] + parameters: + - name: documentId + in: query + required: true + description: The document whose historical metadata version is being requested. + schema: + type: string + format: uuid + maxLength: 36 + - name: version + in: query + required: true + description: The metadata version number (>= 1) to return. + schema: + type: integer + format: int32 + minimum: 1 + maximum: 2147483647 + responses: + "200": + description: Metadata row at the requested version. + headers: + RateLimit: + $ref: "#/components/headers/RateLimit" + content: + application/json: + schema: + $ref: "#/components/schemas/CustomMetadataResponse" + "400": + $ref: "#/components/responses/InvalidRequest" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + "404": + $ref: "#/components/responses/NotFound" + "429": + $ref: "#/components/responses/TooManyRequests" + "500": + $ref: "#/components/responses/InternalError" + + /custom-metadata/history: + get: + operationId: getCustomMetadataHistory + tags: + - CustomMetadataService + summary: List version summaries for a document's metadata + description: >- + Returns a paged list of (version, createdAt, createdBy) tuples in + descending version order. limit must be in [1, 200]; values above + 200 are clamped. offset must be >= 0. + security: + - jwtAuth: [] + parameters: + - name: documentId + in: query + required: true + description: The document whose metadata history is being requested. + schema: + type: string + format: uuid + maxLength: 36 + - name: limit + in: query + required: false + description: Maximum number of version summaries to return per page (1-200, default 50). + schema: + type: integer + format: int32 + minimum: 1 + maximum: 200 + default: 50 + - name: offset + in: query + required: false + description: Number of version summaries to skip before the page starts (default 0). + schema: + type: integer + format: int32 + minimum: 0 + maximum: 2147483647 + default: 0 + responses: + "200": + description: Version summaries in descending version order. + headers: + RateLimit: + $ref: "#/components/headers/RateLimit" + content: + application/json: + schema: + $ref: "#/components/schemas/CustomMetadataHistoryResponse" + "400": + $ref: "#/components/responses/InvalidRequest" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + "404": + $ref: "#/components/responses/NotFound" + "429": + $ref: "#/components/responses/TooManyRequests" + "500": + $ref: "#/components/responses/InternalError" + components: parameters: ClientID: @@ -3312,6 +3891,23 @@ components: minimum: 0 maximum: 4294967295 description: "File size in bytes (null for legacy documents)" + customSchemaId: + type: string + format: uuid + maxLength: 36 + description: >- + Mutable-metadata feature: the client_metadata_schemas row id + this document is currently bound to, or null when the document + has not been opted in to the custom schema system. A document + with a non-null customSchemaId rejects legacy field extraction + writes. + hasCustomMetadata: + type: boolean + description: >- + True iff at least one document_custom_metadata row exists for + this document. A document can have customSchemaId set but + hasCustomMetadata false (schema bound, no metadata written + yet). DocumentInFolder: description: Document within a folder with enriched metadata (lighter format, excludes client_id and fields) @@ -3357,6 +3953,445 @@ components: maximum: 4294967295 description: "File size in bytes (null for legacy documents)" + SchemaStatus: + type: string + enum: + - active + - superseded + - retired + description: | + Lifecycle state of a client_metadata_schemas row. `active` is + the version currently assignable to new documents. `superseded` + is automatically set when a newer version of the same (clientId, + name) lineage is created via POST /versions. `retired` is set + when a super_admin explicitly deletes an unused schema. + + CustomSchemaRequest: + description: >- + Request body for creating a new custom metadata schema. Does not + include a createdBy field; the handler derives the actor from the + authenticated JWT subject. + type: object + required: + - clientId + - name + - schema + properties: + clientId: + type: string + minLength: 1 + maxLength: 36 + description: The client that owns this schema. + name: + type: string + minLength: 1 + maxLength: 255 + description: Unique schema name within the client lineage. + description: + type: string + maxLength: 1024 + description: Optional human-readable description of the schema. + schema: + type: object + additionalProperties: true + maxProperties: 1024 + description: >- + The full JSON Schema document (Draft 2020-12) used to validate + custom document metadata. Must declare root type=object and + must explicitly set additionalProperties. The maxProperties cap + is a defense-in-depth limit against malformed schema payloads; + the byte-level cap (65 536) lives in the customschema validator. + + CustomSchemaVersionRequest: + description: >- + Request body for creating a new version of an existing custom + metadata schema. The parent schema row supplies clientId and name; + the actor is derived from the authenticated JWT subject. + type: object + required: + - schema + properties: + description: + type: string + maxLength: 1024 + description: Optional human-readable description of this new version. + schema: + type: object + additionalProperties: true + maxProperties: 1024 + description: >- + The full JSON Schema document (Draft 2020-12) for the new + version. Same validation rules as CustomSchemaRequest.schema. + + CustomSchemaResponse: + description: >- + Full custom metadata schema row including the JSON Schema document + and lifecycle metadata. + type: object + required: + - id + - clientId + - name + - schema + - version + - status + - documentCount + - canDelete + - createdAt + - createdBy + properties: + id: + type: string + format: uuid + maxLength: 36 + description: The schema row ID. + clientId: + type: string + minLength: 1 + maxLength: 36 + description: The client that owns this schema. + name: + type: string + minLength: 1 + maxLength: 255 + description: The schema name (shared across versions in a lineage). + description: + type: string + maxLength: 1024 + description: Optional human-readable description of the schema. + schema: + type: object + additionalProperties: true + maxProperties: 1024 + description: The full JSON Schema document (Draft 2020-12). + version: + type: integer + format: int32 + minimum: 1 + maximum: 2147483647 + description: Monotonic version number within the (clientId, name) lineage. + status: + $ref: "#/components/schemas/SchemaStatus" + documentCount: + type: integer + format: int32 + minimum: 0 + maximum: 2147483647 + description: Number of documents currently referencing this schema version. + canDelete: + type: boolean + description: True when documentCount is zero and the schema can be retired. + createdAt: + type: string + format: date-time + maxLength: 64 + description: Timestamp when this schema row was created. + createdBy: + type: string + minLength: 1 + maxLength: 255 + description: >- + Cognito subject ID (JWT sub claim) of the caller. Opaque UUID, + not an email. + example: "b7a4c1d2-8e3f-4a5b-9c6d-0e1f2a3b4c5d" + + CustomSchemaListResponse: + description: >- + List of custom metadata schemas for a client. Each row omits the + full JSON Schema document; use GET /super-admin/custom-schemas/ + {schemaId} to retrieve it. + type: object + required: + - schemas + properties: + schemas: + type: array + maxItems: 200 + description: The schema rows matching the query filters. + items: + type: object + required: + - id + - clientId + - name + - version + - status + - documentCount + - canDelete + - createdAt + - createdBy + properties: + id: + type: string + format: uuid + maxLength: 36 + description: The schema row ID. + clientId: + type: string + minLength: 1 + maxLength: 36 + description: The client that owns this schema. + name: + type: string + minLength: 1 + maxLength: 255 + description: The schema name (shared across versions in a lineage). + description: + type: string + maxLength: 1024 + description: Optional human-readable description of the schema. + version: + type: integer + format: int32 + minimum: 1 + maximum: 2147483647 + description: Monotonic version number within the lineage. + status: + $ref: "#/components/schemas/SchemaStatus" + documentCount: + type: integer + format: int32 + minimum: 0 + maximum: 2147483647 + description: Number of documents referencing this schema version. + canDelete: + type: boolean + description: True when documentCount is zero. + createdAt: + type: string + format: date-time + maxLength: 64 + description: Timestamp when this schema row was created. + createdBy: + type: string + minLength: 1 + maxLength: 255 + description: >- + Cognito subject ID (JWT sub claim) of the caller. Opaque + UUID, not an email. + example: "b7a4c1d2-8e3f-4a5b-9c6d-0e1f2a3b4c5d" + + DocumentSchemaAssignRequest: + description: >- + Request body for PATCH /super-admin/documents/{id}/schema. The + actor is derived from the JWT sub claim; the body has no createdBy + field. A null customSchemaId clears the binding. + type: object + properties: + customSchemaId: + type: string + format: uuid + maxLength: 36 + nullable: true + description: The client_metadata_schemas row id to bind, or null to clear. + + DocumentSchemaAssignResponse: + description: >- + Result of a schema binding mutation. Returns the bound schema + metadata (or nulls when cleared). + type: object + required: + - documentId + properties: + documentId: + type: string + format: uuid + maxLength: 36 + customSchemaId: + type: string + format: uuid + maxLength: 36 + nullable: true + schemaName: + type: string + maxLength: 255 + nullable: true + schemaVersion: + type: integer + format: int32 + minimum: 1 + maximum: 2147483647 + nullable: true + + DocumentMetadataResetResponse: + description: >- + Result of a reset-metadata operation. The previous* fields + describe the document's schema binding before the reset ran so + the caller can log or display what was wiped. Fields are null + when the document had no schema bound at reset time (the + idempotent no-op case, returned alongside + metadataVersionsDeleted set to 0). + type: object + required: + - documentId + - previousSchemaId + - previousSchemaName + - previousSchemaVersion + - metadataVersionsDeleted + - resetAt + - resetBy + properties: + documentId: + type: string + format: uuid + maxLength: 36 + description: The document whose metadata was reset. + previousSchemaId: + type: string + format: uuid + maxLength: 36 + nullable: true + description: >- + The document's custom_schema_id before the reset ran. + Null when no schema was bound. + previousSchemaName: + type: string + maxLength: 255 + nullable: true + description: >- + Name of the previously bound schema, or null when no schema + was bound. + previousSchemaVersion: + type: integer + format: int32 + minimum: 1 + maximum: 2147483647 + nullable: true + description: >- + Version of the previously bound schema row, or null when no + schema was bound. + metadataVersionsDeleted: + type: integer + format: int32 + minimum: 0 + maximum: 2147483647 + description: >- + Number of document_custom_metadata rows that existed (and + were deleted) at reset time. Zero is valid: the reset + endpoint is idempotent on already-clean documents. + resetAt: + type: string + format: date-time + maxLength: 64 + description: UTC timestamp when the reset completed. + resetBy: + type: string + minLength: 1 + maxLength: 255 + description: >- + Cognito subject ID (JWT sub claim) of the caller. Opaque + UUID, not an email. + example: "b7a4c1d2-8e3f-4a5b-9c6d-0e1f2a3b4c5d" + + CustomMetadataRequest: + description: >- + POST /custom-metadata body. No createdBy field — the actor is + derived from the JWT sub claim. + type: object + required: + - documentId + - metadata + properties: + documentId: + type: string + format: uuid + maxLength: 36 + metadata: + type: object + additionalProperties: true + maxProperties: 1024 + description: >- + Arbitrary JSON object that must conform to the document's + bound schema_def. The byte-level cap (1 048 576) lives in the + service layer constant MaxMetadataPayloadBytes. + + CustomMetadataResponse: + description: >- + Full metadata row decorated with the document's bound schema id, + name, and version. + type: object + required: + - id + - documentId + - metadata + - version + - createdAt + - createdBy + properties: + id: + type: string + format: uuid + maxLength: 36 + documentId: + type: string + format: uuid + maxLength: 36 + metadata: + type: object + additionalProperties: true + maxProperties: 1024 + version: + type: integer + format: int32 + minimum: 1 + maximum: 2147483647 + schemaId: + type: string + format: uuid + maxLength: 36 + nullable: true + schemaName: + type: string + maxLength: 255 + nullable: true + schemaVersion: + type: integer + format: int32 + minimum: 1 + maximum: 2147483647 + nullable: true + createdAt: + type: string + format: date-time + maxLength: 64 + createdBy: + type: string + minLength: 1 + maxLength: 255 + description: Cognito subject ID (JWT sub claim) of the writer. + example: "b7a4c1d2-8e3f-4a5b-9c6d-0e1f2a3b4c5d" + + CustomMetadataHistoryResponse: + description: Paged list of version summaries, newest first. + type: object + required: + - versions + properties: + versions: + type: array + maxItems: 200 + items: + type: object + required: + - version + - createdAt + - createdBy + properties: + version: + type: integer + format: int32 + minimum: 1 + maximum: 2147483647 + createdAt: + type: string + format: date-time + maxLength: 64 + createdBy: + type: string + minLength: 1 + maxLength: 255 + example: "b7a4c1d2-8e3f-4a5b-9c6d-0e1f2a3b4c5d" + ClientCreate: description: The properties for creation. type: object diff --git a/vendor/github.com/santhosh-tekuri/jsonschema/v6/.gitmodules b/vendor/github.com/santhosh-tekuri/jsonschema/v6/.gitmodules new file mode 100644 index 00000000..d14f5ea7 --- /dev/null +++ b/vendor/github.com/santhosh-tekuri/jsonschema/v6/.gitmodules @@ -0,0 +1,4 @@ +[submodule "testdata/JSON-Schema-Test-Suite"] + path = testdata/JSON-Schema-Test-Suite + url = https://github.com/json-schema-org/JSON-Schema-Test-Suite.git + branch = main diff --git a/vendor/github.com/santhosh-tekuri/jsonschema/v6/.golangci.yml b/vendor/github.com/santhosh-tekuri/jsonschema/v6/.golangci.yml new file mode 100644 index 00000000..6534d531 --- /dev/null +++ b/vendor/github.com/santhosh-tekuri/jsonschema/v6/.golangci.yml @@ -0,0 +1,7 @@ +version: "2" +linters: + enable: + - nakedret + - errname + - godot + - misspell diff --git a/vendor/github.com/santhosh-tekuri/jsonschema/v6/.pre-commit-hooks.yaml b/vendor/github.com/santhosh-tekuri/jsonschema/v6/.pre-commit-hooks.yaml new file mode 100644 index 00000000..695b502e --- /dev/null +++ b/vendor/github.com/santhosh-tekuri/jsonschema/v6/.pre-commit-hooks.yaml @@ -0,0 +1,7 @@ +- id: jsonschema-validate + name: Validate JSON against JSON Schema + description: ensure json files follow specified JSON Schema + entry: jv + language: golang + additional_dependencies: + - ./cmd/jv diff --git a/vendor/github.com/santhosh-tekuri/jsonschema/v6/LICENSE b/vendor/github.com/santhosh-tekuri/jsonschema/v6/LICENSE new file mode 100644 index 00000000..19dc35b2 --- /dev/null +++ b/vendor/github.com/santhosh-tekuri/jsonschema/v6/LICENSE @@ -0,0 +1,175 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. \ No newline at end of file diff --git a/vendor/github.com/santhosh-tekuri/jsonschema/v6/README.md b/vendor/github.com/santhosh-tekuri/jsonschema/v6/README.md new file mode 100644 index 00000000..1243b66c --- /dev/null +++ b/vendor/github.com/santhosh-tekuri/jsonschema/v6/README.md @@ -0,0 +1,88 @@ +# jsonschema v6.0.2 + +[![License](https://img.shields.io/badge/License-Apache%202.0-blue.svg)](https://opensource.org/licenses/Apache-2.0) +[![GoDoc](https://godoc.org/github.com/santhosh-tekuri/jsonschema?status.svg)](https://pkg.go.dev/github.com/santhosh-tekuri/jsonschema/v6) +[![Go Report Card](https://goreportcard.com/badge/github.com/santhosh-tekuri/jsonschema/v6)](https://goreportcard.com/report/github.com/santhosh-tekuri/jsonschema/v6) +[![Build Status](https://github.com/santhosh-tekuri/jsonschema/actions/workflows/go.yaml/badge.svg?branch=boon)](https://github.com/santhosh-tekuri/jsonschema/actions/workflows/go.yaml) +[![codecov](https://codecov.io/gh/santhosh-tekuri/jsonschema/branch/boon/graph/badge.svg?token=JMVj1pFT2l)](https://codecov.io/gh/santhosh-tekuri/jsonschema/tree/boon) + +see [godoc](https://pkg.go.dev/github.com/santhosh-tekuri/jsonschema/v6) for examples + +## Library Features + +- [x] pass [JSON-Schema-Test-Suite](https://github.com/json-schema-org/JSON-Schema-Test-Suite) excluding optional(compare with other impls at [bowtie](https://bowtie-json-schema.github.io/bowtie/#)) + - [x] [![draft-04](https://img.shields.io/endpoint?url=https://bowtie.report/badges/go-jsonschema/compliance/draft4.json)](https://bowtie.report/#/dialects/draft4) + - [x] [![draft-06](https://img.shields.io/endpoint?url=https://bowtie.report/badges/go-jsonschema/compliance/draft6.json)](https://bowtie.report/#/dialects/draft6) + - [x] [![draft-07](https://img.shields.io/endpoint?url=https://bowtie.report/badges/go-jsonschema/compliance/draft7.json)](https://bowtie.report/#/dialects/draft7) + - [x] [![draft/2019-09](https://img.shields.io/endpoint?url=https://bowtie.report/badges/go-jsonschema/compliance/draft2019-09.json)](https://bowtie.report/#/dialects/draft2019-09) + - [x] [![draft/2020-12](https://img.shields.io/endpoint?url=https://bowtie.report/badges/go-jsonschema/compliance/draft2020-12.json)](https://bowtie.report/#/dialects/draft2020-12) +- [x] detect infinite loop traps + - [x] `$schema` cycle + - [x] validation cycle +- [x] custom `$schema` url +- [x] vocabulary based validation +- [x] custom regex engine +- [x] format assertions + - [x] flag to enable in draft >= 2019-09 + - [x] custom format registration + - [x] built-in formats + - [x] regex, uuid + - [x] ipv4, ipv6 + - [x] hostname, email + - [x] date, time, date-time, duration + - [x] json-pointer, relative-json-pointer + - [x] uri, uri-reference, uri-template + - [x] iri, iri-reference + - [x] period, semver +- [x] content assertions + - [x] flag to enable in draft >= 7 + - [x] contentEncoding + - [x] base64 + - [x] custom + - [x] contentMediaType + - [x] application/json + - [x] custom + - [x] contentSchema +- [x] errors + - [x] introspectable + - [x] hierarchy + - [x] alternative display with `#` + - [x] output + - [x] flag + - [x] basic + - [x] detailed +- [x] custom vocabulary + - enable via `$vocabulary` for draft >=2019-19 + - enable via flag for draft <= 7 +- [x] mixed dialect support + +## CLI v0.7.0 + +to install: `go install github.com/santhosh-tekuri/jsonschema/cmd/jv@latest` + +Note that the cli is versioned independently. you can see it in git tags `cmd/jv/v0.7.0` + +``` +Usage: jv [OPTIONS] SCHEMA [INSTANCE...] + +Options: + -c, --assert-content Enable content assertions with draft >= 7 + -f, --assert-format Enable format assertions with draft >= 2019 + --cacert pem-file Use the specified pem-file to verify the peer. The file may contain multiple CA certificates + -d, --draft version Draft version used when '$schema' is missing. Valid values 4, 6, 7, 2019, 2020 (default 2020) + -h, --help Print help information + -k, --insecure Use insecure TLS connection + -o, --output format Output format. Valid values simple, alt, flag, basic, detailed (default "simple") + -q, --quiet Do not print errors + -v, --version Print build information +``` + +- [x] exit code `1` for validation errors, `2` for usage errors +- [x] validate both schema and multiple instances +- [x] support both json and yaml files +- [x] support standard input, use `-` +- [x] quite mode with parsable output +- [x] http(s) url support + - [x] custom certs for validation, use `--cacert` + - [x] flag to skip certificate verification, use `--insecure` + diff --git a/vendor/github.com/santhosh-tekuri/jsonschema/v6/compiler.go b/vendor/github.com/santhosh-tekuri/jsonschema/v6/compiler.go new file mode 100644 index 00000000..4da73610 --- /dev/null +++ b/vendor/github.com/santhosh-tekuri/jsonschema/v6/compiler.go @@ -0,0 +1,332 @@ +package jsonschema + +import ( + "fmt" + "regexp" + "slices" +) + +// Compiler compiles json schema into *Schema. +type Compiler struct { + schemas map[urlPtr]*Schema + roots *roots + formats map[string]*Format + decoders map[string]*Decoder + mediaTypes map[string]*MediaType + assertFormat bool + assertContent bool +} + +// NewCompiler create Compiler Object. +func NewCompiler() *Compiler { + return &Compiler{ + schemas: map[urlPtr]*Schema{}, + roots: newRoots(), + formats: map[string]*Format{}, + decoders: map[string]*Decoder{}, + mediaTypes: map[string]*MediaType{}, + assertFormat: false, + assertContent: false, + } +} + +// DefaultDraft overrides the draft used to +// compile schemas without `$schema` field. +// +// By default, this library uses the latest +// draft supported. +// +// The use of this option is HIGHLY encouraged +// to ensure continued correct operation of your +// schema. The current default value will not stay +// the same overtime. +func (c *Compiler) DefaultDraft(d *Draft) { + c.roots.defaultDraft = d +} + +// AssertFormat always enables format assertions. +// +// Default Behavior: +// for draft-07: enabled. +// for draft/2019-09: disabled unless metaschema says `format` vocabulary is required. +// for draft/2020-12: disabled unless metaschema says `format-assertion` vocabulary is required. +func (c *Compiler) AssertFormat() { + c.assertFormat = true +} + +// AssertContent enables content assertions. +// +// Content assertions include keywords: +// - contentEncoding +// - contentMediaType +// - contentSchema +// +// Default behavior is always disabled. +func (c *Compiler) AssertContent() { + c.assertContent = true +} + +// RegisterFormat registers custom format. +// +// NOTE: +// - "regex" format can not be overridden +// - format assertions are disabled for draft >= 2019-09 +// see [Compiler.AssertFormat] +func (c *Compiler) RegisterFormat(f *Format) { + if f.Name != "regex" { + c.formats[f.Name] = f + } +} + +// RegisterContentEncoding registers custom contentEncoding. +// +// NOTE: content assertions are disabled by default. +// see [Compiler.AssertContent]. +func (c *Compiler) RegisterContentEncoding(d *Decoder) { + c.decoders[d.Name] = d +} + +// RegisterContentMediaType registers custom contentMediaType. +// +// NOTE: content assertions are disabled by default. +// see [Compiler.AssertContent]. +func (c *Compiler) RegisterContentMediaType(mt *MediaType) { + c.mediaTypes[mt.Name] = mt +} + +// RegisterVocabulary registers custom vocabulary. +// +// NOTE: +// - vocabularies are disabled for draft >= 2019-09 +// see [Compiler.AssertVocabs] +func (c *Compiler) RegisterVocabulary(vocab *Vocabulary) { + c.roots.vocabularies[vocab.URL] = vocab +} + +// AssertVocabs always enables user-defined vocabularies assertions. +// +// Default Behavior: +// for draft-07: enabled. +// for draft/2019-09: disabled unless metaschema enables a vocabulary. +// for draft/2020-12: disabled unless metaschema enables a vocabulary. +func (c *Compiler) AssertVocabs() { + c.roots.assertVocabs = true +} + +// AddResource adds schema resource which gets used later in reference +// resolution. +// +// The argument url can be file path or url. Any fragment in url is ignored. +// The argument doc must be valid json value. +func (c *Compiler) AddResource(url string, doc any) error { + uf, err := absolute(url) + if err != nil { + return err + } + if isMeta(string(uf.url)) { + return &ResourceExistsError{string(uf.url)} + } + if !c.roots.loader.add(uf.url, doc) { + return &ResourceExistsError{string(uf.url)} + } + return nil +} + +// UseLoader overrides the default [URLLoader] used +// to load schema resources. +func (c *Compiler) UseLoader(loader URLLoader) { + c.roots.loader.loader = loader +} + +// UseRegexpEngine changes the regexp-engine used. +// By default it uses regexp package from go standard +// library. +// +// NOTE: must be called before compiling any schemas. +func (c *Compiler) UseRegexpEngine(engine RegexpEngine) { + if engine == nil { + engine = goRegexpCompile + } + c.roots.regexpEngine = engine +} + +func (c *Compiler) enqueue(q *queue, up urlPtr) *Schema { + if sch, ok := c.schemas[up]; ok { + // already got compiled + return sch + } + if sch := q.get(up); sch != nil { + return sch + } + sch := newSchema(up) + q.append(sch) + return sch +} + +// MustCompile is like [Compile] but panics if compilation fails. +// It simplifies safe initialization of global variables holding +// compiled schema. +func (c *Compiler) MustCompile(loc string) *Schema { + sch, err := c.Compile(loc) + if err != nil { + panic(fmt.Sprintf("jsonschema: Compile(%q): %v", loc, err)) + } + return sch +} + +// Compile compiles json-schema at given loc. +func (c *Compiler) Compile(loc string) (*Schema, error) { + uf, err := absolute(loc) + if err != nil { + return nil, err + } + up, err := c.roots.resolveFragment(*uf) + if err != nil { + return nil, err + } + return c.doCompile(up) +} + +func (c *Compiler) doCompile(up urlPtr) (*Schema, error) { + q := &queue{} + compiled := 0 + + c.enqueue(q, up) + for q.len() > compiled { + sch := q.at(compiled) + if err := c.roots.ensureSubschema(sch.up); err != nil { + return nil, err + } + r := c.roots.roots[sch.up.url] + v, err := sch.up.lookup(r.doc) + if err != nil { + return nil, err + } + if err := c.compileValue(v, sch, r, q); err != nil { + return nil, err + } + compiled++ + } + for _, sch := range *q { + c.schemas[sch.up] = sch + } + return c.schemas[up], nil +} + +func (c *Compiler) compileValue(v any, sch *Schema, r *root, q *queue) error { + res := r.resource(sch.up.ptr) + sch.DraftVersion = res.dialect.draft.version + + base := urlPtr{sch.up.url, res.ptr} + sch.resource = c.enqueue(q, base) + + // if resource, enqueue dynamic anchors for compilation + if sch.DraftVersion >= 2020 && sch.up == sch.resource.up { + res := r.resource(sch.up.ptr) + for anchor, anchorPtr := range res.anchors { + if slices.Contains(res.dynamicAnchors, anchor) { + up := urlPtr{sch.up.url, anchorPtr} + danchorSch := c.enqueue(q, up) + if sch.dynamicAnchors == nil { + sch.dynamicAnchors = map[string]*Schema{} + } + sch.dynamicAnchors[string(anchor)] = danchorSch + } + } + } + + switch v := v.(type) { + case bool: + sch.Bool = &v + case map[string]any: + if err := c.compileObject(v, sch, r, q); err != nil { + return err + } + } + + sch.allPropsEvaluated = sch.AdditionalProperties != nil + if sch.DraftVersion < 2020 { + sch.allItemsEvaluated = sch.AdditionalItems != nil + switch items := sch.Items.(type) { + case *Schema: + sch.allItemsEvaluated = true + case []*Schema: + sch.numItemsEvaluated = len(items) + } + } else { + sch.allItemsEvaluated = sch.Items2020 != nil + sch.numItemsEvaluated = len(sch.PrefixItems) + } + + return nil +} + +func (c *Compiler) compileObject(obj map[string]any, sch *Schema, r *root, q *queue) error { + if len(obj) == 0 { + b := true + sch.Bool = &b + return nil + } + oc := objCompiler{ + c: c, + obj: obj, + up: sch.up, + r: r, + res: r.resource(sch.up.ptr), + q: q, + } + return oc.compile(sch) +} + +// queue -- + +type queue []*Schema + +func (q *queue) append(sch *Schema) { + *q = append(*q, sch) +} + +func (q *queue) at(i int) *Schema { + return (*q)[i] +} + +func (q *queue) len() int { + return len(*q) +} + +func (q *queue) get(up urlPtr) *Schema { + i := slices.IndexFunc(*q, func(sch *Schema) bool { return sch.up == up }) + if i != -1 { + return (*q)[i] + } + return nil +} + +// regexp -- + +// Regexp is the representation of compiled regular expression. +type Regexp interface { + fmt.Stringer + + // MatchString reports whether the string s contains + // any match of the regular expression. + MatchString(string) bool +} + +// RegexpEngine parses a regular expression and returns, +// if successful, a Regexp object that can be used to +// match against text. +type RegexpEngine func(string) (Regexp, error) + +func (re RegexpEngine) validate(v any) error { + s, ok := v.(string) + if !ok { + return nil + } + _, err := re(s) + return err +} + +func goRegexpCompile(s string) (Regexp, error) { + return regexp.Compile(s) +} diff --git a/vendor/github.com/santhosh-tekuri/jsonschema/v6/content.go b/vendor/github.com/santhosh-tekuri/jsonschema/v6/content.go new file mode 100644 index 00000000..8d62e58b --- /dev/null +++ b/vendor/github.com/santhosh-tekuri/jsonschema/v6/content.go @@ -0,0 +1,51 @@ +package jsonschema + +import ( + "bytes" + "encoding/base64" + "encoding/json" +) + +// Decoder specifies how to decode specific contentEncoding. +type Decoder struct { + // Name of contentEncoding. + Name string + // Decode given string to byte array. + Decode func(string) ([]byte, error) +} + +var decoders = map[string]*Decoder{ + "base64": { + Name: "base64", + Decode: func(s string) ([]byte, error) { + return base64.StdEncoding.DecodeString(s) + }, + }, +} + +// MediaType specified how to validate bytes against specific contentMediaType. +type MediaType struct { + // Name of contentMediaType. + Name string + + // Validate checks whether bytes conform to this mediatype. + Validate func([]byte) error + + // UnmarshalJSON unmarshals bytes into json value. + // This must be nil if this mediatype is not compatible + // with json. + UnmarshalJSON func([]byte) (any, error) +} + +var mediaTypes = map[string]*MediaType{ + "application/json": { + Name: "application/json", + Validate: func(b []byte) error { + var v any + return json.Unmarshal(b, &v) + }, + UnmarshalJSON: func(b []byte) (any, error) { + return UnmarshalJSON(bytes.NewReader(b)) + }, + }, +} diff --git a/vendor/github.com/santhosh-tekuri/jsonschema/v6/draft.go b/vendor/github.com/santhosh-tekuri/jsonschema/v6/draft.go new file mode 100644 index 00000000..fd09bae8 --- /dev/null +++ b/vendor/github.com/santhosh-tekuri/jsonschema/v6/draft.go @@ -0,0 +1,360 @@ +package jsonschema + +import ( + "fmt" + "slices" + "strings" +) + +// A Draft represents json-schema specification. +type Draft struct { + version int + url string + sch *Schema + id string // property name used to represent id + subschemas []SchemaPath // locations of subschemas + vocabPrefix string // prefix used for vocabulary + allVocabs map[string]*Schema // names of supported vocabs with its schemas + defaultVocabs []string // names of default vocabs +} + +// String returns the specification url. +func (d *Draft) String() string { + return d.url +} + +var ( + Draft4 = &Draft{ + version: 4, + url: "http://json-schema.org/draft-04/schema", + id: "id", + subschemas: []SchemaPath{ + // type agonistic + schemaPath("definitions/*"), + schemaPath("not"), + schemaPath("allOf/[]"), + schemaPath("anyOf/[]"), + schemaPath("oneOf/[]"), + // object + schemaPath("properties/*"), + schemaPath("additionalProperties"), + schemaPath("patternProperties/*"), + // array + schemaPath("items"), + schemaPath("items/[]"), + schemaPath("additionalItems"), + schemaPath("dependencies/*"), + }, + vocabPrefix: "", + allVocabs: map[string]*Schema{}, + defaultVocabs: []string{}, + } + + Draft6 = &Draft{ + version: 6, + url: "http://json-schema.org/draft-06/schema", + id: "$id", + subschemas: joinSubschemas(Draft4.subschemas, + schemaPath("propertyNames"), + schemaPath("contains"), + ), + vocabPrefix: "", + allVocabs: map[string]*Schema{}, + defaultVocabs: []string{}, + } + + Draft7 = &Draft{ + version: 7, + url: "http://json-schema.org/draft-07/schema", + id: "$id", + subschemas: joinSubschemas(Draft6.subschemas, + schemaPath("if"), + schemaPath("then"), + schemaPath("else"), + ), + vocabPrefix: "", + allVocabs: map[string]*Schema{}, + defaultVocabs: []string{}, + } + + Draft2019 = &Draft{ + version: 2019, + url: "https://json-schema.org/draft/2019-09/schema", + id: "$id", + subschemas: joinSubschemas(Draft7.subschemas, + schemaPath("$defs/*"), + schemaPath("dependentSchemas/*"), + schemaPath("unevaluatedProperties"), + schemaPath("unevaluatedItems"), + schemaPath("contentSchema"), + ), + vocabPrefix: "https://json-schema.org/draft/2019-09/vocab/", + allVocabs: map[string]*Schema{ + "core": nil, + "applicator": nil, + "validation": nil, + "meta-data": nil, + "format": nil, + "content": nil, + }, + defaultVocabs: []string{"core", "applicator", "validation"}, + } + + Draft2020 = &Draft{ + version: 2020, + url: "https://json-schema.org/draft/2020-12/schema", + id: "$id", + subschemas: joinSubschemas(Draft2019.subschemas, + schemaPath("prefixItems/[]"), + ), + vocabPrefix: "https://json-schema.org/draft/2020-12/vocab/", + allVocabs: map[string]*Schema{ + "core": nil, + "applicator": nil, + "unevaluated": nil, + "validation": nil, + "meta-data": nil, + "format-annotation": nil, + "format-assertion": nil, + "content": nil, + }, + defaultVocabs: []string{"core", "applicator", "unevaluated", "validation"}, + } + + draftLatest = Draft2020 +) + +func init() { + c := NewCompiler() + c.AssertFormat() + for _, d := range []*Draft{Draft4, Draft6, Draft7, Draft2019, Draft2020} { + d.sch = c.MustCompile(d.url) + for name := range d.allVocabs { + d.allVocabs[name] = c.MustCompile(strings.TrimSuffix(d.url, "schema") + "meta/" + name) + } + } +} + +func draftFromURL(url string) *Draft { + u, frag := split(url) + if frag != "" { + return nil + } + u, ok := strings.CutPrefix(u, "http://") + if !ok { + u, _ = strings.CutPrefix(u, "https://") + } + switch u { + case "json-schema.org/schema": + return draftLatest + case "json-schema.org/draft/2020-12/schema": + return Draft2020 + case "json-schema.org/draft/2019-09/schema": + return Draft2019 + case "json-schema.org/draft-07/schema": + return Draft7 + case "json-schema.org/draft-06/schema": + return Draft6 + case "json-schema.org/draft-04/schema": + return Draft4 + default: + return nil + } +} + +func (d *Draft) getID(obj map[string]any) string { + if d.version < 2019 { + if _, ok := obj["$ref"]; ok { + // All other properties in a "$ref" object MUST be ignored + return "" + } + } + + id, ok := strVal(obj, d.id) + if !ok { + return "" + } + id, _ = split(id) // ignore fragment + return id +} + +func (d *Draft) getVocabs(url url, doc any, vocabularies map[string]*Vocabulary) ([]string, error) { + if d.version < 2019 { + return nil, nil + } + obj, ok := doc.(map[string]any) + if !ok { + return nil, nil + } + v, ok := obj["$vocabulary"] + if !ok { + return nil, nil + } + obj, ok = v.(map[string]any) + if !ok { + return nil, nil + } + + var vocabs []string + for vocab, reqd := range obj { + if reqd, ok := reqd.(bool); !ok || !reqd { + continue + } + name, ok := strings.CutPrefix(vocab, d.vocabPrefix) + if ok { + if _, ok := d.allVocabs[name]; ok { + if !slices.Contains(vocabs, name) { + vocabs = append(vocabs, name) + continue + } + } + } + if _, ok := vocabularies[vocab]; !ok { + return nil, &UnsupportedVocabularyError{url.String(), vocab} + } + if !slices.Contains(vocabs, vocab) { + vocabs = append(vocabs, vocab) + } + } + if !slices.Contains(vocabs, "core") { + vocabs = append(vocabs, "core") + } + return vocabs, nil +} + +// -- + +type dialect struct { + draft *Draft + vocabs []string // nil means use draft.defaultVocabs +} + +func (d *dialect) hasVocab(name string) bool { + if name == "core" || d.draft.version < 2019 { + return true + } + if d.vocabs != nil { + return slices.Contains(d.vocabs, name) + } + return slices.Contains(d.draft.defaultVocabs, name) +} + +func (d *dialect) activeVocabs(assertVocabs bool, vocabularies map[string]*Vocabulary) []string { + if len(vocabularies) == 0 { + return d.vocabs + } + if d.draft.version < 2019 { + assertVocabs = true + } + if !assertVocabs { + return d.vocabs + } + var vocabs []string + if d.vocabs == nil { + vocabs = slices.Clone(d.draft.defaultVocabs) + } else { + vocabs = slices.Clone(d.vocabs) + } + for vocab := range vocabularies { + if !slices.Contains(vocabs, vocab) { + vocabs = append(vocabs, vocab) + } + } + return vocabs +} + +func (d *dialect) getSchema(assertVocabs bool, vocabularies map[string]*Vocabulary) *Schema { + vocabs := d.activeVocabs(assertVocabs, vocabularies) + if vocabs == nil { + return d.draft.sch + } + + var allOf []*Schema + for _, vocab := range vocabs { + sch := d.draft.allVocabs[vocab] + if sch == nil { + if v, ok := vocabularies[vocab]; ok { + sch = v.Schema + } + } + if sch != nil { + allOf = append(allOf, sch) + } + } + if !slices.Contains(vocabs, "core") { + sch := d.draft.allVocabs["core"] + if sch == nil { + sch = d.draft.sch + } + allOf = append(allOf, sch) + } + sch := &Schema{ + Location: "urn:mem:metaschema", + up: urlPtr{url("urn:mem:metaschema"), ""}, + DraftVersion: d.draft.version, + AllOf: allOf, + } + sch.resource = sch + if sch.DraftVersion >= 2020 { + sch.DynamicAnchor = "meta" + sch.dynamicAnchors = map[string]*Schema{ + "meta": sch, + } + } + return sch +} + +// -- + +type ParseIDError struct { + URL string +} + +func (e *ParseIDError) Error() string { + return fmt.Sprintf("error in parsing id at %q", e.URL) +} + +// -- + +type ParseAnchorError struct { + URL string +} + +func (e *ParseAnchorError) Error() string { + return fmt.Sprintf("error in parsing anchor at %q", e.URL) +} + +// -- + +type DuplicateIDError struct { + ID string + URL string + Ptr1 string + Ptr2 string +} + +func (e *DuplicateIDError) Error() string { + return fmt.Sprintf("duplicate id %q in %q at %q and %q", e.ID, e.URL, e.Ptr1, e.Ptr2) +} + +// -- + +type DuplicateAnchorError struct { + Anchor string + URL string + Ptr1 string + Ptr2 string +} + +func (e *DuplicateAnchorError) Error() string { + return fmt.Sprintf("duplicate anchor %q in %q at %q and %q", e.Anchor, e.URL, e.Ptr1, e.Ptr2) +} + +// -- + +func joinSubschemas(a1 []SchemaPath, a2 ...SchemaPath) []SchemaPath { + var a []SchemaPath + a = append(a, a1...) + a = append(a, a2...) + return a +} diff --git a/vendor/github.com/santhosh-tekuri/jsonschema/v6/format.go b/vendor/github.com/santhosh-tekuri/jsonschema/v6/format.go new file mode 100644 index 00000000..b78b22e2 --- /dev/null +++ b/vendor/github.com/santhosh-tekuri/jsonschema/v6/format.go @@ -0,0 +1,708 @@ +package jsonschema + +import ( + "net/netip" + gourl "net/url" + "strconv" + "strings" + "time" +) + +// Format defined specific format. +type Format struct { + // Name of format. + Name string + + // Validate checks if given value is of this format. + Validate func(v any) error +} + +var formats = map[string]*Format{ + "json-pointer": {"json-pointer", validateJSONPointer}, + "relative-json-pointer": {"relative-json-pointer", validateRelativeJSONPointer}, + "uuid": {"uuid", validateUUID}, + "duration": {"duration", validateDuration}, + "period": {"period", validatePeriod}, + "ipv4": {"ipv4", validateIPV4}, + "ipv6": {"ipv6", validateIPV6}, + "hostname": {"hostname", validateHostname}, + "email": {"email", validateEmail}, + "date": {"date", validateDate}, + "time": {"time", validateTime}, + "date-time": {"date-time", validateDateTime}, + "uri": {"uri", validateURI}, + "iri": {"iri", validateURI}, + "uri-reference": {"uri-reference", validateURIReference}, + "iri-reference": {"iri-reference", validateURIReference}, + "uri-template": {"uri-template", validateURITemplate}, + "semver": {"semver", validateSemver}, +} + +// see https://www.rfc-editor.org/rfc/rfc6901#section-3 +func validateJSONPointer(v any) error { + s, ok := v.(string) + if !ok { + return nil + } + if s == "" { + return nil + } + if !strings.HasPrefix(s, "/") { + return LocalizableError("not starting with /") + } + for _, tok := range strings.Split(s, "/")[1:] { + escape := false + for _, ch := range tok { + if escape { + escape = false + if ch != '0' && ch != '1' { + return LocalizableError("~ must be followed by 0 or 1") + } + continue + } + if ch == '~' { + escape = true + continue + } + switch { + case ch >= '\x00' && ch <= '\x2E': + case ch >= '\x30' && ch <= '\x7D': + case ch >= '\x7F' && ch <= '\U0010FFFF': + default: + return LocalizableError("invalid character %q", ch) + } + } + if escape { + return LocalizableError("~ must be followed by 0 or 1") + } + } + return nil +} + +// see https://tools.ietf.org/html/draft-handrews-relative-json-pointer-01#section-3 +func validateRelativeJSONPointer(v any) error { + s, ok := v.(string) + if !ok { + return nil + } + + // start with non-negative-integer + numDigits := 0 + for _, ch := range s { + if ch >= '0' && ch <= '9' { + numDigits++ + } else { + break + } + } + if numDigits == 0 { + return LocalizableError("must start with non-negative integer") + } + if numDigits > 1 && strings.HasPrefix(s, "0") { + return LocalizableError("starts with zero") + } + s = s[numDigits:] + + // followed by either json-pointer or '#' + if s == "#" { + return nil + } + return validateJSONPointer(s) +} + +// see https://datatracker.ietf.org/doc/html/rfc4122#page-4 +func validateUUID(v any) error { + s, ok := v.(string) + if !ok { + return nil + } + + hexGroups := []int{8, 4, 4, 4, 12} + groups := strings.Split(s, "-") + if len(groups) != len(hexGroups) { + return LocalizableError("must have %d elements", len(hexGroups)) + } + for i, group := range groups { + if len(group) != hexGroups[i] { + return LocalizableError("element %d must be %d characters long", i+1, hexGroups[i]) + } + for _, ch := range group { + switch { + case ch >= '0' && ch <= '9': + case ch >= 'a' && ch <= 'f': + case ch >= 'A' && ch <= 'F': + default: + return LocalizableError("non-hex character %q", ch) + } + } + } + return nil +} + +// see https://datatracker.ietf.org/doc/html/rfc3339#appendix-A +func validateDuration(v any) error { + s, ok := v.(string) + if !ok { + return nil + } + + // must start with 'P' + s, ok = strings.CutPrefix(s, "P") + if !ok { + return LocalizableError("must start with P") + } + if s == "" { + return LocalizableError("nothing after P") + } + + // dur-week + if s, ok := strings.CutSuffix(s, "W"); ok { + if s == "" { + return LocalizableError("no number in week") + } + for _, ch := range s { + if ch < '0' || ch > '9' { + return LocalizableError("invalid week") + } + } + return nil + } + + allUnits := []string{"YMD", "HMS"} + for i, s := range strings.Split(s, "T") { + if i != 0 && s == "" { + return LocalizableError("no time elements") + } + if i >= len(allUnits) { + return LocalizableError("more than one T") + } + units := allUnits[i] + for s != "" { + digitCount := 0 + for _, ch := range s { + if ch >= '0' && ch <= '9' { + digitCount++ + } else { + break + } + } + if digitCount == 0 { + return LocalizableError("missing number") + } + s = s[digitCount:] + if s == "" { + return LocalizableError("missing unit") + } + unit := s[0] + j := strings.IndexByte(units, unit) + if j == -1 { + if strings.IndexByte(allUnits[i], unit) != -1 { + return LocalizableError("unit %q out of order", unit) + } + return LocalizableError("invalid unit %q", unit) + } + units = units[j+1:] + s = s[1:] + } + } + + return nil +} + +func validateIPV4(v any) error { + s, ok := v.(string) + if !ok { + return nil + } + groups := strings.Split(s, ".") + if len(groups) != 4 { + return LocalizableError("expected four decimals") + } + for _, group := range groups { + if len(group) > 1 && group[0] == '0' { + return LocalizableError("leading zeros") + } + n, err := strconv.Atoi(group) + if err != nil { + return err + } + if n < 0 || n > 255 { + return LocalizableError("decimal must be between 0 and 255") + } + } + return nil +} + +func validateIPV6(v any) error { + s, ok := v.(string) + if !ok { + return nil + } + if !strings.Contains(s, ":") { + return LocalizableError("missing colon") + } + addr, err := netip.ParseAddr(s) + if err != nil { + return err + } + if addr.Zone() != "" { + return LocalizableError("zone id is not a part of ipv6 address") + } + return nil +} + +// see https://en.wikipedia.org/wiki/Hostname#Restrictions_on_valid_host_names +func validateHostname(v any) error { + s, ok := v.(string) + if !ok { + return nil + } + + // entire hostname (including the delimiting dots but not a trailing dot) has a maximum of 253 ASCII characters + s = strings.TrimSuffix(s, ".") + if len(s) > 253 { + return LocalizableError("more than 253 characters long") + } + + // Hostnames are composed of series of labels concatenated with dots, as are all domain names + for _, label := range strings.Split(s, ".") { + // Each label must be from 1 to 63 characters long + if len(label) < 1 || len(label) > 63 { + return LocalizableError("label must be 1 to 63 characters long") + } + + // labels must not start or end with a hyphen + if strings.HasPrefix(label, "-") { + return LocalizableError("label starts with hyphen") + } + if strings.HasSuffix(label, "-") { + return LocalizableError("label ends with hyphen") + } + + // labels may contain only the ASCII letters 'a' through 'z' (in a case-insensitive manner), + // the digits '0' through '9', and the hyphen ('-') + for _, ch := range label { + switch { + case ch >= 'a' && ch <= 'z': + case ch >= 'A' && ch <= 'Z': + case ch >= '0' && ch <= '9': + case ch == '-': + default: + return LocalizableError("invalid character %q", ch) + } + } + } + return nil +} + +// see https://en.wikipedia.org/wiki/Email_address +func validateEmail(v any) error { + s, ok := v.(string) + if !ok { + return nil + } + // entire email address to be no more than 254 characters long + if len(s) > 254 { + return LocalizableError("more than 255 characters long") + } + + // email address is generally recognized as having two parts joined with an at-sign + at := strings.LastIndexByte(s, '@') + if at == -1 { + return LocalizableError("missing @") + } + local, domain := s[:at], s[at+1:] + + // local part may be up to 64 characters long + if len(local) > 64 { + return LocalizableError("local part more than 64 characters long") + } + + if len(local) > 1 && strings.HasPrefix(local, `"`) && strings.HasPrefix(local, `"`) { + // quoted + local := local[1 : len(local)-1] + if strings.IndexByte(local, '\\') != -1 || strings.IndexByte(local, '"') != -1 { + return LocalizableError("backslash and quote are not allowed within quoted local part") + } + } else { + // unquoted + if strings.HasPrefix(local, ".") { + return LocalizableError("starts with dot") + } + if strings.HasSuffix(local, ".") { + return LocalizableError("ends with dot") + } + + // consecutive dots not allowed + if strings.Contains(local, "..") { + return LocalizableError("consecutive dots") + } + + // check allowed chars + for _, ch := range local { + switch { + case ch >= 'a' && ch <= 'z': + case ch >= 'A' && ch <= 'Z': + case ch >= '0' && ch <= '9': + case strings.ContainsRune(".!#$%&'*+-/=?^_`{|}~", ch): + default: + return LocalizableError("invalid character %q", ch) + } + } + } + + // domain if enclosed in brackets, must match an IP address + if strings.HasPrefix(domain, "[") && strings.HasSuffix(domain, "]") { + domain = domain[1 : len(domain)-1] + if rem, ok := strings.CutPrefix(domain, "IPv6:"); ok { + if err := validateIPV6(rem); err != nil { + return LocalizableError("invalid ipv6 address: %v", err) + } + return nil + } + if err := validateIPV4(domain); err != nil { + return LocalizableError("invalid ipv4 address: %v", err) + } + return nil + } + + // domain must match the requirements for a hostname + if err := validateHostname(domain); err != nil { + return LocalizableError("invalid domain: %v", err) + } + + return nil +} + +// see see https://datatracker.ietf.org/doc/html/rfc3339#section-5.6 +func validateDate(v any) error { + s, ok := v.(string) + if !ok { + return nil + } + _, err := time.Parse("2006-01-02", s) + return err +} + +// see https://datatracker.ietf.org/doc/html/rfc3339#section-5.6 +// NOTE: golang time package does not support leap seconds. +func validateTime(v any) error { + str, ok := v.(string) + if !ok { + return nil + } + + // min: hh:mm:ssZ + if len(str) < 9 { + return LocalizableError("less than 9 characters long") + } + if str[2] != ':' || str[5] != ':' { + return LocalizableError("missing colon in correct place") + } + + // parse hh:mm:ss + var hms []int + for _, tok := range strings.SplitN(str[:8], ":", 3) { + i, err := strconv.Atoi(tok) + if err != nil { + return LocalizableError("invalid hour/min/sec") + } + if i < 0 { + return LocalizableError("non-positive hour/min/sec") + } + hms = append(hms, i) + } + if len(hms) != 3 { + return LocalizableError("missing hour/min/sec") + } + h, m, s := hms[0], hms[1], hms[2] + if h > 23 || m > 59 || s > 60 { + return LocalizableError("hour/min/sec out of range") + } + str = str[8:] + + // parse sec-frac if present + if rem, ok := strings.CutPrefix(str, "."); ok { + numDigits := 0 + for _, ch := range rem { + if ch >= '0' && ch <= '9' { + numDigits++ + } else { + break + } + } + if numDigits == 0 { + return LocalizableError("no digits in second fraction") + } + str = rem[numDigits:] + } + + if str != "z" && str != "Z" { + // parse time-numoffset + if len(str) != 6 { + return LocalizableError("offset must be 6 characters long") + } + var sign int + switch str[0] { + case '+': + sign = -1 + case '-': + sign = +1 + default: + return LocalizableError("offset must begin with plus/minus") + } + str = str[1:] + if str[2] != ':' { + return LocalizableError("missing colon in offset in correct place") + } + + var zhm []int + for _, tok := range strings.SplitN(str, ":", 2) { + i, err := strconv.Atoi(tok) + if err != nil { + return LocalizableError("invalid hour/min in offset") + } + if i < 0 { + return LocalizableError("non-positive hour/min in offset") + } + zhm = append(zhm, i) + } + zh, zm := zhm[0], zhm[1] + if zh > 23 || zm > 59 { + return LocalizableError("hour/min in offset out of range") + } + + // apply timezone + hm := (h*60 + m) + sign*(zh*60+zm) + if hm < 0 { + hm += 24 * 60 + } + h, m = hm/60, hm%60 + } + + // check leap second + if s >= 60 && (h != 23 || m != 59) { + return LocalizableError("invalid leap second") + } + + return nil +} + +// see https://datatracker.ietf.org/doc/html/rfc3339#section-5.6 +func validateDateTime(v any) error { + s, ok := v.(string) + if !ok { + return nil + } + + // min: yyyy-mm-ddThh:mm:ssZ + if len(s) < 20 { + return LocalizableError("less than 20 characters long") + } + + if s[10] != 't' && s[10] != 'T' { + return LocalizableError("11th character must be t or T") + } + if err := validateDate(s[:10]); err != nil { + return LocalizableError("invalid date element: %v", err) + } + if err := validateTime(s[11:]); err != nil { + return LocalizableError("invalid time element: %v", err) + } + return nil +} + +func parseURL(s string) (*gourl.URL, error) { + u, err := gourl.Parse(s) + if err != nil { + return nil, err + } + + // gourl does not validate ipv6 host address + hostName := u.Hostname() + if strings.Contains(hostName, ":") { + if !strings.Contains(u.Host, "[") || !strings.Contains(u.Host, "]") { + return nil, LocalizableError("ipv6 address not enclosed in brackets") + } + if err := validateIPV6(hostName); err != nil { + return nil, LocalizableError("invalid ipv6 address: %v", err) + } + } + + return u, nil +} + +func validateURI(v any) error { + s, ok := v.(string) + if !ok { + return nil + } + u, err := parseURL(s) + if err != nil { + return err + } + if !u.IsAbs() { + return LocalizableError("relative url") + } + return nil +} + +func validateURIReference(v any) error { + s, ok := v.(string) + if !ok { + return nil + } + if strings.Contains(s, `\`) { + return LocalizableError(`contains \`) + } + _, err := parseURL(s) + return err +} + +func validateURITemplate(v any) error { + s, ok := v.(string) + if !ok { + return nil + } + u, err := parseURL(s) + if err != nil { + return err + } + for _, tok := range strings.Split(u.RawPath, "/") { + tok, err = decode(tok) + if err != nil { + return LocalizableError("percent decode failed: %v", err) + } + want := true + for _, ch := range tok { + var got bool + switch ch { + case '{': + got = true + case '}': + got = false + default: + continue + } + if got != want { + return LocalizableError("nested curly braces") + } + want = !want + } + if !want { + return LocalizableError("no matching closing brace") + } + } + return nil +} + +func validatePeriod(v any) error { + s, ok := v.(string) + if !ok { + return nil + } + + slash := strings.IndexByte(s, '/') + if slash == -1 { + return LocalizableError("missing slash") + } + + start, end := s[:slash], s[slash+1:] + if strings.HasPrefix(start, "P") { + if err := validateDuration(start); err != nil { + return LocalizableError("invalid start duration: %v", err) + } + if err := validateDateTime(end); err != nil { + return LocalizableError("invalid end date-time: %v", err) + } + } else { + if err := validateDateTime(start); err != nil { + return LocalizableError("invalid start date-time: %v", err) + } + if strings.HasPrefix(end, "P") { + if err := validateDuration(end); err != nil { + return LocalizableError("invalid end duration: %v", err) + } + } else if err := validateDateTime(end); err != nil { + return LocalizableError("invalid end date-time: %v", err) + } + } + + return nil +} + +// see https://semver.org/#backusnaur-form-grammar-for-valid-semver-versions +func validateSemver(v any) error { + s, ok := v.(string) + if !ok { + return nil + } + + // build -- + if i := strings.IndexByte(s, '+'); i != -1 { + build := s[i+1:] + if build == "" { + return LocalizableError("build is empty") + } + for _, buildID := range strings.Split(build, ".") { + if buildID == "" { + return LocalizableError("build identifier is empty") + } + for _, ch := range buildID { + switch { + case ch >= '0' && ch <= '9': + case (ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z') || ch == '-': + default: + return LocalizableError("invalid character %q in build identifier", ch) + } + } + } + s = s[:i] + } + + // pre-release -- + if i := strings.IndexByte(s, '-'); i != -1 { + preRelease := s[i+1:] + for _, preReleaseID := range strings.Split(preRelease, ".") { + if preReleaseID == "" { + return LocalizableError("pre-release identifier is empty") + } + allDigits := true + for _, ch := range preReleaseID { + switch { + case ch >= '0' && ch <= '9': + case (ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z') || ch == '-': + allDigits = false + default: + return LocalizableError("invalid character %q in pre-release identifier", ch) + } + } + if allDigits && len(preReleaseID) > 1 && preReleaseID[0] == '0' { + return LocalizableError("pre-release numeric identifier starts with zero") + } + } + s = s[:i] + } + + // versionCore -- + versions := strings.Split(s, ".") + if len(versions) != 3 { + return LocalizableError("versionCore must have 3 numbers separated by dot") + } + names := []string{"major", "minor", "patch"} + for i, version := range versions { + if version == "" { + return LocalizableError("%s is empty", names[i]) + } + if len(version) > 1 && version[0] == '0' { + return LocalizableError("%s starts with zero", names[i]) + } + for _, ch := range version { + if ch < '0' || ch > '9' { + return LocalizableError("%s contains non-digit", names[i]) + } + } + } + + return nil +} diff --git a/vendor/github.com/santhosh-tekuri/jsonschema/v6/go.work b/vendor/github.com/santhosh-tekuri/jsonschema/v6/go.work new file mode 100644 index 00000000..e7f4d93d --- /dev/null +++ b/vendor/github.com/santhosh-tekuri/jsonschema/v6/go.work @@ -0,0 +1,8 @@ +go 1.21.1 + +use ( + . + ./cmd/jv +) + +// replace github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 => ./ diff --git a/vendor/github.com/santhosh-tekuri/jsonschema/v6/go.work.sum b/vendor/github.com/santhosh-tekuri/jsonschema/v6/go.work.sum new file mode 100644 index 00000000..2b5b811d --- /dev/null +++ b/vendor/github.com/santhosh-tekuri/jsonschema/v6/go.work.sum @@ -0,0 +1,4 @@ +github.com/santhosh-tekuri/jsonschema/v6 v6.0.1/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU= +golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= diff --git a/vendor/github.com/santhosh-tekuri/jsonschema/v6/kind/kind.go b/vendor/github.com/santhosh-tekuri/jsonschema/v6/kind/kind.go new file mode 100644 index 00000000..a37fb0b9 --- /dev/null +++ b/vendor/github.com/santhosh-tekuri/jsonschema/v6/kind/kind.go @@ -0,0 +1,651 @@ +package kind + +import ( + "fmt" + "math/big" + "strings" + + "golang.org/x/text/message" +) + +// -- + +type InvalidJsonValue struct { + Value any +} + +func (*InvalidJsonValue) KeywordPath() []string { + return nil +} + +func (k *InvalidJsonValue) LocalizedString(p *message.Printer) string { + return p.Sprintf("invalid jsonType %T", k.Value) +} + +// -- + +type Schema struct { + Location string +} + +func (*Schema) KeywordPath() []string { + return nil +} + +func (k *Schema) LocalizedString(p *message.Printer) string { + return p.Sprintf("jsonschema validation failed with %s", quote(k.Location)) +} + +// -- + +type Group struct{} + +func (*Group) KeywordPath() []string { + return nil +} + +func (*Group) LocalizedString(p *message.Printer) string { + return p.Sprintf("validation failed") +} + +// -- + +type Not struct{} + +func (*Not) KeywordPath() []string { + return nil +} + +func (*Not) LocalizedString(p *message.Printer) string { + return p.Sprintf("'not' failed") +} + +// -- + +type AllOf struct{} + +func (*AllOf) KeywordPath() []string { + return []string{"allOf"} +} + +func (*AllOf) LocalizedString(p *message.Printer) string { + return p.Sprintf("'allOf' failed") +} + +// -- + +type AnyOf struct{} + +func (*AnyOf) KeywordPath() []string { + return []string{"anyOf"} +} + +func (*AnyOf) LocalizedString(p *message.Printer) string { + return p.Sprintf("'anyOf' failed") +} + +// -- + +type OneOf struct { + // Subschemas gives indexes of Subschemas that have matched. + // Value nil, means none of the subschemas matched. + Subschemas []int +} + +func (*OneOf) KeywordPath() []string { + return []string{"oneOf"} +} + +func (k *OneOf) LocalizedString(p *message.Printer) string { + if len(k.Subschemas) == 0 { + return p.Sprintf("'oneOf' failed, none matched") + } + return p.Sprintf("'oneOf' failed, subschemas %d, %d matched", k.Subschemas[0], k.Subschemas[1]) +} + +//-- + +type FalseSchema struct{} + +func (*FalseSchema) KeywordPath() []string { + return nil +} + +func (*FalseSchema) LocalizedString(p *message.Printer) string { + return p.Sprintf("false schema") +} + +// -- + +type RefCycle struct { + URL string + KeywordLocation1 string + KeywordLocation2 string +} + +func (*RefCycle) KeywordPath() []string { + return nil +} + +func (k *RefCycle) LocalizedString(p *message.Printer) string { + return p.Sprintf("both %s and %s resolve to %q causing reference cycle", k.KeywordLocation1, k.KeywordLocation2, k.URL) +} + +// -- + +type Type struct { + Got string + Want []string +} + +func (*Type) KeywordPath() []string { + return []string{"type"} +} + +func (k *Type) LocalizedString(p *message.Printer) string { + want := strings.Join(k.Want, " or ") + return p.Sprintf("got %s, want %s", k.Got, want) +} + +// -- + +type Enum struct { + Got any + Want []any +} + +// KeywordPath implements jsonschema.ErrorKind. +func (*Enum) KeywordPath() []string { + return []string{"enum"} +} + +func (k *Enum) LocalizedString(p *message.Printer) string { + allPrimitive := true +loop: + for _, item := range k.Want { + switch item.(type) { + case []any, map[string]any: + allPrimitive = false + break loop + } + } + if allPrimitive { + if len(k.Want) == 1 { + return p.Sprintf("value must be %s", display(k.Want[0])) + } + var want []string + for _, v := range k.Want { + want = append(want, display(v)) + } + return p.Sprintf("value must be one of %s", strings.Join(want, ", ")) + } + return p.Sprintf("'enum' failed") +} + +// -- + +type Const struct { + Got any + Want any +} + +func (*Const) KeywordPath() []string { + return []string{"const"} +} + +func (k *Const) LocalizedString(p *message.Printer) string { + switch want := k.Want.(type) { + case []any, map[string]any: + return p.Sprintf("'const' failed") + default: + return p.Sprintf("value must be %s", display(want)) + } +} + +// -- + +type Format struct { + Got any + Want string + Err error +} + +func (*Format) KeywordPath() []string { + return []string{"format"} +} + +func (k *Format) LocalizedString(p *message.Printer) string { + return p.Sprintf("%s is not valid %s: %v", display(k.Got), k.Want, localizedError(k.Err, p)) +} + +// -- + +type Reference struct { + Keyword string + URL string +} + +func (k *Reference) KeywordPath() []string { + return []string{k.Keyword} +} + +func (*Reference) LocalizedString(p *message.Printer) string { + return p.Sprintf("validation failed") +} + +// -- + +type MinProperties struct { + Got, Want int +} + +func (*MinProperties) KeywordPath() []string { + return []string{"minProperties"} +} + +func (k *MinProperties) LocalizedString(p *message.Printer) string { + return p.Sprintf("minProperties: got %d, want %d", k.Got, k.Want) +} + +// -- + +type MaxProperties struct { + Got, Want int +} + +func (*MaxProperties) KeywordPath() []string { + return []string{"maxProperties"} +} + +func (k *MaxProperties) LocalizedString(p *message.Printer) string { + return p.Sprintf("maxProperties: got %d, want %d", k.Got, k.Want) +} + +// -- + +type MinItems struct { + Got, Want int +} + +func (*MinItems) KeywordPath() []string { + return []string{"minItems"} +} + +func (k *MinItems) LocalizedString(p *message.Printer) string { + return p.Sprintf("minItems: got %d, want %d", k.Got, k.Want) +} + +// -- + +type MaxItems struct { + Got, Want int +} + +func (*MaxItems) KeywordPath() []string { + return []string{"maxItems"} +} + +func (k *MaxItems) LocalizedString(p *message.Printer) string { + return p.Sprintf("maxItems: got %d, want %d", k.Got, k.Want) +} + +// -- + +type AdditionalItems struct { + Count int +} + +func (*AdditionalItems) KeywordPath() []string { + return []string{"additionalItems"} +} + +func (k *AdditionalItems) LocalizedString(p *message.Printer) string { + return p.Sprintf("last %d additionalItem(s) not allowed", k.Count) +} + +// -- + +type Required struct { + Missing []string +} + +func (*Required) KeywordPath() []string { + return []string{"required"} +} + +func (k *Required) LocalizedString(p *message.Printer) string { + if len(k.Missing) == 1 { + return p.Sprintf("missing property %s", quote(k.Missing[0])) + } + return p.Sprintf("missing properties %s", joinQuoted(k.Missing, ", ")) +} + +// -- + +type Dependency struct { + Prop string // dependency of prop that failed + Missing []string // missing props +} + +func (k *Dependency) KeywordPath() []string { + return []string{"dependency", k.Prop} +} + +func (k *Dependency) LocalizedString(p *message.Printer) string { + return p.Sprintf("properties %s required, if %s exists", joinQuoted(k.Missing, ", "), quote(k.Prop)) +} + +// -- + +type DependentRequired struct { + Prop string // dependency of prop that failed + Missing []string // missing props +} + +func (k *DependentRequired) KeywordPath() []string { + return []string{"dependentRequired", k.Prop} +} + +func (k *DependentRequired) LocalizedString(p *message.Printer) string { + return p.Sprintf("properties %s required, if %s exists", joinQuoted(k.Missing, ", "), quote(k.Prop)) +} + +// -- + +type AdditionalProperties struct { + Properties []string +} + +func (*AdditionalProperties) KeywordPath() []string { + return []string{"additionalProperties"} +} + +func (k *AdditionalProperties) LocalizedString(p *message.Printer) string { + return p.Sprintf("additional properties %s not allowed", joinQuoted(k.Properties, ", ")) +} + +// -- + +type PropertyNames struct { + Property string +} + +func (*PropertyNames) KeywordPath() []string { + return []string{"propertyNames"} +} + +func (k *PropertyNames) LocalizedString(p *message.Printer) string { + return p.Sprintf("invalid propertyName %s", quote(k.Property)) +} + +// -- + +type UniqueItems struct { + Duplicates [2]int +} + +func (*UniqueItems) KeywordPath() []string { + return []string{"uniqueItems"} +} + +func (k *UniqueItems) LocalizedString(p *message.Printer) string { + return p.Sprintf("items at %d and %d are equal", k.Duplicates[0], k.Duplicates[1]) +} + +// -- + +type Contains struct{} + +func (*Contains) KeywordPath() []string { + return []string{"contains"} +} + +func (*Contains) LocalizedString(p *message.Printer) string { + return p.Sprintf("no items match contains schema") +} + +// -- + +type MinContains struct { + Got []int + Want int +} + +func (*MinContains) KeywordPath() []string { + return []string{"minContains"} +} + +func (k *MinContains) LocalizedString(p *message.Printer) string { + if len(k.Got) == 0 { + return p.Sprintf("min %d items required to match contains schema, but none matched", k.Want) + } else { + got := fmt.Sprintf("%v", k.Got) + return p.Sprintf("min %d items required to match contains schema, but matched %d items at %v", k.Want, len(k.Got), got[1:len(got)-1]) + } +} + +// -- + +type MaxContains struct { + Got []int + Want int +} + +func (*MaxContains) KeywordPath() []string { + return []string{"maxContains"} +} + +func (k *MaxContains) LocalizedString(p *message.Printer) string { + got := fmt.Sprintf("%v", k.Got) + return p.Sprintf("max %d items required to match contains schema, but matched %d items at %v", k.Want, len(k.Got), got[1:len(got)-1]) +} + +// -- + +type MinLength struct { + Got, Want int +} + +func (*MinLength) KeywordPath() []string { + return []string{"minLength"} +} + +func (k *MinLength) LocalizedString(p *message.Printer) string { + return p.Sprintf("minLength: got %d, want %d", k.Got, k.Want) +} + +// -- + +type MaxLength struct { + Got, Want int +} + +func (*MaxLength) KeywordPath() []string { + return []string{"maxLength"} +} + +func (k *MaxLength) LocalizedString(p *message.Printer) string { + return p.Sprintf("maxLength: got %d, want %d", k.Got, k.Want) +} + +// -- + +type Pattern struct { + Got string + Want string +} + +func (*Pattern) KeywordPath() []string { + return []string{"pattern"} +} + +func (k *Pattern) LocalizedString(p *message.Printer) string { + return p.Sprintf("%s does not match pattern %s", quote(k.Got), quote(k.Want)) +} + +// -- + +type ContentEncoding struct { + Want string + Err error +} + +func (*ContentEncoding) KeywordPath() []string { + return []string{"contentEncoding"} +} + +func (k *ContentEncoding) LocalizedString(p *message.Printer) string { + return p.Sprintf("value is not %s encoded: %v", quote(k.Want), localizedError(k.Err, p)) +} + +// -- + +type ContentMediaType struct { + Got []byte + Want string + Err error +} + +func (*ContentMediaType) KeywordPath() []string { + return []string{"contentMediaType"} +} + +func (k *ContentMediaType) LocalizedString(p *message.Printer) string { + return p.Sprintf("value if not of mediatype %s: %v", quote(k.Want), k.Err) +} + +// -- + +type ContentSchema struct{} + +func (*ContentSchema) KeywordPath() []string { + return []string{"contentSchema"} +} + +func (*ContentSchema) LocalizedString(p *message.Printer) string { + return p.Sprintf("'contentSchema' failed") +} + +// -- + +type Minimum struct { + Got *big.Rat + Want *big.Rat +} + +func (*Minimum) KeywordPath() []string { + return []string{"minimum"} +} + +func (k *Minimum) LocalizedString(p *message.Printer) string { + got, _ := k.Got.Float64() + want, _ := k.Want.Float64() + return p.Sprintf("minimum: got %v, want %v", got, want) +} + +// -- + +type Maximum struct { + Got *big.Rat + Want *big.Rat +} + +func (*Maximum) KeywordPath() []string { + return []string{"maximum"} +} + +func (k *Maximum) LocalizedString(p *message.Printer) string { + got, _ := k.Got.Float64() + want, _ := k.Want.Float64() + return p.Sprintf("maximum: got %v, want %v", got, want) +} + +// -- + +type ExclusiveMinimum struct { + Got *big.Rat + Want *big.Rat +} + +func (*ExclusiveMinimum) KeywordPath() []string { + return []string{"exclusiveMinimum"} +} + +func (k *ExclusiveMinimum) LocalizedString(p *message.Printer) string { + got, _ := k.Got.Float64() + want, _ := k.Want.Float64() + return p.Sprintf("exclusiveMinimum: got %v, want %v", got, want) +} + +// -- + +type ExclusiveMaximum struct { + Got *big.Rat + Want *big.Rat +} + +func (*ExclusiveMaximum) KeywordPath() []string { + return []string{"exclusiveMaximum"} +} + +func (k *ExclusiveMaximum) LocalizedString(p *message.Printer) string { + got, _ := k.Got.Float64() + want, _ := k.Want.Float64() + return p.Sprintf("exclusiveMaximum: got %v, want %v", got, want) +} + +// -- + +type MultipleOf struct { + Got *big.Rat + Want *big.Rat +} + +func (*MultipleOf) KeywordPath() []string { + return []string{"multipleOf"} +} + +func (k *MultipleOf) LocalizedString(p *message.Printer) string { + got, _ := k.Got.Float64() + want, _ := k.Want.Float64() + return p.Sprintf("multipleOf: got %v, want %v", got, want) +} + +// -- + +func quote(s string) string { + s = fmt.Sprintf("%q", s) + s = strings.ReplaceAll(s, `\"`, `"`) + s = strings.ReplaceAll(s, `'`, `\'`) + return "'" + s[1:len(s)-1] + "'" +} + +func joinQuoted(arr []string, sep string) string { + var sb strings.Builder + for _, s := range arr { + if sb.Len() > 0 { + sb.WriteString(sep) + } + sb.WriteString(quote(s)) + } + return sb.String() +} + +// to be used only for primitive. +func display(v any) string { + switch v := v.(type) { + case string: + return quote(v) + case []any, map[string]any: + return "value" + default: + return fmt.Sprintf("%v", v) + } +} + +func localizedError(err error, p *message.Printer) string { + if err, ok := err.(interface{ LocalizedError(*message.Printer) string }); ok { + return err.LocalizedError(p) + } + return err.Error() +} diff --git a/vendor/github.com/santhosh-tekuri/jsonschema/v6/loader.go b/vendor/github.com/santhosh-tekuri/jsonschema/v6/loader.go new file mode 100644 index 00000000..ce0170e2 --- /dev/null +++ b/vendor/github.com/santhosh-tekuri/jsonschema/v6/loader.go @@ -0,0 +1,266 @@ +package jsonschema + +import ( + "embed" + "encoding/json" + "errors" + "fmt" + "io" + "io/fs" + gourl "net/url" + "os" + "path/filepath" + "runtime" + "strings" +) + +// URLLoader knows how to load json from given url. +type URLLoader interface { + // Load loads json from given absolute url. + Load(url string) (any, error) +} + +// -- + +// FileLoader loads json file url. +type FileLoader struct{} + +func (l FileLoader) Load(url string) (any, error) { + path, err := l.ToFile(url) + if err != nil { + return nil, err + } + f, err := os.Open(path) + if err != nil { + return nil, err + } + defer f.Close() + return UnmarshalJSON(f) +} + +// ToFile is helper method to convert file url to file path. +func (l FileLoader) ToFile(url string) (string, error) { + u, err := gourl.Parse(url) + if err != nil { + return "", err + } + if u.Scheme != "file" { + return "", fmt.Errorf("invalid file url: %s", u) + } + path := u.Path + if runtime.GOOS == "windows" { + path = strings.TrimPrefix(path, "/") + path = filepath.FromSlash(path) + } + return path, nil +} + +// -- + +// SchemeURLLoader delegates to other [URLLoaders] +// based on url scheme. +type SchemeURLLoader map[string]URLLoader + +func (l SchemeURLLoader) Load(url string) (any, error) { + u, err := gourl.Parse(url) + if err != nil { + return nil, err + } + ll, ok := l[u.Scheme] + if !ok { + return nil, &UnsupportedURLSchemeError{u.String()} + } + return ll.Load(url) +} + +// -- + +//go:embed metaschemas +var metaFS embed.FS + +func openMeta(url string) (fs.File, error) { + u, meta := strings.CutPrefix(url, "http://json-schema.org/") + if !meta { + u, meta = strings.CutPrefix(url, "https://json-schema.org/") + } + if meta { + if u == "schema" { + return openMeta(draftLatest.url) + } + f, err := metaFS.Open("metaschemas/" + u) + if err != nil { + if errors.Is(err, fs.ErrNotExist) { + return nil, nil + } + return nil, err + } + return f, err + } + return nil, nil + +} + +func isMeta(url string) bool { + f, err := openMeta(url) + if err != nil { + return true + } + if f != nil { + f.Close() + return true + } + return false +} + +func loadMeta(url string) (any, error) { + f, err := openMeta(url) + if err != nil { + return nil, err + } + if f == nil { + return nil, nil + } + defer f.Close() + return UnmarshalJSON(f) +} + +// -- + +type defaultLoader struct { + docs map[url]any // docs loaded so far + loader URLLoader +} + +func (l *defaultLoader) add(url url, doc any) bool { + if _, ok := l.docs[url]; ok { + return false + } + l.docs[url] = doc + return true +} + +func (l *defaultLoader) load(url url) (any, error) { + if doc, ok := l.docs[url]; ok { + return doc, nil + } + doc, err := loadMeta(url.String()) + if err != nil { + return nil, err + } + if doc != nil { + l.add(url, doc) + return doc, nil + } + if l.loader == nil { + return nil, &LoadURLError{url.String(), errors.New("no URLLoader set")} + } + doc, err = l.loader.Load(url.String()) + if err != nil { + return nil, &LoadURLError{URL: url.String(), Err: err} + } + l.add(url, doc) + return doc, nil +} + +func (l *defaultLoader) getDraft(up urlPtr, doc any, defaultDraft *Draft, cycle map[url]struct{}) (*Draft, error) { + obj, ok := doc.(map[string]any) + if !ok { + return defaultDraft, nil + } + sch, ok := strVal(obj, "$schema") + if !ok { + return defaultDraft, nil + } + if draft := draftFromURL(sch); draft != nil { + return draft, nil + } + sch, _ = split(sch) + if _, err := gourl.Parse(sch); err != nil { + return nil, &InvalidMetaSchemaURLError{up.String(), err} + } + schUrl := url(sch) + if up.ptr.isEmpty() && schUrl == up.url { + return nil, &UnsupportedDraftError{schUrl.String()} + } + if _, ok := cycle[schUrl]; ok { + return nil, &MetaSchemaCycleError{schUrl.String()} + } + cycle[schUrl] = struct{}{} + doc, err := l.load(schUrl) + if err != nil { + return nil, err + } + return l.getDraft(urlPtr{schUrl, ""}, doc, defaultDraft, cycle) +} + +func (l *defaultLoader) getMetaVocabs(doc any, draft *Draft, vocabularies map[string]*Vocabulary) ([]string, error) { + obj, ok := doc.(map[string]any) + if !ok { + return nil, nil + } + sch, ok := strVal(obj, "$schema") + if !ok { + return nil, nil + } + if draft := draftFromURL(sch); draft != nil { + return nil, nil + } + sch, _ = split(sch) + if _, err := gourl.Parse(sch); err != nil { + return nil, &ParseURLError{sch, err} + } + schUrl := url(sch) + doc, err := l.load(schUrl) + if err != nil { + return nil, err + } + return draft.getVocabs(schUrl, doc, vocabularies) +} + +// -- + +type LoadURLError struct { + URL string + Err error +} + +func (e *LoadURLError) Error() string { + return fmt.Sprintf("failing loading %q: %v", e.URL, e.Err) +} + +// -- + +type UnsupportedURLSchemeError struct { + url string +} + +func (e *UnsupportedURLSchemeError) Error() string { + return fmt.Sprintf("no URLLoader registered for %q", e.url) +} + +// -- + +type ResourceExistsError struct { + url string +} + +func (e *ResourceExistsError) Error() string { + return fmt.Sprintf("resource for %q already exists", e.url) +} + +// -- + +// UnmarshalJSON unmarshals into [any] without losing +// number precision using [json.Number]. +func UnmarshalJSON(r io.Reader) (any, error) { + decoder := json.NewDecoder(r) + decoder.UseNumber() + var doc any + if err := decoder.Decode(&doc); err != nil { + return nil, err + } + if _, err := decoder.Token(); err == nil || err != io.EOF { + return nil, fmt.Errorf("invalid character after top-level value") + } + return doc, nil +} diff --git a/vendor/github.com/santhosh-tekuri/jsonschema/v6/metaschemas/draft-04/schema b/vendor/github.com/santhosh-tekuri/jsonschema/v6/metaschemas/draft-04/schema new file mode 100644 index 00000000..b2a7ff0f --- /dev/null +++ b/vendor/github.com/santhosh-tekuri/jsonschema/v6/metaschemas/draft-04/schema @@ -0,0 +1,151 @@ +{ + "$schema": "http://json-schema.org/draft-04/schema#", + "description": "Core schema meta-schema", + "definitions": { + "schemaArray": { + "type": "array", + "minItems": 1, + "items": { "$ref": "#" } + }, + "positiveInteger": { + "type": "integer", + "minimum": 0 + }, + "positiveIntegerDefault0": { + "allOf": [ { "$ref": "#/definitions/positiveInteger" }, { "default": 0 } ] + }, + "simpleTypes": { + "enum": [ "array", "boolean", "integer", "null", "number", "object", "string" ] + }, + "stringArray": { + "type": "array", + "items": { "type": "string" }, + "minItems": 1, + "uniqueItems": true + } + }, + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uriref" + }, + "$schema": { + "type": "string", + "format": "uri" + }, + "title": { + "type": "string" + }, + "description": { + "type": "string" + }, + "default": {}, + "multipleOf": { + "type": "number", + "minimum": 0, + "exclusiveMinimum": true + }, + "maximum": { + "type": "number" + }, + "exclusiveMaximum": { + "type": "boolean", + "default": false + }, + "minimum": { + "type": "number" + }, + "exclusiveMinimum": { + "type": "boolean", + "default": false + }, + "maxLength": { "$ref": "#/definitions/positiveInteger" }, + "minLength": { "$ref": "#/definitions/positiveIntegerDefault0" }, + "pattern": { + "type": "string", + "format": "regex" + }, + "additionalItems": { + "anyOf": [ + { "type": "boolean" }, + { "$ref": "#" } + ], + "default": {} + }, + "items": { + "anyOf": [ + { "$ref": "#" }, + { "$ref": "#/definitions/schemaArray" } + ], + "default": {} + }, + "maxItems": { "$ref": "#/definitions/positiveInteger" }, + "minItems": { "$ref": "#/definitions/positiveIntegerDefault0" }, + "uniqueItems": { + "type": "boolean", + "default": false + }, + "maxProperties": { "$ref": "#/definitions/positiveInteger" }, + "minProperties": { "$ref": "#/definitions/positiveIntegerDefault0" }, + "required": { "$ref": "#/definitions/stringArray" }, + "additionalProperties": { + "anyOf": [ + { "type": "boolean" }, + { "$ref": "#" } + ], + "default": {} + }, + "definitions": { + "type": "object", + "additionalProperties": { "$ref": "#" }, + "default": {} + }, + "properties": { + "type": "object", + "additionalProperties": { "$ref": "#" }, + "default": {} + }, + "patternProperties": { + "type": "object", + "additionalProperties": { "$ref": "#" }, + "default": {} + }, + "dependencies": { + "type": "object", + "additionalProperties": { + "anyOf": [ + { "$ref": "#" }, + { "$ref": "#/definitions/stringArray" } + ] + } + }, + "enum": { + "type": "array", + "minItems": 1, + "uniqueItems": true + }, + "type": { + "anyOf": [ + { "$ref": "#/definitions/simpleTypes" }, + { + "type": "array", + "items": { "$ref": "#/definitions/simpleTypes" }, + "minItems": 1, + "uniqueItems": true + } + ] + }, + "allOf": { "$ref": "#/definitions/schemaArray" }, + "anyOf": { "$ref": "#/definitions/schemaArray" }, + "oneOf": { "$ref": "#/definitions/schemaArray" }, + "not": { "$ref": "#" }, + "format": { "type": "string" }, + "$ref": { "type": "string" } + }, + "dependencies": { + "exclusiveMaximum": [ "maximum" ], + "exclusiveMinimum": [ "minimum" ] + }, + "default": {} +} diff --git a/vendor/github.com/santhosh-tekuri/jsonschema/v6/metaschemas/draft-06/schema b/vendor/github.com/santhosh-tekuri/jsonschema/v6/metaschemas/draft-06/schema new file mode 100644 index 00000000..fa22ad1b --- /dev/null +++ b/vendor/github.com/santhosh-tekuri/jsonschema/v6/metaschemas/draft-06/schema @@ -0,0 +1,150 @@ +{ + "$schema": "http://json-schema.org/draft-06/schema#", + "$id": "http://json-schema.org/draft-06/schema#", + "title": "Core schema meta-schema", + "definitions": { + "schemaArray": { + "type": "array", + "minItems": 1, + "items": { "$ref": "#" } + }, + "nonNegativeInteger": { + "type": "integer", + "minimum": 0 + }, + "nonNegativeIntegerDefault0": { + "allOf": [ + { "$ref": "#/definitions/nonNegativeInteger" }, + { "default": 0 } + ] + }, + "simpleTypes": { + "enum": [ + "array", + "boolean", + "integer", + "null", + "number", + "object", + "string" + ] + }, + "stringArray": { + "type": "array", + "items": { "type": "string" }, + "uniqueItems": true, + "default": [] + } + }, + "type": ["object", "boolean"], + "properties": { + "$id": { + "type": "string", + "format": "uri-reference" + }, + "$schema": { + "type": "string", + "format": "uri" + }, + "$ref": { + "type": "string", + "format": "uri-reference" + }, + "title": { + "type": "string" + }, + "description": { + "type": "string" + }, + "default": {}, + "multipleOf": { + "type": "number", + "exclusiveMinimum": 0 + }, + "maximum": { + "type": "number" + }, + "exclusiveMaximum": { + "type": "number" + }, + "minimum": { + "type": "number" + }, + "exclusiveMinimum": { + "type": "number" + }, + "maxLength": { "$ref": "#/definitions/nonNegativeInteger" }, + "minLength": { "$ref": "#/definitions/nonNegativeIntegerDefault0" }, + "pattern": { + "type": "string", + "format": "regex" + }, + "additionalItems": { "$ref": "#" }, + "items": { + "anyOf": [ + { "$ref": "#" }, + { "$ref": "#/definitions/schemaArray" } + ], + "default": {} + }, + "maxItems": { "$ref": "#/definitions/nonNegativeInteger" }, + "minItems": { "$ref": "#/definitions/nonNegativeIntegerDefault0" }, + "uniqueItems": { + "type": "boolean", + "default": false + }, + "contains": { "$ref": "#" }, + "maxProperties": { "$ref": "#/definitions/nonNegativeInteger" }, + "minProperties": { "$ref": "#/definitions/nonNegativeIntegerDefault0" }, + "required": { "$ref": "#/definitions/stringArray" }, + "additionalProperties": { "$ref": "#" }, + "definitions": { + "type": "object", + "additionalProperties": { "$ref": "#" }, + "default": {} + }, + "properties": { + "type": "object", + "additionalProperties": { "$ref": "#" }, + "default": {} + }, + "patternProperties": { + "type": "object", + "additionalProperties": { "$ref": "#" }, + "default": {} + }, + "dependencies": { + "type": "object", + "additionalProperties": { + "anyOf": [ + { "$ref": "#" }, + { "$ref": "#/definitions/stringArray" } + ] + } + }, + "propertyNames": { "$ref": "#" }, + "const": {}, + "enum": { + "type": "array", + "minItems": 1, + "uniqueItems": true + }, + "type": { + "anyOf": [ + { "$ref": "#/definitions/simpleTypes" }, + { + "type": "array", + "items": { "$ref": "#/definitions/simpleTypes" }, + "minItems": 1, + "uniqueItems": true + } + ] + }, + "format": { "type": "string" }, + "allOf": { "$ref": "#/definitions/schemaArray" }, + "anyOf": { "$ref": "#/definitions/schemaArray" }, + "oneOf": { "$ref": "#/definitions/schemaArray" }, + "not": { "$ref": "#" } + }, + "default": {} +} diff --git a/vendor/github.com/santhosh-tekuri/jsonschema/v6/metaschemas/draft-07/schema b/vendor/github.com/santhosh-tekuri/jsonschema/v6/metaschemas/draft-07/schema new file mode 100644 index 00000000..326759a6 --- /dev/null +++ b/vendor/github.com/santhosh-tekuri/jsonschema/v6/metaschemas/draft-07/schema @@ -0,0 +1,172 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "http://json-schema.org/draft-07/schema#", + "title": "Core schema meta-schema", + "definitions": { + "schemaArray": { + "type": "array", + "minItems": 1, + "items": { "$ref": "#" } + }, + "nonNegativeInteger": { + "type": "integer", + "minimum": 0 + }, + "nonNegativeIntegerDefault0": { + "allOf": [ + { "$ref": "#/definitions/nonNegativeInteger" }, + { "default": 0 } + ] + }, + "simpleTypes": { + "enum": [ + "array", + "boolean", + "integer", + "null", + "number", + "object", + "string" + ] + }, + "stringArray": { + "type": "array", + "items": { "type": "string" }, + "uniqueItems": true, + "default": [] + } + }, + "type": ["object", "boolean"], + "properties": { + "$id": { + "type": "string", + "format": "uri-reference" + }, + "$schema": { + "type": "string", + "format": "uri" + }, + "$ref": { + "type": "string", + "format": "uri-reference" + }, + "$comment": { + "type": "string" + }, + "title": { + "type": "string" + }, + "description": { + "type": "string" + }, + "default": true, + "readOnly": { + "type": "boolean", + "default": false + }, + "writeOnly": { + "type": "boolean", + "default": false + }, + "examples": { + "type": "array", + "items": true + }, + "multipleOf": { + "type": "number", + "exclusiveMinimum": 0 + }, + "maximum": { + "type": "number" + }, + "exclusiveMaximum": { + "type": "number" + }, + "minimum": { + "type": "number" + }, + "exclusiveMinimum": { + "type": "number" + }, + "maxLength": { "$ref": "#/definitions/nonNegativeInteger" }, + "minLength": { "$ref": "#/definitions/nonNegativeIntegerDefault0" }, + "pattern": { + "type": "string", + "format": "regex" + }, + "additionalItems": { "$ref": "#" }, + "items": { + "anyOf": [ + { "$ref": "#" }, + { "$ref": "#/definitions/schemaArray" } + ], + "default": true + }, + "maxItems": { "$ref": "#/definitions/nonNegativeInteger" }, + "minItems": { "$ref": "#/definitions/nonNegativeIntegerDefault0" }, + "uniqueItems": { + "type": "boolean", + "default": false + }, + "contains": { "$ref": "#" }, + "maxProperties": { "$ref": "#/definitions/nonNegativeInteger" }, + "minProperties": { "$ref": "#/definitions/nonNegativeIntegerDefault0" }, + "required": { "$ref": "#/definitions/stringArray" }, + "additionalProperties": { "$ref": "#" }, + "definitions": { + "type": "object", + "additionalProperties": { "$ref": "#" }, + "default": {} + }, + "properties": { + "type": "object", + "additionalProperties": { "$ref": "#" }, + "default": {} + }, + "patternProperties": { + "type": "object", + "additionalProperties": { "$ref": "#" }, + "propertyNames": { "format": "regex" }, + "default": {} + }, + "dependencies": { + "type": "object", + "additionalProperties": { + "anyOf": [ + { "$ref": "#" }, + { "$ref": "#/definitions/stringArray" } + ] + } + }, + "propertyNames": { "$ref": "#" }, + "const": true, + "enum": { + "type": "array", + "items": true, + "minItems": 1, + "uniqueItems": true + }, + "type": { + "anyOf": [ + { "$ref": "#/definitions/simpleTypes" }, + { + "type": "array", + "items": { "$ref": "#/definitions/simpleTypes" }, + "minItems": 1, + "uniqueItems": true + } + ] + }, + "format": { "type": "string" }, + "contentMediaType": { "type": "string" }, + "contentEncoding": { "type": "string" }, + "if": { "$ref": "#" }, + "then": { "$ref": "#" }, + "else": { "$ref": "#" }, + "allOf": { "$ref": "#/definitions/schemaArray" }, + "anyOf": { "$ref": "#/definitions/schemaArray" }, + "oneOf": { "$ref": "#/definitions/schemaArray" }, + "not": { "$ref": "#" } + }, + "default": true +} diff --git a/vendor/github.com/santhosh-tekuri/jsonschema/v6/metaschemas/draft/2019-09/meta/applicator b/vendor/github.com/santhosh-tekuri/jsonschema/v6/metaschemas/draft/2019-09/meta/applicator new file mode 100644 index 00000000..857d2d49 --- /dev/null +++ b/vendor/github.com/santhosh-tekuri/jsonschema/v6/metaschemas/draft/2019-09/meta/applicator @@ -0,0 +1,55 @@ +{ + "$schema": "https://json-schema.org/draft/2019-09/schema", + "$id": "https://json-schema.org/draft/2019-09/meta/applicator", + "$vocabulary": { + "https://json-schema.org/draft/2019-09/vocab/applicator": true + }, + "$recursiveAnchor": true, + "title": "Applicator vocabulary meta-schema", + "type": ["object", "boolean"], + "properties": { + "additionalItems": { "$recursiveRef": "#" }, + "unevaluatedItems": { "$recursiveRef": "#" }, + "items": { + "anyOf": [ + { "$recursiveRef": "#" }, + { "$ref": "#/$defs/schemaArray" } + ] + }, + "contains": { "$recursiveRef": "#" }, + "additionalProperties": { "$recursiveRef": "#" }, + "unevaluatedProperties": { "$recursiveRef": "#" }, + "properties": { + "type": "object", + "additionalProperties": { "$recursiveRef": "#" }, + "default": {} + }, + "patternProperties": { + "type": "object", + "additionalProperties": { "$recursiveRef": "#" }, + "propertyNames": { "format": "regex" }, + "default": {} + }, + "dependentSchemas": { + "type": "object", + "additionalProperties": { + "$recursiveRef": "#" + } + }, + "propertyNames": { "$recursiveRef": "#" }, + "if": { "$recursiveRef": "#" }, + "then": { "$recursiveRef": "#" }, + "else": { "$recursiveRef": "#" }, + "allOf": { "$ref": "#/$defs/schemaArray" }, + "anyOf": { "$ref": "#/$defs/schemaArray" }, + "oneOf": { "$ref": "#/$defs/schemaArray" }, + "not": { "$recursiveRef": "#" } + }, + "$defs": { + "schemaArray": { + "type": "array", + "minItems": 1, + "items": { "$recursiveRef": "#" } + } + } +} diff --git a/vendor/github.com/santhosh-tekuri/jsonschema/v6/metaschemas/draft/2019-09/meta/content b/vendor/github.com/santhosh-tekuri/jsonschema/v6/metaschemas/draft/2019-09/meta/content new file mode 100644 index 00000000..fa5d20b8 --- /dev/null +++ b/vendor/github.com/santhosh-tekuri/jsonschema/v6/metaschemas/draft/2019-09/meta/content @@ -0,0 +1,15 @@ +{ + "$schema": "https://json-schema.org/draft/2019-09/schema", + "$id": "https://json-schema.org/draft/2019-09/meta/content", + "$vocabulary": { + "https://json-schema.org/draft/2019-09/vocab/content": true + }, + "$recursiveAnchor": true, + "title": "Content vocabulary meta-schema", + "type": ["object", "boolean"], + "properties": { + "contentMediaType": { "type": "string" }, + "contentEncoding": { "type": "string" }, + "contentSchema": { "$recursiveRef": "#" } + } +} diff --git a/vendor/github.com/santhosh-tekuri/jsonschema/v6/metaschemas/draft/2019-09/meta/core b/vendor/github.com/santhosh-tekuri/jsonschema/v6/metaschemas/draft/2019-09/meta/core new file mode 100644 index 00000000..bf573198 --- /dev/null +++ b/vendor/github.com/santhosh-tekuri/jsonschema/v6/metaschemas/draft/2019-09/meta/core @@ -0,0 +1,56 @@ +{ + "$schema": "https://json-schema.org/draft/2019-09/schema", + "$id": "https://json-schema.org/draft/2019-09/meta/core", + "$vocabulary": { + "https://json-schema.org/draft/2019-09/vocab/core": true + }, + "$recursiveAnchor": true, + "title": "Core vocabulary meta-schema", + "type": ["object", "boolean"], + "properties": { + "$id": { + "type": "string", + "format": "uri-reference", + "$comment": "Non-empty fragments not allowed.", + "pattern": "^[^#]*#?$" + }, + "$schema": { + "type": "string", + "format": "uri" + }, + "$anchor": { + "type": "string", + "pattern": "^[A-Za-z][-A-Za-z0-9.:_]*$" + }, + "$ref": { + "type": "string", + "format": "uri-reference" + }, + "$recursiveRef": { + "type": "string", + "format": "uri-reference" + }, + "$recursiveAnchor": { + "type": "boolean", + "default": false + }, + "$vocabulary": { + "type": "object", + "propertyNames": { + "type": "string", + "format": "uri" + }, + "additionalProperties": { + "type": "boolean" + } + }, + "$comment": { + "type": "string" + }, + "$defs": { + "type": "object", + "additionalProperties": { "$recursiveRef": "#" }, + "default": {} + } + } +} diff --git a/vendor/github.com/santhosh-tekuri/jsonschema/v6/metaschemas/draft/2019-09/meta/format b/vendor/github.com/santhosh-tekuri/jsonschema/v6/metaschemas/draft/2019-09/meta/format new file mode 100644 index 00000000..fe553c23 --- /dev/null +++ b/vendor/github.com/santhosh-tekuri/jsonschema/v6/metaschemas/draft/2019-09/meta/format @@ -0,0 +1,13 @@ +{ + "$schema": "https://json-schema.org/draft/2019-09/schema", + "$id": "https://json-schema.org/draft/2019-09/meta/format", + "$vocabulary": { + "https://json-schema.org/draft/2019-09/vocab/format": true + }, + "$recursiveAnchor": true, + "title": "Format vocabulary meta-schema", + "type": ["object", "boolean"], + "properties": { + "format": { "type": "string" } + } +} diff --git a/vendor/github.com/santhosh-tekuri/jsonschema/v6/metaschemas/draft/2019-09/meta/meta-data b/vendor/github.com/santhosh-tekuri/jsonschema/v6/metaschemas/draft/2019-09/meta/meta-data new file mode 100644 index 00000000..5c95715c --- /dev/null +++ b/vendor/github.com/santhosh-tekuri/jsonschema/v6/metaschemas/draft/2019-09/meta/meta-data @@ -0,0 +1,35 @@ +{ + "$schema": "https://json-schema.org/draft/2019-09/schema", + "$id": "https://json-schema.org/draft/2019-09/meta/meta-data", + "$vocabulary": { + "https://json-schema.org/draft/2019-09/vocab/meta-data": true + }, + "$recursiveAnchor": true, + "title": "Meta-data vocabulary meta-schema", + "type": ["object", "boolean"], + "properties": { + "title": { + "type": "string" + }, + "description": { + "type": "string" + }, + "default": true, + "deprecated": { + "type": "boolean", + "default": false + }, + "readOnly": { + "type": "boolean", + "default": false + }, + "writeOnly": { + "type": "boolean", + "default": false + }, + "examples": { + "type": "array", + "items": true + } + } +} diff --git a/vendor/github.com/santhosh-tekuri/jsonschema/v6/metaschemas/draft/2019-09/meta/validation b/vendor/github.com/santhosh-tekuri/jsonschema/v6/metaschemas/draft/2019-09/meta/validation new file mode 100644 index 00000000..f3525e07 --- /dev/null +++ b/vendor/github.com/santhosh-tekuri/jsonschema/v6/metaschemas/draft/2019-09/meta/validation @@ -0,0 +1,97 @@ +{ + "$schema": "https://json-schema.org/draft/2019-09/schema", + "$id": "https://json-schema.org/draft/2019-09/meta/validation", + "$vocabulary": { + "https://json-schema.org/draft/2019-09/vocab/validation": true + }, + "$recursiveAnchor": true, + "title": "Validation vocabulary meta-schema", + "type": ["object", "boolean"], + "properties": { + "multipleOf": { + "type": "number", + "exclusiveMinimum": 0 + }, + "maximum": { + "type": "number" + }, + "exclusiveMaximum": { + "type": "number" + }, + "minimum": { + "type": "number" + }, + "exclusiveMinimum": { + "type": "number" + }, + "maxLength": { "$ref": "#/$defs/nonNegativeInteger" }, + "minLength": { "$ref": "#/$defs/nonNegativeIntegerDefault0" }, + "pattern": { + "type": "string", + "format": "regex" + }, + "maxItems": { "$ref": "#/$defs/nonNegativeInteger" }, + "minItems": { "$ref": "#/$defs/nonNegativeIntegerDefault0" }, + "uniqueItems": { + "type": "boolean", + "default": false + }, + "maxContains": { "$ref": "#/$defs/nonNegativeInteger" }, + "minContains": { + "$ref": "#/$defs/nonNegativeInteger", + "default": 1 + }, + "maxProperties": { "$ref": "#/$defs/nonNegativeInteger" }, + "minProperties": { "$ref": "#/$defs/nonNegativeIntegerDefault0" }, + "required": { "$ref": "#/$defs/stringArray" }, + "dependentRequired": { + "type": "object", + "additionalProperties": { + "$ref": "#/$defs/stringArray" + } + }, + "const": true, + "enum": { + "type": "array", + "items": true + }, + "type": { + "anyOf": [ + { "$ref": "#/$defs/simpleTypes" }, + { + "type": "array", + "items": { "$ref": "#/$defs/simpleTypes" }, + "minItems": 1, + "uniqueItems": true + } + ] + } + }, + "$defs": { + "nonNegativeInteger": { + "type": "integer", + "minimum": 0 + }, + "nonNegativeIntegerDefault0": { + "$ref": "#/$defs/nonNegativeInteger", + "default": 0 + }, + "simpleTypes": { + "enum": [ + "array", + "boolean", + "integer", + "null", + "number", + "object", + "string" + ] + }, + "stringArray": { + "type": "array", + "items": { "type": "string" }, + "uniqueItems": true, + "default": [] + } + } +} diff --git a/vendor/github.com/santhosh-tekuri/jsonschema/v6/metaschemas/draft/2019-09/schema b/vendor/github.com/santhosh-tekuri/jsonschema/v6/metaschemas/draft/2019-09/schema new file mode 100644 index 00000000..f433389b --- /dev/null +++ b/vendor/github.com/santhosh-tekuri/jsonschema/v6/metaschemas/draft/2019-09/schema @@ -0,0 +1,41 @@ +{ + "$schema": "https://json-schema.org/draft/2019-09/schema", + "$id": "https://json-schema.org/draft/2019-09/schema", + "$vocabulary": { + "https://json-schema.org/draft/2019-09/vocab/core": true, + "https://json-schema.org/draft/2019-09/vocab/applicator": true, + "https://json-schema.org/draft/2019-09/vocab/validation": true, + "https://json-schema.org/draft/2019-09/vocab/meta-data": true, + "https://json-schema.org/draft/2019-09/vocab/format": false, + "https://json-schema.org/draft/2019-09/vocab/content": true + }, + "$recursiveAnchor": true, + "title": "Core and Validation specifications meta-schema", + "allOf": [ + {"$ref": "meta/core"}, + {"$ref": "meta/applicator"}, + {"$ref": "meta/validation"}, + {"$ref": "meta/meta-data"}, + {"$ref": "meta/format"}, + {"$ref": "meta/content"} + ], + "type": ["object", "boolean"], + "properties": { + "definitions": { + "$comment": "While no longer an official keyword as it is replaced by $defs, this keyword is retained in the meta-schema to prevent incompatible extensions as it remains in common use.", + "type": "object", + "additionalProperties": { "$recursiveRef": "#" }, + "default": {} + }, + "dependencies": { + "$comment": "\"dependencies\" is no longer a keyword, but schema authors should avoid redefining it to facilitate a smooth transition to \"dependentSchemas\" and \"dependentRequired\"", + "type": "object", + "additionalProperties": { + "anyOf": [ + { "$recursiveRef": "#" }, + { "$ref": "meta/validation#/$defs/stringArray" } + ] + } + } + } +} diff --git a/vendor/github.com/santhosh-tekuri/jsonschema/v6/metaschemas/draft/2020-12/meta/applicator b/vendor/github.com/santhosh-tekuri/jsonschema/v6/metaschemas/draft/2020-12/meta/applicator new file mode 100644 index 00000000..0ef24edc --- /dev/null +++ b/vendor/github.com/santhosh-tekuri/jsonschema/v6/metaschemas/draft/2020-12/meta/applicator @@ -0,0 +1,47 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://json-schema.org/draft/2020-12/meta/applicator", + "$vocabulary": { + "https://json-schema.org/draft/2020-12/vocab/applicator": true + }, + "$dynamicAnchor": "meta", + "title": "Applicator vocabulary meta-schema", + "type": ["object", "boolean"], + "properties": { + "prefixItems": { "$ref": "#/$defs/schemaArray" }, + "items": { "$dynamicRef": "#meta" }, + "contains": { "$dynamicRef": "#meta" }, + "additionalProperties": { "$dynamicRef": "#meta" }, + "properties": { + "type": "object", + "additionalProperties": { "$dynamicRef": "#meta" }, + "default": {} + }, + "patternProperties": { + "type": "object", + "additionalProperties": { "$dynamicRef": "#meta" }, + "propertyNames": { "format": "regex" }, + "default": {} + }, + "dependentSchemas": { + "type": "object", + "additionalProperties": { "$dynamicRef": "#meta" }, + "default": {} + }, + "propertyNames": { "$dynamicRef": "#meta" }, + "if": { "$dynamicRef": "#meta" }, + "then": { "$dynamicRef": "#meta" }, + "else": { "$dynamicRef": "#meta" }, + "allOf": { "$ref": "#/$defs/schemaArray" }, + "anyOf": { "$ref": "#/$defs/schemaArray" }, + "oneOf": { "$ref": "#/$defs/schemaArray" }, + "not": { "$dynamicRef": "#meta" } + }, + "$defs": { + "schemaArray": { + "type": "array", + "minItems": 1, + "items": { "$dynamicRef": "#meta" } + } + } +} diff --git a/vendor/github.com/santhosh-tekuri/jsonschema/v6/metaschemas/draft/2020-12/meta/content b/vendor/github.com/santhosh-tekuri/jsonschema/v6/metaschemas/draft/2020-12/meta/content new file mode 100644 index 00000000..0330ff0a --- /dev/null +++ b/vendor/github.com/santhosh-tekuri/jsonschema/v6/metaschemas/draft/2020-12/meta/content @@ -0,0 +1,15 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://json-schema.org/draft/2020-12/meta/content", + "$vocabulary": { + "https://json-schema.org/draft/2020-12/vocab/content": true + }, + "$dynamicAnchor": "meta", + "title": "Content vocabulary meta-schema", + "type": ["object", "boolean"], + "properties": { + "contentEncoding": { "type": "string" }, + "contentMediaType": { "type": "string" }, + "contentSchema": { "$dynamicRef": "#meta" } + } +} diff --git a/vendor/github.com/santhosh-tekuri/jsonschema/v6/metaschemas/draft/2020-12/meta/core b/vendor/github.com/santhosh-tekuri/jsonschema/v6/metaschemas/draft/2020-12/meta/core new file mode 100644 index 00000000..c4de7005 --- /dev/null +++ b/vendor/github.com/santhosh-tekuri/jsonschema/v6/metaschemas/draft/2020-12/meta/core @@ -0,0 +1,50 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://json-schema.org/draft/2020-12/meta/core", + "$vocabulary": { + "https://json-schema.org/draft/2020-12/vocab/core": true + }, + "$dynamicAnchor": "meta", + "title": "Core vocabulary meta-schema", + "type": ["object", "boolean"], + "properties": { + "$id": { + "$ref": "#/$defs/uriReferenceString", + "$comment": "Non-empty fragments not allowed.", + "pattern": "^[^#]*#?$" + }, + "$schema": { "$ref": "#/$defs/uriString" }, + "$ref": { "$ref": "#/$defs/uriReferenceString" }, + "$anchor": { "$ref": "#/$defs/anchorString" }, + "$dynamicRef": { "$ref": "#/$defs/uriReferenceString" }, + "$dynamicAnchor": { "$ref": "#/$defs/anchorString" }, + "$vocabulary": { + "type": "object", + "propertyNames": { "$ref": "#/$defs/uriString" }, + "additionalProperties": { + "type": "boolean" + } + }, + "$comment": { + "type": "string" + }, + "$defs": { + "type": "object", + "additionalProperties": { "$dynamicRef": "#meta" } + } + }, + "$defs": { + "anchorString": { + "type": "string", + "pattern": "^[A-Za-z_][-A-Za-z0-9._]*$" + }, + "uriString": { + "type": "string", + "format": "uri" + }, + "uriReferenceString": { + "type": "string", + "format": "uri-reference" + } + } +} diff --git a/vendor/github.com/santhosh-tekuri/jsonschema/v6/metaschemas/draft/2020-12/meta/format-annotation b/vendor/github.com/santhosh-tekuri/jsonschema/v6/metaschemas/draft/2020-12/meta/format-annotation new file mode 100644 index 00000000..0aa07d1c --- /dev/null +++ b/vendor/github.com/santhosh-tekuri/jsonschema/v6/metaschemas/draft/2020-12/meta/format-annotation @@ -0,0 +1,13 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://json-schema.org/draft/2020-12/meta/format-annotation", + "$vocabulary": { + "https://json-schema.org/draft/2020-12/vocab/format-annotation": true + }, + "$dynamicAnchor": "meta", + "title": "Format vocabulary meta-schema for annotation results", + "type": ["object", "boolean"], + "properties": { + "format": { "type": "string" } + } +} diff --git a/vendor/github.com/santhosh-tekuri/jsonschema/v6/metaschemas/draft/2020-12/meta/format-assertion b/vendor/github.com/santhosh-tekuri/jsonschema/v6/metaschemas/draft/2020-12/meta/format-assertion new file mode 100644 index 00000000..38613bff --- /dev/null +++ b/vendor/github.com/santhosh-tekuri/jsonschema/v6/metaschemas/draft/2020-12/meta/format-assertion @@ -0,0 +1,13 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://json-schema.org/draft/2020-12/meta/format-assertion", + "$vocabulary": { + "https://json-schema.org/draft/2020-12/vocab/format-assertion": true + }, + "$dynamicAnchor": "meta", + "title": "Format vocabulary meta-schema for assertion results", + "type": ["object", "boolean"], + "properties": { + "format": { "type": "string" } + } +} diff --git a/vendor/github.com/santhosh-tekuri/jsonschema/v6/metaschemas/draft/2020-12/meta/meta-data b/vendor/github.com/santhosh-tekuri/jsonschema/v6/metaschemas/draft/2020-12/meta/meta-data new file mode 100644 index 00000000..30e28371 --- /dev/null +++ b/vendor/github.com/santhosh-tekuri/jsonschema/v6/metaschemas/draft/2020-12/meta/meta-data @@ -0,0 +1,35 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://json-schema.org/draft/2020-12/meta/meta-data", + "$vocabulary": { + "https://json-schema.org/draft/2020-12/vocab/meta-data": true + }, + "$dynamicAnchor": "meta", + "title": "Meta-data vocabulary meta-schema", + "type": ["object", "boolean"], + "properties": { + "title": { + "type": "string" + }, + "description": { + "type": "string" + }, + "default": true, + "deprecated": { + "type": "boolean", + "default": false + }, + "readOnly": { + "type": "boolean", + "default": false + }, + "writeOnly": { + "type": "boolean", + "default": false + }, + "examples": { + "type": "array", + "items": true + } + } +} diff --git a/vendor/github.com/santhosh-tekuri/jsonschema/v6/metaschemas/draft/2020-12/meta/unevaluated b/vendor/github.com/santhosh-tekuri/jsonschema/v6/metaschemas/draft/2020-12/meta/unevaluated new file mode 100644 index 00000000..e9e093d1 --- /dev/null +++ b/vendor/github.com/santhosh-tekuri/jsonschema/v6/metaschemas/draft/2020-12/meta/unevaluated @@ -0,0 +1,14 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://json-schema.org/draft/2020-12/meta/unevaluated", + "$vocabulary": { + "https://json-schema.org/draft/2020-12/vocab/unevaluated": true + }, + "$dynamicAnchor": "meta", + "title": "Unevaluated applicator vocabulary meta-schema", + "type": ["object", "boolean"], + "properties": { + "unevaluatedItems": { "$dynamicRef": "#meta" }, + "unevaluatedProperties": { "$dynamicRef": "#meta" } + } +} diff --git a/vendor/github.com/santhosh-tekuri/jsonschema/v6/metaschemas/draft/2020-12/meta/validation b/vendor/github.com/santhosh-tekuri/jsonschema/v6/metaschemas/draft/2020-12/meta/validation new file mode 100644 index 00000000..4e016ed2 --- /dev/null +++ b/vendor/github.com/santhosh-tekuri/jsonschema/v6/metaschemas/draft/2020-12/meta/validation @@ -0,0 +1,97 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://json-schema.org/draft/2020-12/meta/validation", + "$vocabulary": { + "https://json-schema.org/draft/2020-12/vocab/validation": true + }, + "$dynamicAnchor": "meta", + "title": "Validation vocabulary meta-schema", + "type": ["object", "boolean"], + "properties": { + "type": { + "anyOf": [ + { "$ref": "#/$defs/simpleTypes" }, + { + "type": "array", + "items": { "$ref": "#/$defs/simpleTypes" }, + "minItems": 1, + "uniqueItems": true + } + ] + }, + "const": true, + "enum": { + "type": "array", + "items": true + }, + "multipleOf": { + "type": "number", + "exclusiveMinimum": 0 + }, + "maximum": { + "type": "number" + }, + "exclusiveMaximum": { + "type": "number" + }, + "minimum": { + "type": "number" + }, + "exclusiveMinimum": { + "type": "number" + }, + "maxLength": { "$ref": "#/$defs/nonNegativeInteger" }, + "minLength": { "$ref": "#/$defs/nonNegativeIntegerDefault0" }, + "pattern": { + "type": "string", + "format": "regex" + }, + "maxItems": { "$ref": "#/$defs/nonNegativeInteger" }, + "minItems": { "$ref": "#/$defs/nonNegativeIntegerDefault0" }, + "uniqueItems": { + "type": "boolean", + "default": false + }, + "maxContains": { "$ref": "#/$defs/nonNegativeInteger" }, + "minContains": { + "$ref": "#/$defs/nonNegativeInteger", + "default": 1 + }, + "maxProperties": { "$ref": "#/$defs/nonNegativeInteger" }, + "minProperties": { "$ref": "#/$defs/nonNegativeIntegerDefault0" }, + "required": { "$ref": "#/$defs/stringArray" }, + "dependentRequired": { + "type": "object", + "additionalProperties": { + "$ref": "#/$defs/stringArray" + } + } + }, + "$defs": { + "nonNegativeInteger": { + "type": "integer", + "minimum": 0 + }, + "nonNegativeIntegerDefault0": { + "$ref": "#/$defs/nonNegativeInteger", + "default": 0 + }, + "simpleTypes": { + "enum": [ + "array", + "boolean", + "integer", + "null", + "number", + "object", + "string" + ] + }, + "stringArray": { + "type": "array", + "items": { "type": "string" }, + "uniqueItems": true, + "default": [] + } + } +} diff --git a/vendor/github.com/santhosh-tekuri/jsonschema/v6/metaschemas/draft/2020-12/schema b/vendor/github.com/santhosh-tekuri/jsonschema/v6/metaschemas/draft/2020-12/schema new file mode 100644 index 00000000..364f8ada --- /dev/null +++ b/vendor/github.com/santhosh-tekuri/jsonschema/v6/metaschemas/draft/2020-12/schema @@ -0,0 +1,57 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://json-schema.org/draft/2020-12/schema", + "$vocabulary": { + "https://json-schema.org/draft/2020-12/vocab/core": true, + "https://json-schema.org/draft/2020-12/vocab/applicator": true, + "https://json-schema.org/draft/2020-12/vocab/unevaluated": true, + "https://json-schema.org/draft/2020-12/vocab/validation": true, + "https://json-schema.org/draft/2020-12/vocab/meta-data": true, + "https://json-schema.org/draft/2020-12/vocab/format-annotation": true, + "https://json-schema.org/draft/2020-12/vocab/content": true + }, + "$dynamicAnchor": "meta", + "title": "Core and Validation specifications meta-schema", + "allOf": [ + {"$ref": "meta/core"}, + {"$ref": "meta/applicator"}, + {"$ref": "meta/unevaluated"}, + {"$ref": "meta/validation"}, + {"$ref": "meta/meta-data"}, + {"$ref": "meta/format-annotation"}, + {"$ref": "meta/content"} + ], + "type": ["object", "boolean"], + "$comment": "This meta-schema also defines keywords that have appeared in previous drafts in order to prevent incompatible extensions as they remain in common use.", + "properties": { + "definitions": { + "$comment": "\"definitions\" has been replaced by \"$defs\".", + "type": "object", + "additionalProperties": { "$dynamicRef": "#meta" }, + "deprecated": true, + "default": {} + }, + "dependencies": { + "$comment": "\"dependencies\" has been split and replaced by \"dependentSchemas\" and \"dependentRequired\" in order to serve their differing semantics.", + "type": "object", + "additionalProperties": { + "anyOf": [ + { "$dynamicRef": "#meta" }, + { "$ref": "meta/validation#/$defs/stringArray" } + ] + }, + "deprecated": true, + "default": {} + }, + "$recursiveAnchor": { + "$comment": "\"$recursiveAnchor\" has been replaced by \"$dynamicAnchor\".", + "$ref": "meta/core#/$defs/anchorString", + "deprecated": true + }, + "$recursiveRef": { + "$comment": "\"$recursiveRef\" has been replaced by \"$dynamicRef\".", + "$ref": "meta/core#/$defs/uriReferenceString", + "deprecated": true + } + } +} diff --git a/vendor/github.com/santhosh-tekuri/jsonschema/v6/objcompiler.go b/vendor/github.com/santhosh-tekuri/jsonschema/v6/objcompiler.go new file mode 100644 index 00000000..f1494b13 --- /dev/null +++ b/vendor/github.com/santhosh-tekuri/jsonschema/v6/objcompiler.go @@ -0,0 +1,549 @@ +package jsonschema + +import ( + "encoding/json" + "fmt" + "math/big" + "strconv" +) + +type objCompiler struct { + c *Compiler + obj map[string]any + up urlPtr + r *root + res *resource + q *queue +} + +func (c *objCompiler) compile(s *Schema) error { + // id -- + if id := c.res.dialect.draft.getID(c.obj); id != "" { + s.ID = id + } + + // anchor -- + if s.DraftVersion < 2019 { + // anchor is specified in id + id := c.string(c.res.dialect.draft.id) + if id != "" { + _, f := split(id) + if f != "" { + var err error + s.Anchor, err = decode(f) + if err != nil { + return &ParseAnchorError{URL: s.Location} + } + } + } + } else { + s.Anchor = c.string("$anchor") + } + + if err := c.compileDraft4(s); err != nil { + return err + } + if s.DraftVersion >= 6 { + if err := c.compileDraft6(s); err != nil { + return err + } + } + if s.DraftVersion >= 7 { + if err := c.compileDraft7(s); err != nil { + return err + } + } + if s.DraftVersion >= 2019 { + if err := c.compileDraft2019(s); err != nil { + return err + } + } + if s.DraftVersion >= 2020 { + if err := c.compileDraft2020(s); err != nil { + return err + } + } + + // vocabularies + vocabs := c.res.dialect.activeVocabs(c.c.roots.assertVocabs, c.c.roots.vocabularies) + for _, vocab := range vocabs { + v := c.c.roots.vocabularies[vocab] + if v == nil { + continue + } + ext, err := v.Compile(&CompilerContext{c}, c.obj) + if err != nil { + return err + } + if ext != nil { + s.Extensions = append(s.Extensions, ext) + } + } + + return nil +} + +func (c *objCompiler) compileDraft4(s *Schema) error { + var err error + + if c.hasVocab("core") { + if s.Ref, err = c.enqueueRef("$ref"); err != nil { + return err + } + if s.DraftVersion < 2019 && s.Ref != nil { + // All other properties in a "$ref" object MUST be ignored + return nil + } + } + + if c.hasVocab("applicator") { + s.AllOf = c.enqueueArr("allOf") + s.AnyOf = c.enqueueArr("anyOf") + s.OneOf = c.enqueueArr("oneOf") + s.Not = c.enqueueProp("not") + + if s.DraftVersion < 2020 { + if items, ok := c.obj["items"]; ok { + if _, ok := items.([]any); ok { + s.Items = c.enqueueArr("items") + s.AdditionalItems = c.enqueueAdditional("additionalItems") + } else { + s.Items = c.enqueueProp("items") + } + } + } + + s.Properties = c.enqueueMap("properties") + if m := c.enqueueMap("patternProperties"); m != nil { + s.PatternProperties = map[Regexp]*Schema{} + for pname, sch := range m { + re, err := c.c.roots.regexpEngine(pname) + if err != nil { + return &InvalidRegexError{c.up.format("patternProperties"), pname, err} + } + s.PatternProperties[re] = sch + } + } + s.AdditionalProperties = c.enqueueAdditional("additionalProperties") + + if m := c.objVal("dependencies"); m != nil { + s.Dependencies = map[string]any{} + for pname, pvalue := range m { + if arr, ok := pvalue.([]any); ok { + s.Dependencies[pname] = toStrings(arr) + } else { + ptr := c.up.ptr.append2("dependencies", pname) + s.Dependencies[pname] = c.enqueuePtr(ptr) + } + } + } + } + + if c.hasVocab("validation") { + if t, ok := c.obj["type"]; ok { + s.Types = newTypes(t) + } + if arr := c.arrVal("enum"); arr != nil { + s.Enum = newEnum(arr) + } + s.MultipleOf = c.numVal("multipleOf") + s.Maximum = c.numVal("maximum") + if c.boolean("exclusiveMaximum") { + s.ExclusiveMaximum = s.Maximum + s.Maximum = nil + } else { + s.ExclusiveMaximum = c.numVal("exclusiveMaximum") + } + s.Minimum = c.numVal("minimum") + if c.boolean("exclusiveMinimum") { + s.ExclusiveMinimum = s.Minimum + s.Minimum = nil + } else { + s.ExclusiveMinimum = c.numVal("exclusiveMinimum") + } + + s.MinLength = c.intVal("minLength") + s.MaxLength = c.intVal("maxLength") + if pat := c.strVal("pattern"); pat != nil { + s.Pattern, err = c.c.roots.regexpEngine(*pat) + if err != nil { + return &InvalidRegexError{c.up.format("pattern"), *pat, err} + } + } + + s.MinItems = c.intVal("minItems") + s.MaxItems = c.intVal("maxItems") + s.UniqueItems = c.boolean("uniqueItems") + + s.MaxProperties = c.intVal("maxProperties") + s.MinProperties = c.intVal("minProperties") + if arr := c.arrVal("required"); arr != nil { + s.Required = toStrings(arr) + } + } + + // format -- + if c.assertFormat(s.DraftVersion) { + if f := c.strVal("format"); f != nil { + if *f == "regex" { + s.Format = &Format{ + Name: "regex", + Validate: c.c.roots.regexpEngine.validate, + } + } else { + s.Format = c.c.formats[*f] + if s.Format == nil { + s.Format = formats[*f] + } + } + } + } + + // annotations -- + s.Title = c.string("title") + s.Description = c.string("description") + if v, ok := c.obj["default"]; ok { + s.Default = &v + } + + return nil +} + +func (c *objCompiler) compileDraft6(s *Schema) error { + if c.hasVocab("applicator") { + s.Contains = c.enqueueProp("contains") + s.PropertyNames = c.enqueueProp("propertyNames") + } + if c.hasVocab("validation") { + if v, ok := c.obj["const"]; ok { + s.Const = &v + } + } + return nil +} + +func (c *objCompiler) compileDraft7(s *Schema) error { + if c.hasVocab("applicator") { + s.If = c.enqueueProp("if") + if s.If != nil { + b := c.boolVal("if") + if b == nil || *b { + s.Then = c.enqueueProp("then") + } + if b == nil || !*b { + s.Else = c.enqueueProp("else") + } + } + } + + if c.c.assertContent { + if ce := c.strVal("contentEncoding"); ce != nil { + s.ContentEncoding = c.c.decoders[*ce] + if s.ContentEncoding == nil { + s.ContentEncoding = decoders[*ce] + } + } + if cm := c.strVal("contentMediaType"); cm != nil { + s.ContentMediaType = c.c.mediaTypes[*cm] + if s.ContentMediaType == nil { + s.ContentMediaType = mediaTypes[*cm] + } + } + } + + // annotations -- + s.Comment = c.string("$comment") + s.ReadOnly = c.boolean("readOnly") + s.WriteOnly = c.boolean("writeOnly") + if arr, ok := c.obj["examples"].([]any); ok { + s.Examples = arr + } + + return nil +} + +func (c *objCompiler) compileDraft2019(s *Schema) error { + var err error + + if c.hasVocab("core") { + if s.RecursiveRef, err = c.enqueueRef("$recursiveRef"); err != nil { + return err + } + s.RecursiveAnchor = c.boolean("$recursiveAnchor") + } + + if c.hasVocab("validation") { + if s.Contains != nil { + s.MinContains = c.intVal("minContains") + s.MaxContains = c.intVal("maxContains") + } + if m := c.objVal("dependentRequired"); m != nil { + s.DependentRequired = map[string][]string{} + for pname, pvalue := range m { + if arr, ok := pvalue.([]any); ok { + s.DependentRequired[pname] = toStrings(arr) + } + } + } + } + + if c.hasVocab("applicator") { + s.DependentSchemas = c.enqueueMap("dependentSchemas") + } + + var unevaluated bool + if s.DraftVersion == 2019 { + unevaluated = c.hasVocab("applicator") + } else { + unevaluated = c.hasVocab("unevaluated") + } + if unevaluated { + s.UnevaluatedItems = c.enqueueProp("unevaluatedItems") + s.UnevaluatedProperties = c.enqueueProp("unevaluatedProperties") + } + + if c.c.assertContent { + if s.ContentMediaType != nil && s.ContentMediaType.UnmarshalJSON != nil { + s.ContentSchema = c.enqueueProp("contentSchema") + } + } + + // annotations -- + s.Deprecated = c.boolean("deprecated") + + return nil +} + +func (c *objCompiler) compileDraft2020(s *Schema) error { + if c.hasVocab("core") { + sch, err := c.enqueueRef("$dynamicRef") + if err != nil { + return err + } + if sch != nil { + dref := c.strVal("$dynamicRef") + _, frag, err := splitFragment(*dref) + if err != nil { + return err + } + var anch string + if anchor, ok := frag.convert().(anchor); ok { + anch = string(anchor) + } + s.DynamicRef = &DynamicRef{sch, anch} + } + s.DynamicAnchor = c.string("$dynamicAnchor") + } + + if c.hasVocab("applicator") { + s.PrefixItems = c.enqueueArr("prefixItems") + s.Items2020 = c.enqueueProp("items") + } + + return nil +} + +// enqueue helpers -- + +func (c *objCompiler) enqueuePtr(ptr jsonPointer) *Schema { + up := urlPtr{c.up.url, ptr} + return c.c.enqueue(c.q, up) +} + +func (c *objCompiler) enqueueRef(pname string) (*Schema, error) { + ref := c.strVal(pname) + if ref == nil { + return nil, nil + } + baseURL := c.res.id + // baseURL := c.r.baseURL(c.up.ptr) + uf, err := baseURL.join(*ref) + if err != nil { + return nil, err + } + + up, err := c.r.resolve(*uf) + if err != nil { + return nil, err + } + if up != nil { + // local ref + return c.enqueuePtr(up.ptr), nil + } + + // remote ref + up_, err := c.c.roots.resolveFragment(*uf) + if err != nil { + return nil, err + } + return c.c.enqueue(c.q, up_), nil +} + +func (c *objCompiler) enqueueProp(pname string) *Schema { + if _, ok := c.obj[pname]; !ok { + return nil + } + ptr := c.up.ptr.append(pname) + return c.enqueuePtr(ptr) +} + +func (c *objCompiler) enqueueArr(pname string) []*Schema { + arr := c.arrVal(pname) + if arr == nil { + return nil + } + sch := make([]*Schema, len(arr)) + for i := range arr { + ptr := c.up.ptr.append2(pname, strconv.Itoa(i)) + sch[i] = c.enqueuePtr(ptr) + } + return sch +} + +func (c *objCompiler) enqueueMap(pname string) map[string]*Schema { + obj := c.objVal(pname) + if obj == nil { + return nil + } + sch := make(map[string]*Schema) + for k := range obj { + ptr := c.up.ptr.append2(pname, k) + sch[k] = c.enqueuePtr(ptr) + } + return sch +} + +func (c *objCompiler) enqueueAdditional(pname string) any { + if b := c.boolVal(pname); b != nil { + return *b + } + if sch := c.enqueueProp(pname); sch != nil { + return sch + } + return nil +} + +// -- + +func (c *objCompiler) hasVocab(name string) bool { + return c.res.dialect.hasVocab(name) +} + +func (c *objCompiler) assertFormat(draftVersion int) bool { + if c.c.assertFormat || draftVersion < 2019 { + return true + } + if draftVersion == 2019 { + return c.hasVocab("format") + } else { + return c.hasVocab("format-assertion") + } +} + +// value helpers -- + +func (c *objCompiler) boolVal(pname string) *bool { + v, ok := c.obj[pname] + if !ok { + return nil + } + b, ok := v.(bool) + if !ok { + return nil + } + return &b +} + +func (c *objCompiler) boolean(pname string) bool { + b := c.boolVal(pname) + return b != nil && *b +} + +func (c *objCompiler) strVal(pname string) *string { + v, ok := c.obj[pname] + if !ok { + return nil + } + s, ok := v.(string) + if !ok { + return nil + } + return &s +} + +func (c *objCompiler) string(pname string) string { + if s := c.strVal(pname); s != nil { + return *s + } + return "" +} + +func (c *objCompiler) numVal(pname string) *big.Rat { + v, ok := c.obj[pname] + if !ok { + return nil + } + switch v.(type) { + case json.Number, float32, float64, int, int8, int16, int32, int64, uint, uint8, uint16, uint32, uint64: + if n, ok := new(big.Rat).SetString(fmt.Sprint(v)); ok { + return n + } + } + return nil +} + +func (c *objCompiler) intVal(pname string) *int { + if n := c.numVal(pname); n != nil && n.IsInt() { + n := int(n.Num().Int64()) + return &n + } + return nil +} + +func (c *objCompiler) objVal(pname string) map[string]any { + v, ok := c.obj[pname] + if !ok { + return nil + } + obj, ok := v.(map[string]any) + if !ok { + return nil + } + return obj +} + +func (c *objCompiler) arrVal(pname string) []any { + v, ok := c.obj[pname] + if !ok { + return nil + } + arr, ok := v.([]any) + if !ok { + return nil + } + return arr +} + +// -- + +type InvalidRegexError struct { + URL string + Regex string + Err error +} + +func (e *InvalidRegexError) Error() string { + return fmt.Sprintf("invalid regex %q at %q: %v", e.Regex, e.URL, e.Err) +} + +// -- + +func toStrings(arr []any) []string { + var strings []string + for _, item := range arr { + if s, ok := item.(string); ok { + strings = append(strings, s) + } + } + return strings +} diff --git a/vendor/github.com/santhosh-tekuri/jsonschema/v6/output.go b/vendor/github.com/santhosh-tekuri/jsonschema/v6/output.go new file mode 100644 index 00000000..69d3f26d --- /dev/null +++ b/vendor/github.com/santhosh-tekuri/jsonschema/v6/output.go @@ -0,0 +1,216 @@ +package jsonschema + +import ( + "encoding/json" + "fmt" + "strings" + + "github.com/santhosh-tekuri/jsonschema/v6/kind" + "golang.org/x/text/language" + "golang.org/x/text/message" +) + +var defaultPrinter = message.NewPrinter(language.English) + +// format --- + +func (e *ValidationError) schemaURL() string { + if ref, ok := e.ErrorKind.(*kind.Reference); ok { + return ref.URL + } else { + return e.SchemaURL + } +} + +func (e *ValidationError) absoluteKeywordLocation() string { + var schemaURL string + var keywordPath []string + if ref, ok := e.ErrorKind.(*kind.Reference); ok { + schemaURL = ref.URL + keywordPath = nil + } else { + schemaURL = e.SchemaURL + keywordPath = e.ErrorKind.KeywordPath() + } + return fmt.Sprintf("%s%s", schemaURL, encode(jsonPtr(keywordPath))) +} + +func (e *ValidationError) skip() bool { + if len(e.Causes) == 1 { + _, ok := e.ErrorKind.(*kind.Reference) + return ok + } + return false +} + +func (e *ValidationError) display(sb *strings.Builder, verbose bool, indent int, absKwLoc string, p *message.Printer) { + if !e.skip() { + if indent > 0 { + sb.WriteByte('\n') + for i := 0; i < indent-1; i++ { + sb.WriteString(" ") + } + sb.WriteString("- ") + } + indent = indent + 1 + + prevAbsKwLoc := absKwLoc + absKwLoc = e.absoluteKeywordLocation() + + if _, ok := e.ErrorKind.(*kind.Schema); ok { + sb.WriteString(e.ErrorKind.LocalizedString(p)) + } else { + sb.WriteString(p.Sprintf("at %s", quote(jsonPtr(e.InstanceLocation)))) + if verbose { + schLoc := absKwLoc + if prevAbsKwLoc != "" { + pu, _ := split(prevAbsKwLoc) + u, f := split(absKwLoc) + if u == pu { + schLoc = fmt.Sprintf("S#%s", f) + } + } + fmt.Fprintf(sb, " [%s]", schLoc) + } + fmt.Fprintf(sb, ": %s", e.ErrorKind.LocalizedString(p)) + } + } + for _, cause := range e.Causes { + cause.display(sb, verbose, indent, absKwLoc, p) + } +} + +func (e *ValidationError) Error() string { + return e.LocalizedError(defaultPrinter) +} + +func (e *ValidationError) LocalizedError(p *message.Printer) string { + var sb strings.Builder + e.display(&sb, false, 0, "", p) + return sb.String() +} + +func (e *ValidationError) GoString() string { + return e.LocalizedGoString(defaultPrinter) +} + +func (e *ValidationError) LocalizedGoString(p *message.Printer) string { + var sb strings.Builder + e.display(&sb, true, 0, "", p) + return sb.String() +} + +func jsonPtr(tokens []string) string { + var sb strings.Builder + for _, tok := range tokens { + sb.WriteByte('/') + sb.WriteString(escape(tok)) + } + return sb.String() +} + +// -- + +// Flag is output format with simple boolean property valid. +type FlagOutput struct { + Valid bool `json:"valid"` +} + +// The `Flag` output format, merely the boolean result. +func (e *ValidationError) FlagOutput() *FlagOutput { + return &FlagOutput{Valid: false} +} + +// -- + +type OutputUnit struct { + Valid bool `json:"valid"` + KeywordLocation string `json:"keywordLocation"` + AbsoluteKeywordLocation string `json:"AbsoluteKeywordLocation,omitempty"` + InstanceLocation string `json:"instanceLocation"` + Error *OutputError `json:"error,omitempty"` + Errors []OutputUnit `json:"errors,omitempty"` +} + +type OutputError struct { + Kind ErrorKind + p *message.Printer +} + +func (k OutputError) String() string { + return k.Kind.LocalizedString(k.p) +} + +func (k OutputError) MarshalJSON() ([]byte, error) { + return json.Marshal(k.Kind.LocalizedString(k.p)) +} + +// The `Basic` structure, a flat list of output units. +func (e *ValidationError) BasicOutput() *OutputUnit { + return e.LocalizedBasicOutput(defaultPrinter) +} + +func (e *ValidationError) LocalizedBasicOutput(p *message.Printer) *OutputUnit { + out := e.output(true, false, "", "", p) + return &out +} + +// The `Detailed` structure, based on the schema. +func (e *ValidationError) DetailedOutput() *OutputUnit { + return e.LocalizedDetailedOutput(defaultPrinter) +} + +func (e *ValidationError) LocalizedDetailedOutput(p *message.Printer) *OutputUnit { + out := e.output(false, false, "", "", p) + return &out +} + +func (e *ValidationError) output(flatten, inRef bool, schemaURL, kwLoc string, p *message.Printer) OutputUnit { + if !inRef { + if _, ok := e.ErrorKind.(*kind.Reference); ok { + inRef = true + } + } + if schemaURL != "" { + kwLoc += e.SchemaURL[len(schemaURL):] + if ref, ok := e.ErrorKind.(*kind.Reference); ok { + kwLoc += jsonPtr(ref.KeywordPath()) + } + } + schemaURL = e.schemaURL() + + keywordLocation := kwLoc + if _, ok := e.ErrorKind.(*kind.Reference); !ok { + keywordLocation += jsonPtr(e.ErrorKind.KeywordPath()) + } + + out := OutputUnit{ + Valid: false, + InstanceLocation: jsonPtr(e.InstanceLocation), + KeywordLocation: keywordLocation, + } + if inRef { + out.AbsoluteKeywordLocation = e.absoluteKeywordLocation() + } + for _, cause := range e.Causes { + causeOut := cause.output(flatten, inRef, schemaURL, kwLoc, p) + if cause.skip() { + causeOut = causeOut.Errors[0] + } + if flatten { + errors := causeOut.Errors + causeOut.Errors = nil + causeOut.Error = &OutputError{cause.ErrorKind, p} + out.Errors = append(out.Errors, causeOut) + if len(errors) > 0 { + out.Errors = append(out.Errors, errors...) + } + } else { + out.Errors = append(out.Errors, causeOut) + } + } + if len(out.Errors) == 0 { + out.Error = &OutputError{e.ErrorKind, p} + } + return out +} diff --git a/vendor/github.com/santhosh-tekuri/jsonschema/v6/position.go b/vendor/github.com/santhosh-tekuri/jsonschema/v6/position.go new file mode 100644 index 00000000..576a2a47 --- /dev/null +++ b/vendor/github.com/santhosh-tekuri/jsonschema/v6/position.go @@ -0,0 +1,142 @@ +package jsonschema + +import ( + "strconv" + "strings" +) + +// Position tells possible tokens in json. +type Position interface { + collect(v any, ptr jsonPointer) map[jsonPointer]any +} + +// -- + +type AllProp struct{} + +func (AllProp) collect(v any, ptr jsonPointer) map[jsonPointer]any { + obj, ok := v.(map[string]any) + if !ok { + return nil + } + m := map[jsonPointer]any{} + for pname, pvalue := range obj { + m[ptr.append(pname)] = pvalue + } + return m +} + +// -- + +type AllItem struct{} + +func (AllItem) collect(v any, ptr jsonPointer) map[jsonPointer]any { + arr, ok := v.([]any) + if !ok { + return nil + } + m := map[jsonPointer]any{} + for i, item := range arr { + m[ptr.append(strconv.Itoa(i))] = item + } + return m +} + +// -- + +type Prop string + +func (p Prop) collect(v any, ptr jsonPointer) map[jsonPointer]any { + obj, ok := v.(map[string]any) + if !ok { + return nil + } + pvalue, ok := obj[string(p)] + if !ok { + return nil + } + return map[jsonPointer]any{ + ptr.append(string(p)): pvalue, + } +} + +// -- + +type Item int + +func (i Item) collect(v any, ptr jsonPointer) map[jsonPointer]any { + arr, ok := v.([]any) + if !ok { + return nil + } + if i < 0 || int(i) >= len(arr) { + return nil + } + return map[jsonPointer]any{ + ptr.append(strconv.Itoa(int(i))): arr[int(i)], + } +} + +// -- + +// SchemaPath tells where to look for subschema inside keyword. +type SchemaPath []Position + +func schemaPath(path string) SchemaPath { + var sp SchemaPath + for _, tok := range strings.Split(path, "/") { + var pos Position + switch tok { + case "*": + pos = AllProp{} + case "[]": + pos = AllItem{} + default: + if i, err := strconv.Atoi(tok); err == nil { + pos = Item(i) + } else { + pos = Prop(tok) + } + } + sp = append(sp, pos) + } + return sp +} + +func (sp SchemaPath) collect(v any, ptr jsonPointer) map[jsonPointer]any { + if len(sp) == 0 { + return map[jsonPointer]any{ + ptr: v, + } + } + p, sp := sp[0], sp[1:] + m := p.collect(v, ptr) + mm := map[jsonPointer]any{} + for ptr, v := range m { + m = sp.collect(v, ptr) + for k, v := range m { + mm[k] = v + } + } + return mm +} + +func (sp SchemaPath) String() string { + var sb strings.Builder + for _, pos := range sp { + if sb.Len() != 0 { + sb.WriteByte('/') + } + switch pos := pos.(type) { + case AllProp: + sb.WriteString("*") + case AllItem: + sb.WriteString("[]") + case Prop: + sb.WriteString(string(pos)) + case Item: + sb.WriteString(strconv.Itoa(int(pos))) + } + } + return sb.String() +} diff --git a/vendor/github.com/santhosh-tekuri/jsonschema/v6/root.go b/vendor/github.com/santhosh-tekuri/jsonschema/v6/root.go new file mode 100644 index 00000000..a8b819ba --- /dev/null +++ b/vendor/github.com/santhosh-tekuri/jsonschema/v6/root.go @@ -0,0 +1,202 @@ +package jsonschema + +import ( + "fmt" + "slices" + "strings" +) + +type root struct { + url url + doc any + resources map[jsonPointer]*resource + subschemasProcessed map[jsonPointer]struct{} +} + +func (r *root) rootResource() *resource { + return r.resources[""] +} + +func (r *root) resource(ptr jsonPointer) *resource { + for { + if res, ok := r.resources[ptr]; ok { + return res + } + slash := strings.LastIndexByte(string(ptr), '/') + if slash == -1 { + break + } + ptr = ptr[:slash] + } + return r.rootResource() +} + +func (r *root) resolveFragmentIn(frag fragment, res *resource) (urlPtr, error) { + var ptr jsonPointer + switch f := frag.convert().(type) { + case jsonPointer: + ptr = res.ptr.concat(f) + case anchor: + aptr, ok := res.anchors[f] + if !ok { + return urlPtr{}, &AnchorNotFoundError{ + URL: r.url.String(), + Reference: (&urlFrag{res.id, frag}).String(), + } + } + ptr = aptr + } + return urlPtr{r.url, ptr}, nil +} + +func (r *root) resolveFragment(frag fragment) (urlPtr, error) { + return r.resolveFragmentIn(frag, r.rootResource()) +} + +// resolves urlFrag to urlPtr from root. +// returns nil if it is external. +func (r *root) resolve(uf urlFrag) (*urlPtr, error) { + var res *resource + if uf.url == r.url { + res = r.rootResource() + } else { + // look for resource with id==uf.url + for _, v := range r.resources { + if v.id == uf.url { + res = v + break + } + } + if res == nil { + return nil, nil // external url + } + } + up, err := r.resolveFragmentIn(uf.frag, res) + return &up, err +} + +func (r *root) collectAnchors(sch any, schPtr jsonPointer, res *resource) error { + obj, ok := sch.(map[string]any) + if !ok { + return nil + } + + addAnchor := func(anchor anchor) error { + ptr1, ok := res.anchors[anchor] + if ok { + if ptr1 == schPtr { + // anchor with same root_ptr already exists + return nil + } + return &DuplicateAnchorError{ + string(anchor), r.url.String(), string(ptr1), string(schPtr), + } + } + res.anchors[anchor] = schPtr + return nil + } + + if res.dialect.draft.version < 2019 { + if _, ok := obj["$ref"]; ok { + // All other properties in a "$ref" object MUST be ignored + return nil + } + // anchor is specified in id + if id, ok := strVal(obj, res.dialect.draft.id); ok { + _, frag, err := splitFragment(id) + if err != nil { + loc := urlPtr{r.url, schPtr} + return &ParseAnchorError{loc.String()} + } + if anchor, ok := frag.convert().(anchor); ok { + if err := addAnchor(anchor); err != nil { + return err + } + } + } + } + if res.dialect.draft.version >= 2019 { + if s, ok := strVal(obj, "$anchor"); ok { + if err := addAnchor(anchor(s)); err != nil { + return err + } + } + } + if res.dialect.draft.version >= 2020 { + if s, ok := strVal(obj, "$dynamicAnchor"); ok { + if err := addAnchor(anchor(s)); err != nil { + return err + } + res.dynamicAnchors = append(res.dynamicAnchors, anchor(s)) + } + } + + return nil +} + +func (r *root) clone() *root { + processed := map[jsonPointer]struct{}{} + for k := range r.subschemasProcessed { + processed[k] = struct{}{} + } + resources := map[jsonPointer]*resource{} + for k, v := range r.resources { + resources[k] = v.clone() + } + return &root{ + url: r.url, + doc: r.doc, + resources: resources, + subschemasProcessed: processed, + } +} + +// -- + +type resource struct { + ptr jsonPointer + id url + dialect dialect + anchors map[anchor]jsonPointer + dynamicAnchors []anchor +} + +func newResource(ptr jsonPointer, id url) *resource { + return &resource{ptr: ptr, id: id, anchors: make(map[anchor]jsonPointer)} +} + +func (res *resource) clone() *resource { + anchors := map[anchor]jsonPointer{} + for k, v := range res.anchors { + anchors[k] = v + } + return &resource{ + ptr: res.ptr, + id: res.id, + dialect: res.dialect, + anchors: anchors, + dynamicAnchors: slices.Clone(res.dynamicAnchors), + } +} + +//-- + +type UnsupportedVocabularyError struct { + URL string + Vocabulary string +} + +func (e *UnsupportedVocabularyError) Error() string { + return fmt.Sprintf("unsupported vocabulary %q in %q", e.Vocabulary, e.URL) +} + +// -- + +type AnchorNotFoundError struct { + URL string + Reference string +} + +func (e *AnchorNotFoundError) Error() string { + return fmt.Sprintf("anchor in %q not found in schema %q", e.Reference, e.URL) +} diff --git a/vendor/github.com/santhosh-tekuri/jsonschema/v6/roots.go b/vendor/github.com/santhosh-tekuri/jsonschema/v6/roots.go new file mode 100644 index 00000000..a8d0ef0c --- /dev/null +++ b/vendor/github.com/santhosh-tekuri/jsonschema/v6/roots.go @@ -0,0 +1,286 @@ +package jsonschema + +import ( + "fmt" + "strings" +) + +type roots struct { + defaultDraft *Draft + roots map[url]*root + loader defaultLoader + regexpEngine RegexpEngine + vocabularies map[string]*Vocabulary + assertVocabs bool +} + +func newRoots() *roots { + return &roots{ + defaultDraft: draftLatest, + roots: map[url]*root{}, + loader: defaultLoader{ + docs: map[url]any{}, + loader: FileLoader{}, + }, + regexpEngine: goRegexpCompile, + vocabularies: map[string]*Vocabulary{}, + } +} + +func (rr *roots) orLoad(u url) (*root, error) { + if r, ok := rr.roots[u]; ok { + return r, nil + } + doc, err := rr.loader.load(u) + if err != nil { + return nil, err + } + return rr.addRoot(u, doc) +} + +func (rr *roots) addRoot(u url, doc any) (*root, error) { + r := &root{ + url: u, + doc: doc, + resources: map[jsonPointer]*resource{}, + subschemasProcessed: map[jsonPointer]struct{}{}, + } + if err := rr.collectResources(r, doc, u, "", dialect{rr.defaultDraft, nil}); err != nil { + return nil, err + } + if !strings.HasPrefix(u.String(), "http://json-schema.org/") && + !strings.HasPrefix(u.String(), "https://json-schema.org/") { + if err := rr.validate(r, doc, ""); err != nil { + return nil, err + } + } + + rr.roots[u] = r + return r, nil +} + +func (rr *roots) resolveFragment(uf urlFrag) (urlPtr, error) { + r, err := rr.orLoad(uf.url) + if err != nil { + return urlPtr{}, err + } + return r.resolveFragment(uf.frag) +} + +func (rr *roots) collectResources(r *root, sch any, base url, schPtr jsonPointer, fallback dialect) error { + if _, ok := r.subschemasProcessed[schPtr]; ok { + return nil + } + if err := rr._collectResources(r, sch, base, schPtr, fallback); err != nil { + return err + } + r.subschemasProcessed[schPtr] = struct{}{} + return nil +} + +func (rr *roots) _collectResources(r *root, sch any, base url, schPtr jsonPointer, fallback dialect) error { + obj, ok := sch.(map[string]any) + if !ok { + if schPtr.isEmpty() { + // root resource + res := newResource(schPtr, base) + res.dialect = fallback + r.resources[schPtr] = res + } + return nil + } + + hasSchema := false + if sch, ok := obj["$schema"]; ok { + if _, ok := sch.(string); ok { + hasSchema = true + } + } + + draft, err := rr.loader.getDraft(urlPtr{r.url, schPtr}, sch, fallback.draft, map[url]struct{}{}) + if err != nil { + return err + } + id := draft.getID(obj) + if id == "" && !schPtr.isEmpty() { + // ignore $schema + draft = fallback.draft + hasSchema = false + id = draft.getID(obj) + } + + var res *resource + if id != "" { + uf, err := base.join(id) + if err != nil { + loc := urlPtr{r.url, schPtr} + return &ParseIDError{loc.String()} + } + base = uf.url + res = newResource(schPtr, base) + } else if schPtr.isEmpty() { + // root resource + res = newResource(schPtr, base) + } + + if res != nil { + found := false + for _, res := range r.resources { + if res.id == base { + found = true + if res.ptr != schPtr { + return &DuplicateIDError{base.String(), r.url.String(), string(schPtr), string(res.ptr)} + } + } + } + if !found { + if hasSchema { + vocabs, err := rr.loader.getMetaVocabs(sch, draft, rr.vocabularies) + if err != nil { + return err + } + res.dialect = dialect{draft, vocabs} + } else { + res.dialect = fallback + } + r.resources[schPtr] = res + } + } + + var baseRes *resource + for _, res := range r.resources { + if res.id == base { + baseRes = res + break + } + } + if baseRes == nil { + panic("baseres is nil") + } + + // found base resource + if err := r.collectAnchors(sch, schPtr, baseRes); err != nil { + return err + } + + // process subschemas + subschemas := map[jsonPointer]any{} + for _, sp := range draft.subschemas { + ss := sp.collect(obj, schPtr) + for k, v := range ss { + subschemas[k] = v + } + } + for _, vocab := range baseRes.dialect.activeVocabs(true, rr.vocabularies) { + if v := rr.vocabularies[vocab]; v != nil { + for _, sp := range v.Subschemas { + ss := sp.collect(obj, schPtr) + for k, v := range ss { + subschemas[k] = v + } + } + } + } + for ptr, v := range subschemas { + if err := rr.collectResources(r, v, base, ptr, baseRes.dialect); err != nil { + return err + } + } + + return nil +} + +func (rr *roots) ensureSubschema(up urlPtr) error { + r, err := rr.orLoad(up.url) + if err != nil { + return err + } + if _, ok := r.subschemasProcessed[up.ptr]; ok { + return nil + } + v, err := up.lookup(r.doc) + if err != nil { + return err + } + rClone := r.clone() + if err := rr.addSubschema(rClone, up.ptr); err != nil { + return err + } + if err := rr.validate(rClone, v, up.ptr); err != nil { + return err + } + rr.roots[r.url] = rClone + return nil +} + +func (rr *roots) addSubschema(r *root, ptr jsonPointer) error { + v, err := (&urlPtr{r.url, ptr}).lookup(r.doc) + if err != nil { + return err + } + base := r.resource(ptr) + baseURL := base.id + if err := rr.collectResources(r, v, baseURL, ptr, base.dialect); err != nil { + return err + } + + // collect anchors + if _, ok := r.resources[ptr]; !ok { + res := r.resource(ptr) + if err := r.collectAnchors(v, ptr, res); err != nil { + return err + } + } + return nil +} + +func (rr *roots) validate(r *root, v any, ptr jsonPointer) error { + dialect := r.resource(ptr).dialect + meta := dialect.getSchema(rr.assertVocabs, rr.vocabularies) + if err := meta.validate(v, rr.regexpEngine, meta, r.resources, rr.assertVocabs, rr.vocabularies); err != nil { + up := urlPtr{r.url, ptr} + return &SchemaValidationError{URL: up.String(), Err: err} + } + return nil +} + +// -- + +type InvalidMetaSchemaURLError struct { + URL string + Err error +} + +func (e *InvalidMetaSchemaURLError) Error() string { + return fmt.Sprintf("invalid $schema in %q: %v", e.URL, e.Err) +} + +// -- + +type UnsupportedDraftError struct { + URL string +} + +func (e *UnsupportedDraftError) Error() string { + return fmt.Sprintf("draft %q is not supported", e.URL) +} + +// -- + +type MetaSchemaCycleError struct { + URL string +} + +func (e *MetaSchemaCycleError) Error() string { + return fmt.Sprintf("cycle in resolving $schema in %q", e.URL) +} + +// -- + +type MetaSchemaMismatchError struct { + URL string +} + +func (e *MetaSchemaMismatchError) Error() string { + return fmt.Sprintf("$schema in %q does not match with $schema in root", e.URL) +} diff --git a/vendor/github.com/santhosh-tekuri/jsonschema/v6/schema.go b/vendor/github.com/santhosh-tekuri/jsonschema/v6/schema.go new file mode 100644 index 00000000..b4c1f37a --- /dev/null +++ b/vendor/github.com/santhosh-tekuri/jsonschema/v6/schema.go @@ -0,0 +1,254 @@ +package jsonschema + +import ( + "encoding/json" + "fmt" + "math/big" +) + +// Schema is the representation of a compiled +// jsonschema. +type Schema struct { + up urlPtr + resource *Schema + dynamicAnchors map[string]*Schema + allPropsEvaluated bool + allItemsEvaluated bool + numItemsEvaluated int + + DraftVersion int + Location string + + // type agnostic -- + Bool *bool // boolean schema + ID string + Ref *Schema + Anchor string + RecursiveRef *Schema + RecursiveAnchor bool + DynamicRef *DynamicRef + DynamicAnchor string // "" if not specified + Types *Types + Enum *Enum + Const *any + Not *Schema + AllOf []*Schema + AnyOf []*Schema + OneOf []*Schema + If *Schema + Then *Schema + Else *Schema + Format *Format + + // object -- + MaxProperties *int + MinProperties *int + Required []string + PropertyNames *Schema + Properties map[string]*Schema + PatternProperties map[Regexp]*Schema + AdditionalProperties any // nil or bool or *Schema + Dependencies map[string]any // value is []string or *Schema + DependentRequired map[string][]string + DependentSchemas map[string]*Schema + UnevaluatedProperties *Schema + + // array -- + MinItems *int + MaxItems *int + UniqueItems bool + Contains *Schema + MinContains *int + MaxContains *int + Items any // nil or []*Schema or *Schema + AdditionalItems any // nil or bool or *Schema + PrefixItems []*Schema + Items2020 *Schema + UnevaluatedItems *Schema + + // string -- + MinLength *int + MaxLength *int + Pattern Regexp + ContentEncoding *Decoder + ContentMediaType *MediaType + ContentSchema *Schema + + // number -- + Maximum *big.Rat + Minimum *big.Rat + ExclusiveMaximum *big.Rat + ExclusiveMinimum *big.Rat + MultipleOf *big.Rat + + Extensions []SchemaExt + + // annotations -- + Title string + Description string + Default *any + Comment string + ReadOnly bool + WriteOnly bool + Examples []any + Deprecated bool +} + +// -- + +type jsonType int + +const ( + invalidType jsonType = 0 + nullType jsonType = 1 << iota + booleanType + numberType + integerType + stringType + arrayType + objectType +) + +func typeOf(v any) jsonType { + switch v.(type) { + case nil: + return nullType + case bool: + return booleanType + case json.Number, float32, float64, int, int8, int16, int32, int64, uint, uint8, uint16, uint32, uint64: + return numberType + case string: + return stringType + case []any: + return arrayType + case map[string]any: + return objectType + default: + return invalidType + } +} + +func typeFromString(s string) jsonType { + switch s { + case "null": + return nullType + case "boolean": + return booleanType + case "number": + return numberType + case "integer": + return integerType + case "string": + return stringType + case "array": + return arrayType + case "object": + return objectType + } + return invalidType +} + +func (jt jsonType) String() string { + switch jt { + case nullType: + return "null" + case booleanType: + return "boolean" + case numberType: + return "number" + case integerType: + return "integer" + case stringType: + return "string" + case arrayType: + return "array" + case objectType: + return "object" + } + return "" +} + +// -- + +// Types encapsulates list of json value types. +type Types int + +func newTypes(v any) *Types { + var types Types + switch v := v.(type) { + case string: + types.Add(v) + case []any: + for _, item := range v { + if s, ok := item.(string); ok { + types.Add(s) + } + } + } + if types.IsEmpty() { + return nil + } + return &types +} + +func (tt Types) IsEmpty() bool { + return tt == 0 +} + +// Add specified json type. If typ is +// not valid json type it is ignored. +func (tt *Types) Add(typ string) { + tt.add(typeFromString(typ)) +} + +func (tt *Types) add(t jsonType) { + *tt = Types(int(*tt) | int(t)) +} + +func (tt Types) contains(t jsonType) bool { + return int(tt)&int(t) != 0 +} + +func (tt Types) ToStrings() []string { + types := []jsonType{ + nullType, booleanType, numberType, integerType, + stringType, arrayType, objectType, + } + var arr []string + for _, t := range types { + if tt.contains(t) { + arr = append(arr, t.String()) + } + } + return arr +} + +func (tt Types) String() string { + return fmt.Sprintf("%v", tt.ToStrings()) +} + +// -- + +type Enum struct { + Values []any + types Types +} + +func newEnum(arr []any) *Enum { + var types Types + for _, item := range arr { + types.add(typeOf(item)) + } + return &Enum{arr, types} +} + +// -- + +type DynamicRef struct { + Ref *Schema + Anchor string // "" if not specified +} + +func newSchema(up urlPtr) *Schema { + return &Schema{up: up, Location: up.String()} +} diff --git a/vendor/github.com/santhosh-tekuri/jsonschema/v6/util.go b/vendor/github.com/santhosh-tekuri/jsonschema/v6/util.go new file mode 100644 index 00000000..c6f8e775 --- /dev/null +++ b/vendor/github.com/santhosh-tekuri/jsonschema/v6/util.go @@ -0,0 +1,464 @@ +package jsonschema + +import ( + "encoding/json" + "fmt" + "hash/maphash" + "math/big" + gourl "net/url" + "path/filepath" + "runtime" + "slices" + "strconv" + "strings" + + "github.com/santhosh-tekuri/jsonschema/v6/kind" + "golang.org/x/text/message" +) + +// -- + +type url (string) + +func (u url) String() string { + return string(u) +} + +func (u url) join(ref string) (*urlFrag, error) { + base, err := gourl.Parse(string(u)) + if err != nil { + return nil, &ParseURLError{URL: u.String(), Err: err} + } + + ref, frag, err := splitFragment(ref) + if err != nil { + return nil, err + } + refURL, err := gourl.Parse(ref) + if err != nil { + return nil, &ParseURLError{URL: ref, Err: err} + } + resolved := base.ResolveReference(refURL) + + // see https://github.com/golang/go/issues/66084 (net/url: ResolveReference ignores Opaque value) + if !refURL.IsAbs() && base.Opaque != "" { + resolved.Opaque = base.Opaque + } + + return &urlFrag{url: url(resolved.String()), frag: frag}, nil +} + +// -- + +type jsonPointer string + +func escape(tok string) string { + tok = strings.ReplaceAll(tok, "~", "~0") + tok = strings.ReplaceAll(tok, "/", "~1") + return tok +} + +func unescape(tok string) (string, bool) { + tilde := strings.IndexByte(tok, '~') + if tilde == -1 { + return tok, true + } + sb := new(strings.Builder) + for { + sb.WriteString(tok[:tilde]) + tok = tok[tilde+1:] + if tok == "" { + return "", false + } + switch tok[0] { + case '0': + sb.WriteByte('~') + case '1': + sb.WriteByte('/') + default: + return "", false + } + tok = tok[1:] + tilde = strings.IndexByte(tok, '~') + if tilde == -1 { + sb.WriteString(tok) + break + } + } + return sb.String(), true +} + +func (ptr jsonPointer) isEmpty() bool { + return string(ptr) == "" +} + +func (ptr jsonPointer) concat(next jsonPointer) jsonPointer { + return jsonPointer(fmt.Sprintf("%s%s", ptr, next)) +} + +func (ptr jsonPointer) append(tok string) jsonPointer { + return jsonPointer(fmt.Sprintf("%s/%s", ptr, escape(tok))) +} + +func (ptr jsonPointer) append2(tok1, tok2 string) jsonPointer { + return jsonPointer(fmt.Sprintf("%s/%s/%s", ptr, escape(tok1), escape(tok2))) +} + +// -- + +type anchor string + +// -- + +type fragment string + +func decode(frag string) (string, error) { + return gourl.PathUnescape(frag) +} + +// avoids escaping /. +func encode(frag string) string { + var sb strings.Builder + for i, tok := range strings.Split(frag, "/") { + if i > 0 { + sb.WriteByte('/') + } + sb.WriteString(gourl.PathEscape(tok)) + } + return sb.String() +} + +func splitFragment(str string) (string, fragment, error) { + u, f := split(str) + f, err := decode(f) + if err != nil { + return "", fragment(""), &ParseURLError{URL: str, Err: err} + } + return u, fragment(f), nil +} + +func split(str string) (string, string) { + hash := strings.IndexByte(str, '#') + if hash == -1 { + return str, "" + } + return str[:hash], str[hash+1:] +} + +func (frag fragment) convert() any { + str := string(frag) + if str == "" || strings.HasPrefix(str, "/") { + return jsonPointer(str) + } + return anchor(str) +} + +// -- + +type urlFrag struct { + url url + frag fragment +} + +func startsWithWindowsDrive(s string) bool { + if s != "" && strings.HasPrefix(s[1:], `:\`) { + return (s[0] >= 'a' && s[0] <= 'z') || (s[0] >= 'A' && s[0] <= 'Z') + } + return false +} + +func absolute(input string) (*urlFrag, error) { + u, frag, err := splitFragment(input) + if err != nil { + return nil, err + } + + // if windows absolute file path, convert to file url + // because: net/url parses driver name as scheme + if runtime.GOOS == "windows" && startsWithWindowsDrive(u) { + u = "file:///" + filepath.ToSlash(u) + } + + gourl, err := gourl.Parse(u) + if err != nil { + return nil, &ParseURLError{URL: input, Err: err} + } + if gourl.IsAbs() { + return &urlFrag{url(u), frag}, nil + } + + // avoid filesystem api in wasm + if runtime.GOOS != "js" { + abs, err := filepath.Abs(u) + if err != nil { + return nil, &ParseURLError{URL: input, Err: err} + } + u = abs + } + if !strings.HasPrefix(u, "/") { + u = "/" + u + } + u = "file://" + filepath.ToSlash(u) + + _, err = gourl.Parse(u) + if err != nil { + return nil, &ParseURLError{URL: input, Err: err} + } + return &urlFrag{url: url(u), frag: frag}, nil +} + +func (uf *urlFrag) String() string { + return fmt.Sprintf("%s#%s", uf.url, encode(string(uf.frag))) +} + +// -- + +type urlPtr struct { + url url + ptr jsonPointer +} + +func (up *urlPtr) lookup(v any) (any, error) { + for _, tok := range strings.Split(string(up.ptr), "/")[1:] { + tok, ok := unescape(tok) + if !ok { + return nil, &InvalidJsonPointerError{up.String()} + } + switch val := v.(type) { + case map[string]any: + if pvalue, ok := val[tok]; ok { + v = pvalue + continue + } + case []any: + if index, err := strconv.Atoi(tok); err == nil { + if index >= 0 && index < len(val) { + v = val[index] + continue + } + } + } + return nil, &JSONPointerNotFoundError{up.String()} + } + return v, nil +} + +func (up *urlPtr) format(tok string) string { + return fmt.Sprintf("%s#%s/%s", up.url, encode(string(up.ptr)), encode(escape(tok))) +} + +func (up *urlPtr) String() string { + return fmt.Sprintf("%s#%s", up.url, encode(string(up.ptr))) +} + +// -- + +func minInt(i, j int) int { + if i < j { + return i + } + return j +} + +func strVal(obj map[string]any, prop string) (string, bool) { + v, ok := obj[prop] + if !ok { + return "", false + } + s, ok := v.(string) + return s, ok +} + +func isInteger(num any) bool { + rat, ok := new(big.Rat).SetString(fmt.Sprint(num)) + return ok && rat.IsInt() +} + +// quote returns single-quoted string. +// used for embedding quoted strings in json. +func quote(s string) string { + s = fmt.Sprintf("%q", s) + s = strings.ReplaceAll(s, `\"`, `"`) + s = strings.ReplaceAll(s, `'`, `\'`) + return "'" + s[1:len(s)-1] + "'" +} + +func equals(v1, v2 any) (bool, ErrorKind) { + switch v1 := v1.(type) { + case map[string]any: + v2, ok := v2.(map[string]any) + if !ok || len(v1) != len(v2) { + return false, nil + } + for k, val1 := range v1 { + val2, ok := v2[k] + if !ok { + return false, nil + } + if ok, k := equals(val1, val2); !ok || k != nil { + return ok, k + } + } + return true, nil + case []any: + v2, ok := v2.([]any) + if !ok || len(v1) != len(v2) { + return false, nil + } + for i := range v1 { + if ok, k := equals(v1[i], v2[i]); !ok || k != nil { + return ok, k + } + } + return true, nil + case nil: + return v2 == nil, nil + case bool: + v2, ok := v2.(bool) + return ok && v1 == v2, nil + case string: + v2, ok := v2.(string) + return ok && v1 == v2, nil + case json.Number, float32, float64, int, int8, int16, int32, int64, uint, uint8, uint16, uint32, uint64: + num1, ok1 := new(big.Rat).SetString(fmt.Sprint(v1)) + num2, ok2 := new(big.Rat).SetString(fmt.Sprint(v2)) + return ok1 && ok2 && num1.Cmp(num2) == 0, nil + default: + return false, &kind.InvalidJsonValue{Value: v1} + } +} + +func duplicates(arr []any) (int, int, ErrorKind) { + if len(arr) <= 20 { + for i := 1; i < len(arr); i++ { + for j := 0; j < i; j++ { + if ok, k := equals(arr[i], arr[j]); ok || k != nil { + return j, i, k + } + } + } + return -1, -1, nil + } + + m := make(map[uint64][]int) + h := new(maphash.Hash) + for i, item := range arr { + h.Reset() + writeHash(item, h) + hash := h.Sum64() + indexes, ok := m[hash] + if ok { + for _, j := range indexes { + if ok, k := equals(item, arr[j]); ok || k != nil { + return j, i, k + } + } + } + indexes = append(indexes, i) + m[hash] = indexes + } + return -1, -1, nil +} + +func writeHash(v any, h *maphash.Hash) ErrorKind { + switch v := v.(type) { + case map[string]any: + _ = h.WriteByte(0) + props := make([]string, 0, len(v)) + for prop := range v { + props = append(props, prop) + } + slices.Sort(props) + for _, prop := range props { + writeHash(prop, h) + writeHash(v[prop], h) + } + case []any: + _ = h.WriteByte(1) + for _, item := range v { + writeHash(item, h) + } + case nil: + _ = h.WriteByte(2) + case bool: + _ = h.WriteByte(3) + if v { + _ = h.WriteByte(1) + } else { + _ = h.WriteByte(0) + } + case string: + _ = h.WriteByte(4) + _, _ = h.WriteString(v) + case json.Number, float32, float64, int, int8, int16, int32, int64, uint, uint8, uint16, uint32, uint64: + _ = h.WriteByte(5) + num, _ := new(big.Rat).SetString(fmt.Sprint(v)) + _, _ = h.Write(num.Num().Bytes()) + _, _ = h.Write(num.Denom().Bytes()) + default: + return &kind.InvalidJsonValue{Value: v} + } + return nil +} + +// -- + +type ParseURLError struct { + URL string + Err error +} + +func (e *ParseURLError) Error() string { + return fmt.Sprintf("error in parsing %q: %v", e.URL, e.Err) +} + +// -- + +type InvalidJsonPointerError struct { + URL string +} + +func (e *InvalidJsonPointerError) Error() string { + return fmt.Sprintf("invalid json-pointer %q", e.URL) +} + +// -- + +type JSONPointerNotFoundError struct { + URL string +} + +func (e *JSONPointerNotFoundError) Error() string { + return fmt.Sprintf("json-pointer in %q not found", e.URL) +} + +// -- + +type SchemaValidationError struct { + URL string + Err error +} + +func (e *SchemaValidationError) Error() string { + return fmt.Sprintf("%q is not valid against metaschema: %v", e.URL, e.Err) +} + +// -- + +// LocalizableError is an error whose message is localizable. +func LocalizableError(format string, args ...any) error { + return &localizableError{format, args} +} + +type localizableError struct { + msg string + args []any +} + +func (e *localizableError) Error() string { + return fmt.Sprintf(e.msg, e.args...) +} + +func (e *localizableError) LocalizedError(p *message.Printer) string { + return p.Sprintf(e.msg, e.args...) +} diff --git a/vendor/github.com/santhosh-tekuri/jsonschema/v6/validator.go b/vendor/github.com/santhosh-tekuri/jsonschema/v6/validator.go new file mode 100644 index 00000000..e2ace37a --- /dev/null +++ b/vendor/github.com/santhosh-tekuri/jsonschema/v6/validator.go @@ -0,0 +1,975 @@ +package jsonschema + +import ( + "encoding/json" + "fmt" + "math/big" + "slices" + "strconv" + "unicode/utf8" + + "github.com/santhosh-tekuri/jsonschema/v6/kind" + "golang.org/x/text/message" +) + +func (sch *Schema) Validate(v any) error { + return sch.validate(v, nil, nil, nil, false, nil) +} + +func (sch *Schema) validate(v any, regexpEngine RegexpEngine, meta *Schema, resources map[jsonPointer]*resource, assertVocabs bool, vocabularies map[string]*Vocabulary) error { + vd := validator{ + v: v, + vloc: make([]string, 0, 8), + sch: sch, + scp: &scope{sch, "", 0, nil}, + uneval: unevalFrom(v, sch, false), + errors: nil, + boolResult: false, + regexpEngine: regexpEngine, + meta: meta, + resources: resources, + assertVocabs: assertVocabs, + vocabularies: vocabularies, + } + if _, err := vd.validate(); err != nil { + verr := err.(*ValidationError) + var causes []*ValidationError + if _, ok := verr.ErrorKind.(*kind.Group); ok { + causes = verr.Causes + } else { + causes = []*ValidationError{verr} + } + return &ValidationError{ + SchemaURL: sch.Location, + InstanceLocation: nil, + ErrorKind: &kind.Schema{Location: sch.Location}, + Causes: causes, + } + } + + return nil +} + +type validator struct { + v any + vloc []string + sch *Schema + scp *scope + uneval *uneval + errors []*ValidationError + boolResult bool // is interested to know valid or not (but not actuall error) + regexpEngine RegexpEngine + + // meta validation + meta *Schema // set only when validating with metaschema + resources map[jsonPointer]*resource // resources which should be validated with their dialect + assertVocabs bool + vocabularies map[string]*Vocabulary +} + +func (vd *validator) validate() (*uneval, error) { + s := vd.sch + v := vd.v + + // boolean -- + if s.Bool != nil { + if *s.Bool { + return vd.uneval, nil + } else { + return nil, vd.error(&kind.FalseSchema{}) + } + } + + // check cycle -- + if scp := vd.scp.checkCycle(); scp != nil { + return nil, vd.error(&kind.RefCycle{ + URL: s.Location, + KeywordLocation1: vd.scp.kwLoc(), + KeywordLocation2: scp.kwLoc(), + }) + } + + t := typeOf(v) + if t == invalidType { + return nil, vd.error(&kind.InvalidJsonValue{Value: v}) + } + + // type -- + if s.Types != nil && !s.Types.IsEmpty() { + matched := s.Types.contains(t) || (s.Types.contains(integerType) && t == numberType && isInteger(v)) + if !matched { + return nil, vd.error(&kind.Type{Got: t.String(), Want: s.Types.ToStrings()}) + } + } + + // const -- + if s.Const != nil { + ok, k := equals(v, *s.Const) + if k != nil { + return nil, vd.error(k) + } else if !ok { + return nil, vd.error(&kind.Const{Got: v, Want: *s.Const}) + } + } + + // enum -- + if s.Enum != nil { + matched := s.Enum.types.contains(typeOf(v)) + if matched { + matched = false + for _, item := range s.Enum.Values { + ok, k := equals(v, item) + if k != nil { + return nil, vd.error(k) + } else if ok { + matched = true + break + } + } + } + if !matched { + return nil, vd.error(&kind.Enum{Got: v, Want: s.Enum.Values}) + } + } + + // format -- + if s.Format != nil { + var err error + if s.Format.Name == "regex" && vd.regexpEngine != nil { + err = vd.regexpEngine.validate(v) + } else { + err = s.Format.Validate(v) + } + if err != nil { + return nil, vd.error(&kind.Format{Got: v, Want: s.Format.Name, Err: err}) + } + } + + // $ref -- + if s.Ref != nil { + err := vd.validateRef(s.Ref, "$ref") + if s.DraftVersion < 2019 { + return vd.uneval, err + } + if err != nil { + vd.addErr(err) + } + } + + // type specific validations -- + switch v := v.(type) { + case map[string]any: + vd.objValidate(v) + case []any: + vd.arrValidate(v) + case string: + vd.strValidate(v) + case json.Number, float32, float64, int, int8, int16, int32, int64, uint, uint8, uint16, uint32, uint64: + vd.numValidate(v) + } + + if len(vd.errors) == 0 || !vd.boolResult { + if s.DraftVersion >= 2019 { + vd.validateRefs() + } + vd.condValidate() + + for _, ext := range s.Extensions { + ext.Validate(&ValidatorContext{vd}, v) + } + + if s.DraftVersion >= 2019 { + vd.unevalValidate() + } + } + + switch len(vd.errors) { + case 0: + return vd.uneval, nil + case 1: + return nil, vd.errors[0] + default: + verr := vd.error(&kind.Group{}) + verr.Causes = vd.errors + return nil, verr + } +} + +func (vd *validator) objValidate(obj map[string]any) { + s := vd.sch + + // minProperties -- + if s.MinProperties != nil { + if len(obj) < *s.MinProperties { + vd.addError(&kind.MinProperties{Got: len(obj), Want: *s.MinProperties}) + } + } + + // maxProperties -- + if s.MaxProperties != nil { + if len(obj) > *s.MaxProperties { + vd.addError(&kind.MaxProperties{Got: len(obj), Want: *s.MaxProperties}) + } + } + + // required -- + if len(s.Required) > 0 { + if missing := vd.findMissing(obj, s.Required); missing != nil { + vd.addError(&kind.Required{Missing: missing}) + } + } + + if vd.boolResult && len(vd.errors) > 0 { + return + } + + // dependencies -- + for pname, dep := range s.Dependencies { + if _, ok := obj[pname]; ok { + switch dep := dep.(type) { + case []string: + if missing := vd.findMissing(obj, dep); missing != nil { + vd.addError(&kind.Dependency{Prop: pname, Missing: missing}) + } + case *Schema: + vd.addErr(vd.validateSelf(dep, "", false)) + } + } + } + + var additionalPros []string + for pname, pvalue := range obj { + if vd.boolResult && len(vd.errors) > 0 { + return + } + evaluated := false + + // properties -- + if sch, ok := s.Properties[pname]; ok { + evaluated = true + vd.addErr(vd.validateVal(sch, pvalue, pname)) + } + + // patternProperties -- + for regex, sch := range s.PatternProperties { + if regex.MatchString(pname) { + evaluated = true + vd.addErr(vd.validateVal(sch, pvalue, pname)) + } + } + + if !evaluated && s.AdditionalProperties != nil { + evaluated = true + switch additional := s.AdditionalProperties.(type) { + case bool: + if !additional { + additionalPros = append(additionalPros, pname) + } + case *Schema: + vd.addErr(vd.validateVal(additional, pvalue, pname)) + } + } + + if evaluated { + delete(vd.uneval.props, pname) + } + } + if len(additionalPros) > 0 { + vd.addError(&kind.AdditionalProperties{Properties: additionalPros}) + } + + if s.DraftVersion == 4 { + return + } + + // propertyNames -- + if s.PropertyNames != nil { + for pname := range obj { + sch, meta, resources := s.PropertyNames, vd.meta, vd.resources + res := vd.metaResource(sch) + if res != nil { + meta = res.dialect.getSchema(vd.assertVocabs, vd.vocabularies) + sch = meta + } + if err := sch.validate(pname, vd.regexpEngine, meta, resources, vd.assertVocabs, vd.vocabularies); err != nil { + verr := err.(*ValidationError) + verr.SchemaURL = s.PropertyNames.Location + verr.ErrorKind = &kind.PropertyNames{Property: pname} + vd.addErr(verr) + } + } + } + + if s.DraftVersion == 6 { + return + } + + // dependentSchemas -- + for pname, sch := range s.DependentSchemas { + if _, ok := obj[pname]; ok { + vd.addErr(vd.validateSelf(sch, "", false)) + } + } + + // dependentRequired -- + for pname, reqd := range s.DependentRequired { + if _, ok := obj[pname]; ok { + if missing := vd.findMissing(obj, reqd); missing != nil { + vd.addError(&kind.DependentRequired{Prop: pname, Missing: missing}) + } + } + } +} + +func (vd *validator) arrValidate(arr []any) { + s := vd.sch + + // minItems -- + if s.MinItems != nil { + if len(arr) < *s.MinItems { + vd.addError(&kind.MinItems{Got: len(arr), Want: *s.MinItems}) + } + } + + // maxItems -- + if s.MaxItems != nil { + if len(arr) > *s.MaxItems { + vd.addError(&kind.MaxItems{Got: len(arr), Want: *s.MaxItems}) + } + } + + // uniqueItems -- + if s.UniqueItems && len(arr) > 1 { + i, j, k := duplicates(arr) + if k != nil { + vd.addError(k) + } else if i != -1 { + vd.addError(&kind.UniqueItems{Duplicates: [2]int{i, j}}) + } + } + + if s.DraftVersion < 2020 { + evaluated := 0 + + // items -- + switch items := s.Items.(type) { + case *Schema: + for i, item := range arr { + vd.addErr(vd.validateVal(items, item, strconv.Itoa(i))) + } + evaluated = len(arr) + case []*Schema: + min := minInt(len(arr), len(items)) + for i, item := range arr[:min] { + vd.addErr(vd.validateVal(items[i], item, strconv.Itoa(i))) + } + evaluated = min + } + + // additionalItems -- + if s.AdditionalItems != nil { + switch additional := s.AdditionalItems.(type) { + case bool: + if !additional && evaluated != len(arr) { + vd.addError(&kind.AdditionalItems{Count: len(arr) - evaluated}) + } + case *Schema: + for i, item := range arr[evaluated:] { + vd.addErr(vd.validateVal(additional, item, strconv.Itoa(i))) + } + } + } + } else { + evaluated := minInt(len(s.PrefixItems), len(arr)) + + // prefixItems -- + for i, item := range arr[:evaluated] { + vd.addErr(vd.validateVal(s.PrefixItems[i], item, strconv.Itoa(i))) + } + + // items2020 -- + if s.Items2020 != nil { + for i, item := range arr[evaluated:] { + vd.addErr(vd.validateVal(s.Items2020, item, strconv.Itoa(i))) + } + } + } + + // contains -- + if s.Contains != nil { + var errors []*ValidationError + var matched []int + + for i, item := range arr { + if err := vd.validateVal(s.Contains, item, strconv.Itoa(i)); err != nil { + errors = append(errors, err.(*ValidationError)) + } else { + matched = append(matched, i) + if s.DraftVersion >= 2020 { + delete(vd.uneval.items, i) + } + } + } + + // minContains -- + if s.MinContains != nil { + if len(matched) < *s.MinContains { + vd.addErrors(errors, &kind.MinContains{Got: matched, Want: *s.MinContains}) + } + } else if len(matched) == 0 { + vd.addErrors(errors, &kind.Contains{}) + } + + // maxContains -- + if s.MaxContains != nil { + if len(matched) > *s.MaxContains { + vd.addError(&kind.MaxContains{Got: matched, Want: *s.MaxContains}) + } + } + } +} + +func (vd *validator) strValidate(str string) { + s := vd.sch + + strLen := -1 + if s.MinLength != nil || s.MaxLength != nil { + strLen = utf8.RuneCount([]byte(str)) + } + + // minLength -- + if s.MinLength != nil { + if strLen < *s.MinLength { + vd.addError(&kind.MinLength{Got: strLen, Want: *s.MinLength}) + } + } + + // maxLength -- + if s.MaxLength != nil { + if strLen > *s.MaxLength { + vd.addError(&kind.MaxLength{Got: strLen, Want: *s.MaxLength}) + } + } + + // pattern -- + if s.Pattern != nil { + if !s.Pattern.MatchString(str) { + vd.addError(&kind.Pattern{Got: str, Want: s.Pattern.String()}) + } + } + + if s.DraftVersion == 6 { + return + } + + var err error + + // contentEncoding -- + decoded := []byte(str) + if s.ContentEncoding != nil { + decoded, err = s.ContentEncoding.Decode(str) + if err != nil { + decoded = nil + vd.addError(&kind.ContentEncoding{Want: s.ContentEncoding.Name, Err: err}) + } + } + + var deserialized *any + if decoded != nil && s.ContentMediaType != nil { + if s.ContentSchema == nil { + err = s.ContentMediaType.Validate(decoded) + } else { + var value any + value, err = s.ContentMediaType.UnmarshalJSON(decoded) + if err == nil { + deserialized = &value + } + } + if err != nil { + vd.addError(&kind.ContentMediaType{ + Got: decoded, + Want: s.ContentMediaType.Name, + Err: err, + }) + } + } + + if deserialized != nil && s.ContentSchema != nil { + sch, meta, resources := s.ContentSchema, vd.meta, vd.resources + res := vd.metaResource(sch) + if res != nil { + meta = res.dialect.getSchema(vd.assertVocabs, vd.vocabularies) + sch = meta + } + if err = sch.validate(*deserialized, vd.regexpEngine, meta, resources, vd.assertVocabs, vd.vocabularies); err != nil { + verr := err.(*ValidationError) + verr.SchemaURL = s.Location + verr.ErrorKind = &kind.ContentSchema{} + vd.addErr(verr) + } + } +} + +func (vd *validator) numValidate(v any) { + s := vd.sch + + var numVal *big.Rat + num := func() *big.Rat { + if numVal == nil { + numVal, _ = new(big.Rat).SetString(fmt.Sprintf("%v", v)) + } + return numVal + } + + // minimum -- + if s.Minimum != nil && num().Cmp(s.Minimum) < 0 { + vd.addError(&kind.Minimum{Got: num(), Want: s.Minimum}) + } + + // maximum -- + if s.Maximum != nil && num().Cmp(s.Maximum) > 0 { + vd.addError(&kind.Maximum{Got: num(), Want: s.Maximum}) + } + + // exclusiveMinimum + if s.ExclusiveMinimum != nil && num().Cmp(s.ExclusiveMinimum) <= 0 { + vd.addError(&kind.ExclusiveMinimum{Got: num(), Want: s.ExclusiveMinimum}) + } + + // exclusiveMaximum + if s.ExclusiveMaximum != nil && num().Cmp(s.ExclusiveMaximum) >= 0 { + vd.addError(&kind.ExclusiveMaximum{Got: num(), Want: s.ExclusiveMaximum}) + } + + // multipleOf + if s.MultipleOf != nil { + if q := new(big.Rat).Quo(num(), s.MultipleOf); !q.IsInt() { + vd.addError(&kind.MultipleOf{Got: num(), Want: s.MultipleOf}) + } + } +} + +func (vd *validator) condValidate() { + s := vd.sch + + // not -- + if s.Not != nil { + if vd.validateSelf(s.Not, "", true) == nil { + vd.addError(&kind.Not{}) + } + } + + // allOf -- + if len(s.AllOf) > 0 { + var errors []*ValidationError + for _, sch := range s.AllOf { + if err := vd.validateSelf(sch, "", false); err != nil { + errors = append(errors, err.(*ValidationError)) + if vd.boolResult { + break + } + } + } + if len(errors) != 0 { + vd.addErrors(errors, &kind.AllOf{}) + } + } + + // anyOf + if len(s.AnyOf) > 0 { + var matched bool + var errors []*ValidationError + for _, sch := range s.AnyOf { + if err := vd.validateSelf(sch, "", false); err != nil { + errors = append(errors, err.(*ValidationError)) + } else { + matched = true + // for uneval, all schemas must be evaluated + if vd.uneval.isEmpty() { + break + } + } + } + if !matched { + vd.addErrors(errors, &kind.AnyOf{}) + } + } + + // oneOf + if len(s.OneOf) > 0 { + var matched = -1 + var errors []*ValidationError + for i, sch := range s.OneOf { + if err := vd.validateSelf(sch, "", matched != -1); err != nil { + if matched == -1 { + errors = append(errors, err.(*ValidationError)) + } + } else { + if matched == -1 { + matched = i + } else { + vd.addError(&kind.OneOf{Subschemas: []int{matched, i}}) + break + } + } + } + if matched == -1 { + vd.addErrors(errors, &kind.OneOf{Subschemas: nil}) + } + } + + // if, then, else -- + if s.If != nil { + if vd.validateSelf(s.If, "", true) == nil { + if s.Then != nil { + vd.addErr(vd.validateSelf(s.Then, "", false)) + } + } else if s.Else != nil { + vd.addErr(vd.validateSelf(s.Else, "", false)) + } + } +} + +func (vd *validator) unevalValidate() { + s := vd.sch + + // unevaluatedProperties + if obj, ok := vd.v.(map[string]any); ok && s.UnevaluatedProperties != nil { + for pname := range vd.uneval.props { + if pvalue, ok := obj[pname]; ok { + vd.addErr(vd.validateVal(s.UnevaluatedProperties, pvalue, pname)) + } + } + vd.uneval.props = nil + } + + // unevaluatedItems + if arr, ok := vd.v.([]any); ok && s.UnevaluatedItems != nil { + for i := range vd.uneval.items { + vd.addErr(vd.validateVal(s.UnevaluatedItems, arr[i], strconv.Itoa(i))) + } + vd.uneval.items = nil + } +} + +// validation helpers -- + +func (vd *validator) validateSelf(sch *Schema, refKw string, boolResult bool) error { + scp := vd.scp.child(sch, refKw, vd.scp.vid) + uneval := unevalFrom(vd.v, sch, !vd.uneval.isEmpty()) + subvd := validator{ + v: vd.v, + vloc: vd.vloc, + sch: sch, + scp: scp, + uneval: uneval, + errors: nil, + boolResult: vd.boolResult || boolResult, + regexpEngine: vd.regexpEngine, + meta: vd.meta, + resources: vd.resources, + assertVocabs: vd.assertVocabs, + vocabularies: vd.vocabularies, + } + subvd.handleMeta() + uneval, err := subvd.validate() + if err == nil { + vd.uneval.merge(uneval) + } + return err +} + +func (vd *validator) validateVal(sch *Schema, v any, vtok string) error { + vloc := append(vd.vloc, vtok) + scp := vd.scp.child(sch, "", vd.scp.vid+1) + uneval := unevalFrom(v, sch, false) + subvd := validator{ + v: v, + vloc: vloc, + sch: sch, + scp: scp, + uneval: uneval, + errors: nil, + boolResult: vd.boolResult, + regexpEngine: vd.regexpEngine, + meta: vd.meta, + resources: vd.resources, + assertVocabs: vd.assertVocabs, + vocabularies: vd.vocabularies, + } + subvd.handleMeta() + _, err := subvd.validate() + return err +} + +func (vd *validator) validateValue(sch *Schema, v any, vpath []string) error { + vloc := append(vd.vloc, vpath...) + scp := vd.scp.child(sch, "", vd.scp.vid+1) + uneval := unevalFrom(v, sch, false) + subvd := validator{ + v: v, + vloc: vloc, + sch: sch, + scp: scp, + uneval: uneval, + errors: nil, + boolResult: vd.boolResult, + regexpEngine: vd.regexpEngine, + meta: vd.meta, + resources: vd.resources, + assertVocabs: vd.assertVocabs, + vocabularies: vd.vocabularies, + } + subvd.handleMeta() + _, err := subvd.validate() + return err +} + +func (vd *validator) metaResource(sch *Schema) *resource { + if sch != vd.meta { + return nil + } + ptr := "" + for _, tok := range vd.instanceLocation() { + ptr += "/" + ptr += escape(tok) + } + return vd.resources[jsonPointer(ptr)] +} + +func (vd *validator) handleMeta() { + res := vd.metaResource(vd.sch) + if res == nil { + return + } + sch := res.dialect.getSchema(vd.assertVocabs, vd.vocabularies) + vd.meta = sch + vd.sch = sch +} + +// reference validation -- + +func (vd *validator) validateRef(sch *Schema, kw string) error { + err := vd.validateSelf(sch, kw, false) + if err != nil { + refErr := vd.error(&kind.Reference{Keyword: kw, URL: sch.Location}) + verr := err.(*ValidationError) + if _, ok := verr.ErrorKind.(*kind.Group); ok { + refErr.Causes = verr.Causes + } else { + refErr.Causes = append(refErr.Causes, verr) + } + return refErr + } + return nil +} + +func (vd *validator) resolveRecursiveAnchor(fallback *Schema) *Schema { + sch := fallback + scp := vd.scp + for scp != nil { + if scp.sch.resource.RecursiveAnchor { + sch = scp.sch + } + scp = scp.parent + } + return sch +} + +func (vd *validator) resolveDynamicAnchor(name string, fallback *Schema) *Schema { + sch := fallback + scp := vd.scp + for scp != nil { + if dsch, ok := scp.sch.resource.dynamicAnchors[name]; ok { + sch = dsch + } + scp = scp.parent + } + return sch +} + +func (vd *validator) validateRefs() { + // $recursiveRef -- + if sch := vd.sch.RecursiveRef; sch != nil { + if sch.RecursiveAnchor { + sch = vd.resolveRecursiveAnchor(sch) + } + vd.addErr(vd.validateRef(sch, "$recursiveRef")) + } + + // $dynamicRef -- + if dref := vd.sch.DynamicRef; dref != nil { + sch := dref.Ref // initial target + if dref.Anchor != "" { + // $dynamicRef includes anchor + if sch.DynamicAnchor == dref.Anchor { + // initial target has matching $dynamicAnchor + sch = vd.resolveDynamicAnchor(dref.Anchor, sch) + } + } + vd.addErr(vd.validateRef(sch, "$dynamicRef")) + } +} + +// error helpers -- + +func (vd *validator) instanceLocation() []string { + return slices.Clone(vd.vloc) +} + +func (vd *validator) error(kind ErrorKind) *ValidationError { + if vd.boolResult { + return &ValidationError{} + } + return &ValidationError{ + SchemaURL: vd.sch.Location, + InstanceLocation: vd.instanceLocation(), + ErrorKind: kind, + Causes: nil, + } +} + +func (vd *validator) addErr(err error) { + if err != nil { + vd.errors = append(vd.errors, err.(*ValidationError)) + } +} + +func (vd *validator) addError(kind ErrorKind) { + vd.errors = append(vd.errors, vd.error(kind)) +} + +func (vd *validator) addErrors(errors []*ValidationError, kind ErrorKind) { + err := vd.error(kind) + err.Causes = errors + vd.errors = append(vd.errors, err) +} + +func (vd *validator) findMissing(obj map[string]any, reqd []string) []string { + var missing []string + for _, pname := range reqd { + if _, ok := obj[pname]; !ok { + if vd.boolResult { + return []string{} // non-nil + } + missing = append(missing, pname) + } + } + return missing +} + +// -- + +type scope struct { + sch *Schema + + // if empty, compute from self.sch and self.parent.sch. + // not empty, only when there is a jump i.e, $ref, $XXXRef + refKeyword string + + // unique id of value being validated + // if two scopes validate same value, they will have + // same vid + vid int + + parent *scope +} + +func (sc *scope) child(sch *Schema, refKeyword string, vid int) *scope { + return &scope{sch, refKeyword, vid, sc} +} + +func (sc *scope) checkCycle() *scope { + scp := sc.parent + for scp != nil { + if scp.vid != sc.vid { + break + } + if scp.sch == sc.sch { + return scp + } + scp = scp.parent + } + return nil +} + +func (sc *scope) kwLoc() string { + var loc string + for sc.parent != nil { + if sc.refKeyword != "" { + loc = fmt.Sprintf("/%s%s", escape(sc.refKeyword), loc) + } else { + cur := sc.sch.Location + parent := sc.parent.sch.Location + loc = fmt.Sprintf("%s%s", cur[len(parent):], loc) + } + sc = sc.parent + } + return loc +} + +// -- + +type uneval struct { + props map[string]struct{} + items map[int]struct{} +} + +func unevalFrom(v any, sch *Schema, callerNeeds bool) *uneval { + uneval := &uneval{} + switch v := v.(type) { + case map[string]any: + if !sch.allPropsEvaluated && (callerNeeds || sch.UnevaluatedProperties != nil) { + uneval.props = map[string]struct{}{} + for k := range v { + uneval.props[k] = struct{}{} + } + } + case []any: + if !sch.allItemsEvaluated && (callerNeeds || sch.UnevaluatedItems != nil) && sch.numItemsEvaluated < len(v) { + uneval.items = map[int]struct{}{} + for i := sch.numItemsEvaluated; i < len(v); i++ { + uneval.items[i] = struct{}{} + } + } + } + return uneval +} + +func (ue *uneval) merge(other *uneval) { + for k := range ue.props { + if _, ok := other.props[k]; !ok { + delete(ue.props, k) + } + } + for i := range ue.items { + if _, ok := other.items[i]; !ok { + delete(ue.items, i) + } + } +} + +func (ue *uneval) isEmpty() bool { + return len(ue.props) == 0 && len(ue.items) == 0 +} + +// -- + +type ValidationError struct { + // absolute, dereferenced schema location. + SchemaURL string + + // location of the JSON value within the instance being validated. + InstanceLocation []string + + // kind of error + ErrorKind ErrorKind + + // holds nested errors + Causes []*ValidationError +} + +type ErrorKind interface { + KeywordPath() []string + LocalizedString(*message.Printer) string +} diff --git a/vendor/github.com/santhosh-tekuri/jsonschema/v6/vocab.go b/vendor/github.com/santhosh-tekuri/jsonschema/v6/vocab.go new file mode 100644 index 00000000..c81cb700 --- /dev/null +++ b/vendor/github.com/santhosh-tekuri/jsonschema/v6/vocab.go @@ -0,0 +1,111 @@ +package jsonschema + +// CompilerContext provides helpers for +// compiling a [Vocabulary]. +type CompilerContext struct { + c *objCompiler +} + +func (ctx *CompilerContext) Enqueue(schPath []string) *Schema { + ptr := ctx.c.up.ptr + for _, tok := range schPath { + ptr = ptr.append(tok) + } + return ctx.c.enqueuePtr(ptr) +} + +// Vocabulary defines a set of keywords, their syntax and +// their semantics. +type Vocabulary struct { + // URL identifier for this Vocabulary. + URL string + + // Schema that is used to validate the keywords that is introduced by this + // vocabulary. + Schema *Schema + + // Subschemas lists the possible locations of subschemas introduced by + // this vocabulary. + Subschemas []SchemaPath + + // Compile compiles the keywords(introduced by this vocabulary) in obj into [SchemaExt]. + // If obj does not contain any keywords introduced by this vocabulary, nil SchemaExt must + // be returned. + Compile func(ctx *CompilerContext, obj map[string]any) (SchemaExt, error) +} + +// -- + +// SchemaExt is compled form of vocabulary. +type SchemaExt interface { + // Validate validates v against and errors if any are reported + // to ctx. + Validate(ctx *ValidatorContext, v any) +} + +// ValidatorContext provides helpers for +// validating with [SchemaExt]. +type ValidatorContext struct { + vd *validator +} + +// ValueLocation returns location of value as jsonpath token array. +func (ctx *ValidatorContext) ValueLocation() []string { + return ctx.vd.vloc +} + +// Validate validates v with sch. vpath gives path of v from current context value. +func (ctx *ValidatorContext) Validate(sch *Schema, v any, vpath []string) error { + switch len(vpath) { + case 0: + return ctx.vd.validateSelf(sch, "", false) + case 1: + return ctx.vd.validateVal(sch, v, vpath[0]) + default: + return ctx.vd.validateValue(sch, v, vpath) + } +} + +// EvaluatedProp marks given property of current object as evaluated. +func (ctx *ValidatorContext) EvaluatedProp(pname string) { + delete(ctx.vd.uneval.props, pname) +} + +// EvaluatedItem marks items at given index of current array as evaluated. +func (ctx *ValidatorContext) EvaluatedItem(index int) { + delete(ctx.vd.uneval.items, index) +} + +// AddError reports validation-error of given kind. +func (ctx *ValidatorContext) AddError(k ErrorKind) { + ctx.vd.addError(k) +} + +// AddErrors reports validation-errors of given kind. +func (ctx *ValidatorContext) AddErrors(errors []*ValidationError, k ErrorKind) { + ctx.vd.addErrors(errors, k) +} + +// AddErr reports the given err. This is typically used to report +// the error created by subschema validation. +// +// NOTE that err must be of type *ValidationError. +func (ctx *ValidatorContext) AddErr(err error) { + ctx.vd.addErr(err) +} + +func (ctx *ValidatorContext) Equals(v1, v2 any) (bool, error) { + b, k := equals(v1, v2) + if k != nil { + return false, ctx.vd.error(k) + } + return b, nil +} + +func (ctx *ValidatorContext) Duplicates(arr []any) (int, int, error) { + i, j, k := duplicates(arr) + if k != nil { + return -1, -1, ctx.vd.error(k) + } + return i, j, nil +} diff --git a/vendor/golang.org/x/text/feature/plural/common.go b/vendor/golang.org/x/text/feature/plural/common.go new file mode 100644 index 00000000..fdcb373f --- /dev/null +++ b/vendor/golang.org/x/text/feature/plural/common.go @@ -0,0 +1,70 @@ +// Code generated by running "go generate" in golang.org/x/text. DO NOT EDIT. + +package plural + +// Form defines a plural form. +// +// Not all languages support all forms. Also, the meaning of each form varies +// per language. It is important to note that the name of a form does not +// necessarily correspond one-to-one with the set of numbers. For instance, +// for Croation, One matches not only 1, but also 11, 21, etc. +// +// Each language must at least support the form "other". +type Form byte + +const ( + Other Form = iota + Zero + One + Two + Few + Many +) + +var countMap = map[string]Form{ + "other": Other, + "zero": Zero, + "one": One, + "two": Two, + "few": Few, + "many": Many, +} + +type pluralCheck struct { + // category: + // 3..7: opID + // 0..2: category + cat byte + setID byte +} + +// opID identifies the type of operand in the plural rule, being i, n or f. +// (v, w, and t are treated as filters in our implementation.) +type opID byte + +const ( + opMod opID = 0x1 // is '%' used? + opNotEqual opID = 0x2 // using "!=" to compare + opI opID = 0 << 2 // integers after taking the absolute value + opN opID = 1 << 2 // full number (must be integer) + opF opID = 2 << 2 // fraction + opV opID = 3 << 2 // number of visible digits + opW opID = 4 << 2 // number of visible digits without trailing zeros + opBretonM opID = 5 << 2 // hard-wired rule for Breton + opItalian800 opID = 6 << 2 // hard-wired rule for Italian + opAzerbaijan00s opID = 7 << 2 // hard-wired rule for Azerbaijan +) +const ( + // Use this plural form to indicate the next rule needs to match as well. + // The last condition in the list will have the correct plural form. + andNext = 0x7 + formMask = 0x7 + + opShift = 3 + + // numN indicates the maximum integer, or maximum mod value, for which we + // have inclusion masks. + numN = 100 + // The common denominator of the modulo that is taken. + maxMod = 100 +) diff --git a/vendor/golang.org/x/text/feature/plural/message.go b/vendor/golang.org/x/text/feature/plural/message.go new file mode 100644 index 00000000..56d518cc --- /dev/null +++ b/vendor/golang.org/x/text/feature/plural/message.go @@ -0,0 +1,244 @@ +// Copyright 2017 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package plural + +import ( + "fmt" + "io" + "reflect" + "strconv" + + "golang.org/x/text/internal/catmsg" + "golang.org/x/text/internal/number" + "golang.org/x/text/language" + "golang.org/x/text/message/catalog" +) + +// TODO: consider deleting this interface. Maybe VisibleDigits is always +// sufficient and practical. + +// Interface is used for types that can determine their own plural form. +type Interface interface { + // PluralForm reports the plural form for the given language of the + // underlying value. It also returns the integer value. If the integer value + // is larger than fits in n, PluralForm may return a value modulo + // 10,000,000. + PluralForm(t language.Tag, scale int) (f Form, n int) +} + +// Selectf returns the first case for which its selector is a match for the +// arg-th substitution argument to a formatting call, formatting it as indicated +// by format. +// +// The cases argument are pairs of selectors and messages. Selectors are of type +// string or Form. Messages are of type string or catalog.Message. A selector +// matches an argument if: +// - it is "other" or Other +// - it matches the plural form of the argument: "zero", "one", "two", "few", +// or "many", or the equivalent Form +// - it is of the form "=x" where x is an integer that matches the value of +// the argument. +// - it is of the form " kindDefault { + e.EncodeUint(uint64(m.scale)) + } + + forms := validForms(cardinal, e.Language()) + + for i := 0; i < len(m.cases); { + if err := compileSelector(e, forms, m.cases[i]); err != nil { + return err + } + if i++; i >= len(m.cases) { + return fmt.Errorf("plural: no message defined for selector %v", m.cases[i-1]) + } + var msg catalog.Message + switch x := m.cases[i].(type) { + case string: + msg = catalog.String(x) + case catalog.Message: + msg = x + default: + return fmt.Errorf("plural: message of type %T; must be string or catalog.Message", x) + } + if err := e.EncodeMessage(msg); err != nil { + return err + } + i++ + } + return nil +} + +func compileSelector(e *catmsg.Encoder, valid []Form, selector interface{}) error { + form := Other + switch x := selector.(type) { + case string: + if x == "" { + return fmt.Errorf("plural: empty selector") + } + if c := x[0]; c == '=' || c == '<' { + val, err := strconv.ParseUint(x[1:], 10, 16) + if err != nil { + return fmt.Errorf("plural: invalid number in selector %q: %v", selector, err) + } + e.EncodeUint(uint64(c)) + e.EncodeUint(val) + return nil + } + var ok bool + form, ok = countMap[x] + if !ok { + return fmt.Errorf("plural: invalid plural form %q", selector) + } + case Form: + form = x + default: + return fmt.Errorf("plural: selector of type %T; want string or Form", selector) + } + + ok := false + for _, f := range valid { + if f == form { + ok = true + break + } + } + if !ok { + return fmt.Errorf("plural: form %q not supported for language %q", selector, e.Language()) + } + e.EncodeUint(uint64(form)) + return nil +} + +func execute(d *catmsg.Decoder) bool { + lang := d.Language() + argN := int(d.DecodeUint()) + kind := int(d.DecodeUint()) + scale := -1 // default + if kind > kindDefault { + scale = int(d.DecodeUint()) + } + form := Other + n := -1 + if arg := d.Arg(argN); arg == nil { + // Default to Other. + } else if x, ok := arg.(number.VisibleDigits); ok { + d := x.Digits(nil, lang, scale) + form, n = cardinal.matchDisplayDigits(lang, &d) + } else if x, ok := arg.(Interface); ok { + // This covers lists and formatters from the number package. + form, n = x.PluralForm(lang, scale) + } else { + var f number.Formatter + switch kind { + case kindScale: + f.InitDecimal(lang) + f.SetScale(scale) + case kindScientific: + f.InitScientific(lang) + f.SetScale(scale) + case kindPrecision: + f.InitDecimal(lang) + f.SetPrecision(scale) + case kindDefault: + // sensible default + f.InitDecimal(lang) + if k := reflect.TypeOf(arg).Kind(); reflect.Int <= k && k <= reflect.Uintptr { + f.SetScale(0) + } else { + f.SetScale(2) + } + } + var dec number.Decimal // TODO: buffer in Printer + dec.Convert(f.RoundingContext, arg) + v := number.FormatDigits(&dec, f.RoundingContext) + if !v.NaN && !v.Inf { + form, n = cardinal.matchDisplayDigits(d.Language(), &v) + } + } + for !d.Done() { + f := d.DecodeUint() + if (f == '=' && n == int(d.DecodeUint())) || + (f == '<' && 0 <= n && n < int(d.DecodeUint())) || + form == Form(f) || + Other == Form(f) { + return d.ExecuteMessage() + } + d.SkipMessage() + } + return false +} diff --git a/vendor/golang.org/x/text/feature/plural/plural.go b/vendor/golang.org/x/text/feature/plural/plural.go new file mode 100644 index 00000000..e9f2d42e --- /dev/null +++ b/vendor/golang.org/x/text/feature/plural/plural.go @@ -0,0 +1,262 @@ +// Copyright 2016 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +//go:generate go run gen.go gen_common.go + +// Package plural provides utilities for handling linguistic plurals in text. +// +// The definitions in this package are based on the plural rule handling defined +// in CLDR. See +// https://unicode.org/reports/tr35/tr35-numbers.html#Language_Plural_Rules for +// details. +package plural + +import ( + "golang.org/x/text/internal/language/compact" + "golang.org/x/text/internal/number" + "golang.org/x/text/language" +) + +// Rules defines the plural rules for all languages for a certain plural type. +// +// This package is UNDER CONSTRUCTION and its API may change. +type Rules struct { + rules []pluralCheck + index []byte + langToIndex []byte + inclusionMasks []uint64 +} + +var ( + // Cardinal defines the plural rules for numbers indicating quantities. + Cardinal *Rules = cardinal + + // Ordinal defines the plural rules for numbers indicating position + // (first, second, etc.). + Ordinal *Rules = ordinal + + ordinal = &Rules{ + ordinalRules, + ordinalIndex, + ordinalLangToIndex, + ordinalInclusionMasks[:], + } + + cardinal = &Rules{ + cardinalRules, + cardinalIndex, + cardinalLangToIndex, + cardinalInclusionMasks[:], + } +) + +// getIntApprox converts the digits in slice digits[start:end] to an integer +// according to the following rules: +// - Let i be asInt(digits[start:end]), where out-of-range digits are assumed +// to be zero. +// - Result n is big if i / 10^nMod > 1. +// - Otherwise the result is i % 10^nMod. +// +// For example, if digits is {1, 2, 3} and start:end is 0:5, then the result +// for various values of nMod is: +// - when nMod == 2, n == big +// - when nMod == 3, n == big +// - when nMod == 4, n == big +// - when nMod == 5, n == 12300 +// - when nMod == 6, n == 12300 +// - when nMod == 7, n == 12300 +func getIntApprox(digits []byte, start, end, nMod, big int) (n int) { + // Leading 0 digits just result in 0. + p := start + if p < 0 { + p = 0 + } + // Range only over the part for which we have digits. + mid := end + if mid >= len(digits) { + mid = len(digits) + } + // Check digits more significant that nMod. + if q := end - nMod; q > 0 { + if q > mid { + q = mid + } + for ; p < q; p++ { + if digits[p] != 0 { + return big + } + } + } + for ; p < mid; p++ { + n = 10*n + int(digits[p]) + } + // Multiply for trailing zeros. + for ; p < end; p++ { + n *= 10 + } + return n +} + +// MatchDigits computes the plural form for the given language and the given +// decimal floating point digits. The digits are stored in big-endian order and +// are of value byte(0) - byte(9). The floating point position is indicated by +// exp and the number of visible decimals is scale. All leading and trailing +// zeros may be omitted from digits. +// +// The following table contains examples of possible arguments to represent +// the given numbers. +// +// decimal digits exp scale +// 123 []byte{1, 2, 3} 3 0 +// 123.4 []byte{1, 2, 3, 4} 3 1 +// 123.40 []byte{1, 2, 3, 4} 3 2 +// 100000 []byte{1} 6 0 +// 100000.00 []byte{1} 6 3 +func (p *Rules) MatchDigits(t language.Tag, digits []byte, exp, scale int) Form { + index := tagToID(t) + + // Differentiate up to including mod 1000000 for the integer part. + n := getIntApprox(digits, 0, exp, 6, 1000000) + + // Differentiate up to including mod 100 for the fractional part. + f := getIntApprox(digits, exp, exp+scale, 2, 100) + + return matchPlural(p, index, n, f, scale) +} + +func (p *Rules) matchDisplayDigits(t language.Tag, d *number.Digits) (Form, int) { + n := getIntApprox(d.Digits, 0, int(d.Exp), 6, 1000000) + return p.MatchDigits(t, d.Digits, int(d.Exp), d.NumFracDigits()), n +} + +func validForms(p *Rules, t language.Tag) (forms []Form) { + offset := p.langToIndex[tagToID(t)] + rules := p.rules[p.index[offset]:p.index[offset+1]] + + forms = append(forms, Other) + last := Other + for _, r := range rules { + if cat := Form(r.cat & formMask); cat != andNext && last != cat { + forms = append(forms, cat) + last = cat + } + } + return forms +} + +func (p *Rules) matchComponents(t language.Tag, n, f, scale int) Form { + return matchPlural(p, tagToID(t), n, f, scale) +} + +// MatchPlural returns the plural form for the given language and plural +// operands (as defined in +// https://unicode.org/reports/tr35/tr35-numbers.html#Language_Plural_Rules): +// +// where +// n absolute value of the source number (integer and decimals) +// input +// i integer digits of n. +// v number of visible fraction digits in n, with trailing zeros. +// w number of visible fraction digits in n, without trailing zeros. +// f visible fractional digits in n, with trailing zeros (f = t * 10^(v-w)) +// t visible fractional digits in n, without trailing zeros. +// +// If any of the operand values is too large to fit in an int, it is okay to +// pass the value modulo 10,000,000. +func (p *Rules) MatchPlural(lang language.Tag, i, v, w, f, t int) Form { + return matchPlural(p, tagToID(lang), i, f, v) +} + +func matchPlural(p *Rules, index compact.ID, n, f, v int) Form { + nMask := p.inclusionMasks[n%maxMod] + // Compute the fMask inline in the rules below, as it is relatively rare. + // fMask := p.inclusionMasks[f%maxMod] + vMask := p.inclusionMasks[v%maxMod] + + // Do the matching + offset := p.langToIndex[index] + rules := p.rules[p.index[offset]:p.index[offset+1]] + for i := 0; i < len(rules); i++ { + rule := rules[i] + setBit := uint64(1 << rule.setID) + var skip bool + switch op := opID(rule.cat >> opShift); op { + case opI: // i = x + skip = n >= numN || nMask&setBit == 0 + + case opI | opNotEqual: // i != x + skip = n < numN && nMask&setBit != 0 + + case opI | opMod: // i % m = x + skip = nMask&setBit == 0 + + case opI | opMod | opNotEqual: // i % m != x + skip = nMask&setBit != 0 + + case opN: // n = x + skip = f != 0 || n >= numN || nMask&setBit == 0 + + case opN | opNotEqual: // n != x + skip = f == 0 && n < numN && nMask&setBit != 0 + + case opN | opMod: // n % m = x + skip = f != 0 || nMask&setBit == 0 + + case opN | opMod | opNotEqual: // n % m != x + skip = f == 0 && nMask&setBit != 0 + + case opF: // f = x + skip = f >= numN || p.inclusionMasks[f%maxMod]&setBit == 0 + + case opF | opNotEqual: // f != x + skip = f < numN && p.inclusionMasks[f%maxMod]&setBit != 0 + + case opF | opMod: // f % m = x + skip = p.inclusionMasks[f%maxMod]&setBit == 0 + + case opF | opMod | opNotEqual: // f % m != x + skip = p.inclusionMasks[f%maxMod]&setBit != 0 + + case opV: // v = x + skip = v < numN && vMask&setBit == 0 + + case opV | opNotEqual: // v != x + skip = v < numN && vMask&setBit != 0 + + case opW: // w == 0 + skip = f != 0 + + case opW | opNotEqual: // w != 0 + skip = f == 0 + + // Hard-wired rules that cannot be handled by our algorithm. + + case opBretonM: + skip = f != 0 || n == 0 || n%1000000 != 0 + + case opAzerbaijan00s: + // 100,200,300,400,500,600,700,800,900 + skip = n == 0 || n >= 1000 || n%100 != 0 + + case opItalian800: + skip = (f != 0 || n >= numN || nMask&setBit == 0) && n != 800 + } + if skip { + // advance over AND entries. + for ; i < len(rules) && rules[i].cat&formMask == andNext; i++ { + } + continue + } + // return if we have a final entry. + if cat := rule.cat & formMask; cat != andNext { + return Form(cat) + } + } + return Other +} + +func tagToID(t language.Tag) compact.ID { + id, _ := compact.RegionalID(compact.Tag(t)) + return id +} diff --git a/vendor/golang.org/x/text/feature/plural/tables.go b/vendor/golang.org/x/text/feature/plural/tables.go new file mode 100644 index 00000000..b06b9cb4 --- /dev/null +++ b/vendor/golang.org/x/text/feature/plural/tables.go @@ -0,0 +1,552 @@ +// Code generated by running "go generate" in golang.org/x/text. DO NOT EDIT. + +package plural + +// CLDRVersion is the CLDR version from which the tables in this package are derived. +const CLDRVersion = "32" + +var ordinalRules = []pluralCheck{ // 64 elements + 0: {cat: 0x2f, setID: 0x4}, + 1: {cat: 0x3a, setID: 0x5}, + 2: {cat: 0x22, setID: 0x1}, + 3: {cat: 0x22, setID: 0x6}, + 4: {cat: 0x22, setID: 0x7}, + 5: {cat: 0x2f, setID: 0x8}, + 6: {cat: 0x3c, setID: 0x9}, + 7: {cat: 0x2f, setID: 0xa}, + 8: {cat: 0x3c, setID: 0xb}, + 9: {cat: 0x2c, setID: 0xc}, + 10: {cat: 0x24, setID: 0xd}, + 11: {cat: 0x2d, setID: 0xe}, + 12: {cat: 0x2d, setID: 0xf}, + 13: {cat: 0x2f, setID: 0x10}, + 14: {cat: 0x35, setID: 0x3}, + 15: {cat: 0xc5, setID: 0x11}, + 16: {cat: 0x2, setID: 0x1}, + 17: {cat: 0x5, setID: 0x3}, + 18: {cat: 0xd, setID: 0x12}, + 19: {cat: 0x22, setID: 0x1}, + 20: {cat: 0x2f, setID: 0x13}, + 21: {cat: 0x3d, setID: 0x14}, + 22: {cat: 0x2f, setID: 0x15}, + 23: {cat: 0x3a, setID: 0x16}, + 24: {cat: 0x2f, setID: 0x17}, + 25: {cat: 0x3b, setID: 0x18}, + 26: {cat: 0x2f, setID: 0xa}, + 27: {cat: 0x3c, setID: 0xb}, + 28: {cat: 0x22, setID: 0x1}, + 29: {cat: 0x23, setID: 0x19}, + 30: {cat: 0x24, setID: 0x1a}, + 31: {cat: 0x22, setID: 0x1b}, + 32: {cat: 0x23, setID: 0x2}, + 33: {cat: 0x24, setID: 0x1a}, + 34: {cat: 0xf, setID: 0x15}, + 35: {cat: 0x1a, setID: 0x16}, + 36: {cat: 0xf, setID: 0x17}, + 37: {cat: 0x1b, setID: 0x18}, + 38: {cat: 0xf, setID: 0x1c}, + 39: {cat: 0x1d, setID: 0x1d}, + 40: {cat: 0xa, setID: 0x1e}, + 41: {cat: 0xa, setID: 0x1f}, + 42: {cat: 0xc, setID: 0x20}, + 43: {cat: 0xe4, setID: 0x0}, + 44: {cat: 0x5, setID: 0x3}, + 45: {cat: 0xd, setID: 0xe}, + 46: {cat: 0xd, setID: 0x21}, + 47: {cat: 0x22, setID: 0x1}, + 48: {cat: 0x23, setID: 0x19}, + 49: {cat: 0x24, setID: 0x1a}, + 50: {cat: 0x25, setID: 0x22}, + 51: {cat: 0x22, setID: 0x23}, + 52: {cat: 0x23, setID: 0x19}, + 53: {cat: 0x24, setID: 0x1a}, + 54: {cat: 0x25, setID: 0x22}, + 55: {cat: 0x22, setID: 0x24}, + 56: {cat: 0x23, setID: 0x19}, + 57: {cat: 0x24, setID: 0x1a}, + 58: {cat: 0x25, setID: 0x22}, + 59: {cat: 0x21, setID: 0x25}, + 60: {cat: 0x22, setID: 0x1}, + 61: {cat: 0x23, setID: 0x2}, + 62: {cat: 0x24, setID: 0x26}, + 63: {cat: 0x25, setID: 0x27}, +} // Size: 152 bytes + +var ordinalIndex = []uint8{ // 22 elements + 0x00, 0x00, 0x02, 0x03, 0x04, 0x05, 0x07, 0x09, + 0x0b, 0x0f, 0x10, 0x13, 0x16, 0x1c, 0x1f, 0x22, + 0x28, 0x2f, 0x33, 0x37, 0x3b, 0x40, +} // Size: 46 bytes + +var ordinalLangToIndex = []uint8{ // 775 elements + // Entry 0 - 3F + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x12, 0x12, 0x00, 0x00, 0x00, 0x00, 0x10, 0x10, + 0x10, 0x10, 0x10, 0x00, 0x00, 0x05, 0x05, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + // Entry 40 - 7F + 0x12, 0x12, 0x12, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x0e, + 0x0e, 0x0e, 0x0e, 0x0e, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x14, 0x14, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + // Entry 80 - BF + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x0c, 0x0c, + 0x0c, 0x0c, 0x0c, 0x0c, 0x0c, 0x0c, 0x0c, 0x0c, + 0x0c, 0x0c, 0x0c, 0x0c, 0x0c, 0x0c, 0x0c, 0x0c, + 0x0c, 0x0c, 0x0c, 0x0c, 0x0c, 0x0c, 0x0c, 0x0c, + 0x0c, 0x0c, 0x0c, 0x0c, 0x0c, 0x0c, 0x0c, 0x0c, + 0x0c, 0x0c, 0x0c, 0x0c, 0x0c, 0x0c, 0x0c, 0x0c, + 0x0c, 0x0c, 0x0c, 0x0c, 0x0c, 0x0c, 0x0c, 0x0c, + 0x0c, 0x0c, 0x0c, 0x0c, 0x0c, 0x0c, 0x0c, 0x0c, + // Entry C0 - FF + 0x0c, 0x0c, 0x0c, 0x0c, 0x0c, 0x0c, 0x0c, 0x0c, + 0x0c, 0x0c, 0x0c, 0x0c, 0x0c, 0x0c, 0x0c, 0x0c, + 0x0c, 0x0c, 0x0c, 0x0c, 0x0c, 0x0c, 0x0c, 0x0c, + 0x0c, 0x0c, 0x0c, 0x0c, 0x0c, 0x0c, 0x0c, 0x0c, + 0x0c, 0x0c, 0x0c, 0x0c, 0x0c, 0x0c, 0x0c, 0x0c, + 0x0c, 0x0c, 0x0c, 0x0c, 0x0c, 0x0c, 0x0c, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + // Entry 100 - 13F + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x02, 0x02, + 0x00, 0x00, 0x00, 0x02, 0x02, 0x02, 0x02, 0x02, + 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, + 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, + 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, + // Entry 140 - 17F + 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, + 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, + 0x02, 0x02, 0x00, 0x00, 0x00, 0x00, 0x02, 0x02, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x11, 0x11, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x11, + 0x11, 0x00, 0x00, 0x00, 0x00, 0x00, 0x03, 0x03, + 0x02, 0x02, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + // Entry 180 - 1BF + 0x00, 0x00, 0x00, 0x00, 0x09, 0x09, 0x09, 0x09, + 0x09, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x0a, 0x0a, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x08, 0x08, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + // Entry 1C0 - 1FF + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x02, 0x02, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x0f, 0x0f, 0x00, 0x00, + 0x00, 0x00, 0x02, 0x0d, 0x0d, 0x02, 0x02, 0x02, + 0x02, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + // Entry 200 - 23F + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x04, 0x04, 0x04, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x13, 0x13, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + // Entry 240 - 27F + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x02, + 0x02, 0x02, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + // Entry 280 - 2BF + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x0b, 0x0b, 0x0b, 0x0b, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x01, 0x01, + 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x07, 0x07, 0x02, 0x00, 0x00, 0x00, 0x00, + // Entry 2C0 - 2FF + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x06, 0x06, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x02, 0x02, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + // Entry 300 - 33F + 0x00, 0x00, 0x00, 0x00, 0x00, 0x0e, 0x0c, +} // Size: 799 bytes + +var ordinalInclusionMasks = []uint64{ // 100 elements + // Entry 0 - 1F + 0x0000002000010009, 0x00000018482000d3, 0x0000000042840195, 0x000000410a040581, + 0x00000041040c0081, 0x0000009840040041, 0x0000008400045001, 0x0000003850040001, + 0x0000003850060001, 0x0000003800049001, 0x0000000800052001, 0x0000000040660031, + 0x0000000041840331, 0x0000000100040f01, 0x00000001001c0001, 0x0000000040040001, + 0x0000000000045001, 0x0000000070040001, 0x0000000070040001, 0x0000000000049001, + 0x0000000080050001, 0x0000000040200011, 0x0000000040800111, 0x0000000100000501, + 0x0000000100080001, 0x0000000040000001, 0x0000000000005001, 0x0000000050000001, + 0x0000000050000001, 0x0000000000009001, 0x0000000000010001, 0x0000000040200011, + // Entry 20 - 3F + 0x0000000040800111, 0x0000000100000501, 0x0000000100080001, 0x0000000040000001, + 0x0000000000005001, 0x0000000050000001, 0x0000000050000001, 0x0000000000009001, + 0x0000000200050001, 0x0000000040200011, 0x0000000040800111, 0x0000000100000501, + 0x0000000100080001, 0x0000000040000001, 0x0000000000005001, 0x0000000050000001, + 0x0000000050000001, 0x0000000000009001, 0x0000000080010001, 0x0000000040200011, + 0x0000000040800111, 0x0000000100000501, 0x0000000100080001, 0x0000000040000001, + 0x0000000000005001, 0x0000000050000001, 0x0000000050000001, 0x0000000000009001, + 0x0000000200050001, 0x0000000040200011, 0x0000000040800111, 0x0000000100000501, + // Entry 40 - 5F + 0x0000000100080001, 0x0000000040000001, 0x0000000000005001, 0x0000000050000001, + 0x0000000050000001, 0x0000000000009001, 0x0000000080010001, 0x0000000040200011, + 0x0000000040800111, 0x0000000100000501, 0x0000000100080001, 0x0000000040000001, + 0x0000000000005001, 0x0000000050000001, 0x0000000050000001, 0x0000000000009001, + 0x0000000080070001, 0x0000000040200011, 0x0000000040800111, 0x0000000100000501, + 0x0000000100080001, 0x0000000040000001, 0x0000000000005001, 0x0000000050000001, + 0x0000000050000001, 0x0000000000009001, 0x0000000200010001, 0x0000000040200011, + 0x0000000040800111, 0x0000000100000501, 0x0000000100080001, 0x0000000040000001, + // Entry 60 - 7F + 0x0000000000005001, 0x0000000050000001, 0x0000000050000001, 0x0000000000009001, +} // Size: 824 bytes + +// Slots used for ordinal: 40 of 0xFF rules; 16 of 0xFF indexes; 40 of 64 sets + +var cardinalRules = []pluralCheck{ // 166 elements + 0: {cat: 0x2, setID: 0x3}, + 1: {cat: 0x22, setID: 0x1}, + 2: {cat: 0x2, setID: 0x4}, + 3: {cat: 0x2, setID: 0x4}, + 4: {cat: 0x7, setID: 0x1}, + 5: {cat: 0x62, setID: 0x3}, + 6: {cat: 0x22, setID: 0x4}, + 7: {cat: 0x7, setID: 0x3}, + 8: {cat: 0x42, setID: 0x1}, + 9: {cat: 0x22, setID: 0x4}, + 10: {cat: 0x22, setID: 0x4}, + 11: {cat: 0x22, setID: 0x5}, + 12: {cat: 0x22, setID: 0x1}, + 13: {cat: 0x22, setID: 0x1}, + 14: {cat: 0x7, setID: 0x4}, + 15: {cat: 0x92, setID: 0x3}, + 16: {cat: 0xf, setID: 0x6}, + 17: {cat: 0x1f, setID: 0x7}, + 18: {cat: 0x82, setID: 0x3}, + 19: {cat: 0x92, setID: 0x3}, + 20: {cat: 0xf, setID: 0x6}, + 21: {cat: 0x62, setID: 0x3}, + 22: {cat: 0x4a, setID: 0x6}, + 23: {cat: 0x7, setID: 0x8}, + 24: {cat: 0x62, setID: 0x3}, + 25: {cat: 0x1f, setID: 0x9}, + 26: {cat: 0x62, setID: 0x3}, + 27: {cat: 0x5f, setID: 0x9}, + 28: {cat: 0x72, setID: 0x3}, + 29: {cat: 0x29, setID: 0xa}, + 30: {cat: 0x29, setID: 0xb}, + 31: {cat: 0x4f, setID: 0xb}, + 32: {cat: 0x61, setID: 0x2}, + 33: {cat: 0x2f, setID: 0x6}, + 34: {cat: 0x3a, setID: 0x7}, + 35: {cat: 0x4f, setID: 0x6}, + 36: {cat: 0x5f, setID: 0x7}, + 37: {cat: 0x62, setID: 0x2}, + 38: {cat: 0x4f, setID: 0x6}, + 39: {cat: 0x72, setID: 0x2}, + 40: {cat: 0x21, setID: 0x3}, + 41: {cat: 0x7, setID: 0x4}, + 42: {cat: 0x32, setID: 0x3}, + 43: {cat: 0x21, setID: 0x3}, + 44: {cat: 0x22, setID: 0x1}, + 45: {cat: 0x22, setID: 0x1}, + 46: {cat: 0x23, setID: 0x2}, + 47: {cat: 0x2, setID: 0x3}, + 48: {cat: 0x22, setID: 0x1}, + 49: {cat: 0x24, setID: 0xc}, + 50: {cat: 0x7, setID: 0x1}, + 51: {cat: 0x62, setID: 0x3}, + 52: {cat: 0x74, setID: 0x3}, + 53: {cat: 0x24, setID: 0x3}, + 54: {cat: 0x2f, setID: 0xd}, + 55: {cat: 0x34, setID: 0x1}, + 56: {cat: 0xf, setID: 0x6}, + 57: {cat: 0x1f, setID: 0x7}, + 58: {cat: 0x62, setID: 0x3}, + 59: {cat: 0x4f, setID: 0x6}, + 60: {cat: 0x5a, setID: 0x7}, + 61: {cat: 0xf, setID: 0xe}, + 62: {cat: 0x1f, setID: 0xf}, + 63: {cat: 0x64, setID: 0x3}, + 64: {cat: 0x4f, setID: 0xe}, + 65: {cat: 0x5c, setID: 0xf}, + 66: {cat: 0x22, setID: 0x10}, + 67: {cat: 0x23, setID: 0x11}, + 68: {cat: 0x24, setID: 0x12}, + 69: {cat: 0xf, setID: 0x1}, + 70: {cat: 0x62, setID: 0x3}, + 71: {cat: 0xf, setID: 0x2}, + 72: {cat: 0x63, setID: 0x3}, + 73: {cat: 0xf, setID: 0x13}, + 74: {cat: 0x64, setID: 0x3}, + 75: {cat: 0x74, setID: 0x3}, + 76: {cat: 0xf, setID: 0x1}, + 77: {cat: 0x62, setID: 0x3}, + 78: {cat: 0x4a, setID: 0x1}, + 79: {cat: 0xf, setID: 0x2}, + 80: {cat: 0x63, setID: 0x3}, + 81: {cat: 0x4b, setID: 0x2}, + 82: {cat: 0xf, setID: 0x13}, + 83: {cat: 0x64, setID: 0x3}, + 84: {cat: 0x4c, setID: 0x13}, + 85: {cat: 0x7, setID: 0x1}, + 86: {cat: 0x62, setID: 0x3}, + 87: {cat: 0x7, setID: 0x2}, + 88: {cat: 0x63, setID: 0x3}, + 89: {cat: 0x2f, setID: 0xa}, + 90: {cat: 0x37, setID: 0x14}, + 91: {cat: 0x65, setID: 0x3}, + 92: {cat: 0x7, setID: 0x1}, + 93: {cat: 0x62, setID: 0x3}, + 94: {cat: 0x7, setID: 0x15}, + 95: {cat: 0x64, setID: 0x3}, + 96: {cat: 0x75, setID: 0x3}, + 97: {cat: 0x7, setID: 0x1}, + 98: {cat: 0x62, setID: 0x3}, + 99: {cat: 0xf, setID: 0xe}, + 100: {cat: 0x1f, setID: 0xf}, + 101: {cat: 0x64, setID: 0x3}, + 102: {cat: 0xf, setID: 0x16}, + 103: {cat: 0x17, setID: 0x1}, + 104: {cat: 0x65, setID: 0x3}, + 105: {cat: 0xf, setID: 0x17}, + 106: {cat: 0x65, setID: 0x3}, + 107: {cat: 0xf, setID: 0xf}, + 108: {cat: 0x65, setID: 0x3}, + 109: {cat: 0x2f, setID: 0x6}, + 110: {cat: 0x3a, setID: 0x7}, + 111: {cat: 0x2f, setID: 0xe}, + 112: {cat: 0x3c, setID: 0xf}, + 113: {cat: 0x2d, setID: 0xa}, + 114: {cat: 0x2d, setID: 0x17}, + 115: {cat: 0x2d, setID: 0x18}, + 116: {cat: 0x2f, setID: 0x6}, + 117: {cat: 0x3a, setID: 0xb}, + 118: {cat: 0x2f, setID: 0x19}, + 119: {cat: 0x3c, setID: 0xb}, + 120: {cat: 0x55, setID: 0x3}, + 121: {cat: 0x22, setID: 0x1}, + 122: {cat: 0x24, setID: 0x3}, + 123: {cat: 0x2c, setID: 0xc}, + 124: {cat: 0x2d, setID: 0xb}, + 125: {cat: 0xf, setID: 0x6}, + 126: {cat: 0x1f, setID: 0x7}, + 127: {cat: 0x62, setID: 0x3}, + 128: {cat: 0xf, setID: 0xe}, + 129: {cat: 0x1f, setID: 0xf}, + 130: {cat: 0x64, setID: 0x3}, + 131: {cat: 0xf, setID: 0xa}, + 132: {cat: 0x65, setID: 0x3}, + 133: {cat: 0xf, setID: 0x17}, + 134: {cat: 0x65, setID: 0x3}, + 135: {cat: 0xf, setID: 0x18}, + 136: {cat: 0x65, setID: 0x3}, + 137: {cat: 0x2f, setID: 0x6}, + 138: {cat: 0x3a, setID: 0x1a}, + 139: {cat: 0x2f, setID: 0x1b}, + 140: {cat: 0x3b, setID: 0x1c}, + 141: {cat: 0x2f, setID: 0x1d}, + 142: {cat: 0x3c, setID: 0x1e}, + 143: {cat: 0x37, setID: 0x3}, + 144: {cat: 0xa5, setID: 0x0}, + 145: {cat: 0x22, setID: 0x1}, + 146: {cat: 0x23, setID: 0x2}, + 147: {cat: 0x24, setID: 0x1f}, + 148: {cat: 0x25, setID: 0x20}, + 149: {cat: 0xf, setID: 0x6}, + 150: {cat: 0x62, setID: 0x3}, + 151: {cat: 0xf, setID: 0x1b}, + 152: {cat: 0x63, setID: 0x3}, + 153: {cat: 0xf, setID: 0x21}, + 154: {cat: 0x64, setID: 0x3}, + 155: {cat: 0x75, setID: 0x3}, + 156: {cat: 0x21, setID: 0x3}, + 157: {cat: 0x22, setID: 0x1}, + 158: {cat: 0x23, setID: 0x2}, + 159: {cat: 0x2c, setID: 0x22}, + 160: {cat: 0x2d, setID: 0x5}, + 161: {cat: 0x21, setID: 0x3}, + 162: {cat: 0x22, setID: 0x1}, + 163: {cat: 0x23, setID: 0x2}, + 164: {cat: 0x24, setID: 0x23}, + 165: {cat: 0x25, setID: 0x24}, +} // Size: 356 bytes + +var cardinalIndex = []uint8{ // 36 elements + 0x00, 0x00, 0x02, 0x03, 0x04, 0x06, 0x09, 0x0a, + 0x0c, 0x0d, 0x10, 0x14, 0x17, 0x1d, 0x28, 0x2b, + 0x2d, 0x2f, 0x32, 0x38, 0x42, 0x45, 0x4c, 0x55, + 0x5c, 0x61, 0x6d, 0x74, 0x79, 0x7d, 0x89, 0x91, + 0x95, 0x9c, 0xa1, 0xa6, +} // Size: 60 bytes + +var cardinalLangToIndex = []uint8{ // 775 elements + // Entry 0 - 3F + 0x00, 0x08, 0x08, 0x08, 0x00, 0x00, 0x06, 0x06, + 0x01, 0x01, 0x21, 0x21, 0x21, 0x21, 0x21, 0x21, + 0x21, 0x21, 0x21, 0x21, 0x21, 0x21, 0x21, 0x21, + 0x21, 0x21, 0x21, 0x21, 0x21, 0x21, 0x21, 0x21, + 0x21, 0x21, 0x21, 0x21, 0x21, 0x21, 0x21, 0x21, + 0x01, 0x01, 0x08, 0x08, 0x04, 0x04, 0x08, 0x08, + 0x08, 0x08, 0x08, 0x00, 0x00, 0x1a, 0x1a, 0x08, + 0x08, 0x08, 0x08, 0x08, 0x08, 0x06, 0x00, 0x00, + // Entry 40 - 7F + 0x01, 0x01, 0x01, 0x00, 0x00, 0x00, 0x1e, 0x1e, + 0x08, 0x08, 0x13, 0x13, 0x13, 0x13, 0x13, 0x04, + 0x04, 0x04, 0x04, 0x04, 0x00, 0x00, 0x00, 0x08, + 0x08, 0x08, 0x08, 0x08, 0x08, 0x08, 0x08, 0x08, + 0x18, 0x18, 0x00, 0x00, 0x22, 0x22, 0x09, 0x09, + 0x09, 0x00, 0x00, 0x04, 0x04, 0x04, 0x04, 0x04, + 0x04, 0x04, 0x04, 0x00, 0x00, 0x16, 0x16, 0x00, + 0x00, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + // Entry 80 - BF + 0x08, 0x08, 0x08, 0x08, 0x08, 0x08, 0x04, 0x04, + 0x04, 0x04, 0x04, 0x04, 0x04, 0x04, 0x04, 0x04, + 0x04, 0x04, 0x04, 0x04, 0x04, 0x04, 0x04, 0x04, + 0x04, 0x04, 0x04, 0x04, 0x04, 0x04, 0x04, 0x04, + 0x04, 0x04, 0x04, 0x04, 0x04, 0x04, 0x04, 0x04, + 0x04, 0x04, 0x04, 0x04, 0x04, 0x04, 0x04, 0x04, + 0x04, 0x04, 0x04, 0x04, 0x04, 0x04, 0x04, 0x04, + 0x04, 0x04, 0x04, 0x04, 0x04, 0x04, 0x04, 0x04, + // Entry C0 - FF + 0x04, 0x04, 0x04, 0x04, 0x04, 0x04, 0x04, 0x04, + 0x04, 0x04, 0x04, 0x04, 0x04, 0x04, 0x04, 0x04, + 0x04, 0x04, 0x04, 0x04, 0x04, 0x04, 0x04, 0x04, + 0x04, 0x04, 0x04, 0x04, 0x04, 0x04, 0x04, 0x04, + 0x04, 0x04, 0x04, 0x04, 0x04, 0x04, 0x04, 0x04, + 0x04, 0x04, 0x04, 0x04, 0x04, 0x04, 0x04, 0x08, + 0x08, 0x08, 0x08, 0x08, 0x08, 0x08, 0x08, 0x08, + 0x08, 0x08, 0x08, 0x08, 0x08, 0x08, 0x08, 0x08, + // Entry 100 - 13F + 0x08, 0x08, 0x08, 0x08, 0x08, 0x08, 0x08, 0x08, + 0x08, 0x08, 0x08, 0x08, 0x08, 0x08, 0x04, 0x04, + 0x08, 0x08, 0x00, 0x00, 0x01, 0x01, 0x01, 0x02, + 0x02, 0x02, 0x02, 0x02, 0x04, 0x04, 0x0c, 0x0c, + 0x08, 0x08, 0x08, 0x02, 0x02, 0x02, 0x02, 0x02, + 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, + 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, + 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, + // Entry 140 - 17F + 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, + 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, + 0x02, 0x02, 0x08, 0x08, 0x04, 0x04, 0x1f, 0x1f, + 0x14, 0x14, 0x04, 0x04, 0x08, 0x08, 0x08, 0x08, + 0x01, 0x01, 0x06, 0x00, 0x00, 0x20, 0x20, 0x08, + 0x08, 0x08, 0x08, 0x08, 0x08, 0x17, 0x17, 0x01, + 0x01, 0x13, 0x13, 0x13, 0x16, 0x16, 0x08, 0x08, + 0x02, 0x02, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + // Entry 180 - 1BF + 0x00, 0x04, 0x0a, 0x0a, 0x04, 0x04, 0x04, 0x04, + 0x04, 0x10, 0x17, 0x00, 0x00, 0x00, 0x08, 0x08, + 0x04, 0x08, 0x08, 0x00, 0x00, 0x08, 0x08, 0x02, + 0x02, 0x08, 0x00, 0x00, 0x08, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x08, 0x08, 0x08, + 0x08, 0x08, 0x08, 0x00, 0x00, 0x00, 0x00, 0x01, + 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x08, 0x08, + 0x08, 0x08, 0x00, 0x00, 0x0f, 0x0f, 0x08, 0x10, + // Entry 1C0 - 1FF + 0x10, 0x08, 0x08, 0x0e, 0x0e, 0x08, 0x08, 0x08, + 0x08, 0x00, 0x00, 0x06, 0x06, 0x06, 0x06, 0x06, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x1b, 0x1b, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x0d, 0x0d, 0x08, + 0x08, 0x08, 0x00, 0x00, 0x00, 0x00, 0x06, 0x06, + 0x00, 0x00, 0x08, 0x08, 0x0b, 0x0b, 0x08, 0x08, + 0x08, 0x08, 0x12, 0x01, 0x01, 0x00, 0x00, 0x00, + 0x00, 0x1c, 0x1c, 0x00, 0x00, 0x00, 0x00, 0x00, + // Entry 200 - 23F + 0x00, 0x08, 0x10, 0x10, 0x08, 0x08, 0x08, 0x08, + 0x08, 0x00, 0x00, 0x00, 0x08, 0x08, 0x08, 0x04, + 0x04, 0x04, 0x04, 0x04, 0x04, 0x04, 0x04, 0x00, + 0x00, 0x08, 0x08, 0x08, 0x08, 0x08, 0x00, 0x08, + 0x06, 0x00, 0x00, 0x08, 0x08, 0x08, 0x08, 0x08, + 0x08, 0x08, 0x08, 0x08, 0x08, 0x08, 0x06, 0x06, + 0x06, 0x06, 0x06, 0x08, 0x19, 0x19, 0x0d, 0x0d, + 0x08, 0x08, 0x03, 0x04, 0x03, 0x04, 0x04, 0x04, + // Entry 240 - 27F + 0x04, 0x04, 0x04, 0x04, 0x04, 0x04, 0x04, 0x00, + 0x00, 0x00, 0x00, 0x08, 0x08, 0x00, 0x00, 0x12, + 0x12, 0x12, 0x08, 0x08, 0x1d, 0x1d, 0x1d, 0x1d, + 0x1d, 0x1d, 0x1d, 0x00, 0x00, 0x08, 0x08, 0x00, + 0x00, 0x08, 0x08, 0x00, 0x00, 0x08, 0x08, 0x08, + 0x10, 0x10, 0x10, 0x10, 0x08, 0x08, 0x00, 0x00, + 0x00, 0x00, 0x13, 0x11, 0x11, 0x11, 0x11, 0x11, + 0x05, 0x05, 0x18, 0x18, 0x15, 0x15, 0x10, 0x10, + // Entry 280 - 2BF + 0x10, 0x10, 0x10, 0x10, 0x08, 0x08, 0x08, 0x08, + 0x08, 0x08, 0x08, 0x08, 0x08, 0x08, 0x08, 0x13, + 0x13, 0x13, 0x13, 0x13, 0x13, 0x13, 0x13, 0x13, + 0x13, 0x13, 0x08, 0x08, 0x08, 0x04, 0x04, 0x04, + 0x04, 0x04, 0x04, 0x04, 0x04, 0x04, 0x08, 0x08, + 0x08, 0x08, 0x08, 0x08, 0x08, 0x08, 0x08, 0x08, + 0x08, 0x00, 0x00, 0x00, 0x00, 0x06, 0x06, 0x06, + 0x08, 0x08, 0x08, 0x0c, 0x08, 0x00, 0x00, 0x08, + // Entry 2C0 - 2FF + 0x08, 0x08, 0x08, 0x00, 0x00, 0x00, 0x00, 0x07, + 0x07, 0x08, 0x08, 0x1d, 0x1d, 0x04, 0x04, 0x04, + 0x08, 0x08, 0x08, 0x08, 0x08, 0x08, 0x08, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x08, 0x00, 0x00, 0x08, + 0x08, 0x08, 0x08, 0x06, 0x08, 0x08, 0x00, 0x00, + 0x08, 0x08, 0x08, 0x00, 0x00, 0x04, 0x04, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + // Entry 300 - 33F + 0x00, 0x00, 0x00, 0x01, 0x01, 0x04, 0x04, +} // Size: 799 bytes + +var cardinalInclusionMasks = []uint64{ // 100 elements + // Entry 0 - 1F + 0x0000000200500419, 0x0000000000512153, 0x000000000a327105, 0x0000000ca23c7101, + 0x00000004a23c7201, 0x0000000482943001, 0x0000001482943201, 0x0000000502943001, + 0x0000000502943001, 0x0000000522943201, 0x0000000540543401, 0x00000000454128e1, + 0x000000005b02e821, 0x000000006304e821, 0x000000006304ea21, 0x0000000042842821, + 0x0000000042842a21, 0x0000000042842821, 0x0000000042842821, 0x0000000062842a21, + 0x0000000200400421, 0x0000000000400061, 0x000000000a004021, 0x0000000022004021, + 0x0000000022004221, 0x0000000002800021, 0x0000000002800221, 0x0000000002800021, + 0x0000000002800021, 0x0000000022800221, 0x0000000000400421, 0x0000000000400061, + // Entry 20 - 3F + 0x000000000a004021, 0x0000000022004021, 0x0000000022004221, 0x0000000002800021, + 0x0000000002800221, 0x0000000002800021, 0x0000000002800021, 0x0000000022800221, + 0x0000000200400421, 0x0000000000400061, 0x000000000a004021, 0x0000000022004021, + 0x0000000022004221, 0x0000000002800021, 0x0000000002800221, 0x0000000002800021, + 0x0000000002800021, 0x0000000022800221, 0x0000000000400421, 0x0000000000400061, + 0x000000000a004021, 0x0000000022004021, 0x0000000022004221, 0x0000000002800021, + 0x0000000002800221, 0x0000000002800021, 0x0000000002800021, 0x0000000022800221, + 0x0000000200400421, 0x0000000000400061, 0x000000000a004021, 0x0000000022004021, + // Entry 40 - 5F + 0x0000000022004221, 0x0000000002800021, 0x0000000002800221, 0x0000000002800021, + 0x0000000002800021, 0x0000000022800221, 0x0000000040400421, 0x0000000044400061, + 0x000000005a004021, 0x0000000062004021, 0x0000000062004221, 0x0000000042800021, + 0x0000000042800221, 0x0000000042800021, 0x0000000042800021, 0x0000000062800221, + 0x0000000200400421, 0x0000000000400061, 0x000000000a004021, 0x0000000022004021, + 0x0000000022004221, 0x0000000002800021, 0x0000000002800221, 0x0000000002800021, + 0x0000000002800021, 0x0000000022800221, 0x0000000040400421, 0x0000000044400061, + 0x000000005a004021, 0x0000000062004021, 0x0000000062004221, 0x0000000042800021, + // Entry 60 - 7F + 0x0000000042800221, 0x0000000042800021, 0x0000000042800021, 0x0000000062800221, +} // Size: 824 bytes + +// Slots used for cardinal: A6 of 0xFF rules; 24 of 0xFF indexes; 37 of 64 sets + +// Total table size 3860 bytes (3KiB); checksum: AAFBF21 diff --git a/vendor/golang.org/x/text/internal/catmsg/catmsg.go b/vendor/golang.org/x/text/internal/catmsg/catmsg.go new file mode 100644 index 00000000..1b257a7b --- /dev/null +++ b/vendor/golang.org/x/text/internal/catmsg/catmsg.go @@ -0,0 +1,417 @@ +// Copyright 2017 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +// Package catmsg contains support types for package x/text/message/catalog. +// +// This package contains the low-level implementations of Message used by the +// catalog package and provides primitives for other packages to implement their +// own. For instance, the plural package provides functionality for selecting +// translation strings based on the plural category of substitution arguments. +// +// # Encoding and Decoding +// +// Catalogs store Messages encoded as a single string. Compiling a message into +// a string both results in compacter representation and speeds up evaluation. +// +// A Message must implement a Compile method to convert its arbitrary +// representation to a string. The Compile method takes an Encoder which +// facilitates serializing the message. Encoders also provide more context of +// the messages's creation (such as for which language the message is intended), +// which may not be known at the time of the creation of the message. +// +// Each message type must also have an accompanying decoder registered to decode +// the message. This decoder takes a Decoder argument which provides the +// counterparts for the decoding. +// +// # Renderers +// +// A Decoder must be initialized with a Renderer implementation. These +// implementations must be provided by packages that use Catalogs, typically +// formatting packages such as x/text/message. A typical user will not need to +// worry about this type; it is only relevant to packages that do string +// formatting and want to use the catalog package to handle localized strings. +// +// A package that uses catalogs for selecting strings receives selection results +// as sequence of substrings passed to the Renderer. The following snippet shows +// how to express the above example using the message package. +// +// message.Set(language.English, "You are %d minute(s) late.", +// catalog.Var("minutes", plural.Select(1, "one", "minute")), +// catalog.String("You are %[1]d ${minutes} late.")) +// +// p := message.NewPrinter(language.English) +// p.Printf("You are %d minute(s) late.", 5) // always 5 minutes late. +// +// To evaluate the Printf, package message wraps the arguments in a Renderer +// that is passed to the catalog for message decoding. The call sequence that +// results from evaluating the above message, assuming the person is rather +// tardy, is: +// +// Render("You are %[1]d ") +// Arg(1) +// Render("minutes") +// Render(" late.") +// +// The calls to Arg is caused by the plural.Select execution, which evaluates +// the argument to determine whether the singular or plural message form should +// be selected. The calls to Render reports the partial results to the message +// package for further evaluation. +package catmsg + +import ( + "errors" + "fmt" + "strconv" + "strings" + "sync" + + "golang.org/x/text/language" +) + +// A Handle refers to a registered message type. +type Handle int + +// A Handler decodes and evaluates data compiled by a Message and sends the +// result to the Decoder. The output may depend on the value of the substitution +// arguments, accessible by the Decoder's Arg method. The Handler returns false +// if there is no translation for the given substitution arguments. +type Handler func(d *Decoder) bool + +// Register records the existence of a message type and returns a Handle that +// can be used in the Encoder's EncodeMessageType method to create such +// messages. The prefix of the name should be the package path followed by +// an optional disambiguating string. +// Register will panic if a handle for the same name was already registered. +func Register(name string, handler Handler) Handle { + mutex.Lock() + defer mutex.Unlock() + + if _, ok := names[name]; ok { + panic(fmt.Errorf("catmsg: handler for %q already exists", name)) + } + h := Handle(len(handlers)) + names[name] = h + handlers = append(handlers, handler) + return h +} + +// These handlers require fixed positions in the handlers slice. +const ( + msgVars Handle = iota + msgFirst + msgRaw + msgString + msgAffix + // Leave some arbitrary room for future expansion: 20 should suffice. + numInternal = 20 +) + +const prefix = "golang.org/x/text/internal/catmsg." + +var ( + // TODO: find a more stable way to link handles to message types. + mutex sync.Mutex + names = map[string]Handle{ + prefix + "Vars": msgVars, + prefix + "First": msgFirst, + prefix + "Raw": msgRaw, + prefix + "String": msgString, + prefix + "Affix": msgAffix, + } + handlers = make([]Handler, numInternal) +) + +func init() { + // This handler is a message type wrapper that initializes a decoder + // with a variable block. This message type, if present, is always at the + // start of an encoded message. + handlers[msgVars] = func(d *Decoder) bool { + blockSize := int(d.DecodeUint()) + d.vars = d.data[:blockSize] + d.data = d.data[blockSize:] + return d.executeMessage() + } + + // First takes the first message in a sequence that results in a match for + // the given substitution arguments. + handlers[msgFirst] = func(d *Decoder) bool { + for !d.Done() { + if d.ExecuteMessage() { + return true + } + } + return false + } + + handlers[msgRaw] = func(d *Decoder) bool { + d.Render(d.data) + return true + } + + // A String message alternates between a string constant and a variable + // substitution. + handlers[msgString] = func(d *Decoder) bool { + for !d.Done() { + if str := d.DecodeString(); str != "" { + d.Render(str) + } + if d.Done() { + break + } + d.ExecuteSubstitution() + } + return true + } + + handlers[msgAffix] = func(d *Decoder) bool { + // TODO: use an alternative method for common cases. + prefix := d.DecodeString() + suffix := d.DecodeString() + if prefix != "" { + d.Render(prefix) + } + ret := d.ExecuteMessage() + if suffix != "" { + d.Render(suffix) + } + return ret + } +} + +var ( + // ErrIncomplete indicates a compiled message does not define translations + // for all possible argument values. If this message is returned, evaluating + // a message may result in the ErrNoMatch error. + ErrIncomplete = errors.New("catmsg: incomplete message; may not give result for all inputs") + + // ErrNoMatch indicates no translation message matched the given input + // parameters when evaluating a message. + ErrNoMatch = errors.New("catmsg: no translation for inputs") +) + +// A Message holds a collection of translations for the same phrase that may +// vary based on the values of substitution arguments. +type Message interface { + // Compile encodes the format string(s) of the message as a string for later + // evaluation. + // + // The first call Compile makes on the encoder must be EncodeMessageType. + // The handle passed to this call may either be a handle returned by + // Register to encode a single custom message, or HandleFirst followed by + // a sequence of calls to EncodeMessage. + // + // Compile must return ErrIncomplete if it is possible for evaluation to + // not match any translation for a given set of formatting parameters. + // For example, selecting a translation based on plural form may not yield + // a match if the form "Other" is not one of the selectors. + // + // Compile may return any other application-specific error. For backwards + // compatibility with package like fmt, which often do not do sanity + // checking of format strings ahead of time, Compile should still make an + // effort to have some sensible fallback in case of an error. + Compile(e *Encoder) error +} + +// Compile converts a Message to a data string that can be stored in a Catalog. +// The resulting string can subsequently be decoded by passing to the Execute +// method of a Decoder. +func Compile(tag language.Tag, macros Dictionary, m Message) (data string, err error) { + // TODO: pass macros so they can be used for validation. + v := &Encoder{inBody: true} // encoder for variables + v.root = v + e := &Encoder{root: v, parent: v, tag: tag} // encoder for messages + err = m.Compile(e) + // This package serves te message package, which in turn is meant to be a + // drop-in replacement for fmt. With the fmt package, format strings are + // evaluated lazily and errors are handled by substituting strings in the + // result, rather then returning an error. Dealing with multiple languages + // makes it more important to check errors ahead of time. We chose to be + // consistent and compatible and allow graceful degradation in case of + // errors. + buf := e.buf[stripPrefix(e.buf):] + if len(v.buf) > 0 { + // Prepend variable block. + b := make([]byte, 1+maxVarintBytes+len(v.buf)+len(buf)) + b[0] = byte(msgVars) + b = b[:1+encodeUint(b[1:], uint64(len(v.buf)))] + b = append(b, v.buf...) + b = append(b, buf...) + buf = b + } + if err == nil { + err = v.err + } + return string(buf), err +} + +// FirstOf is a message type that prints the first message in the sequence that +// resolves to a match for the given substitution arguments. +type FirstOf []Message + +// Compile implements Message. +func (s FirstOf) Compile(e *Encoder) error { + e.EncodeMessageType(msgFirst) + err := ErrIncomplete + for i, m := range s { + if err == nil { + return fmt.Errorf("catalog: message argument %d is complete and blocks subsequent messages", i-1) + } + err = e.EncodeMessage(m) + } + return err +} + +// Var defines a message that can be substituted for a placeholder of the same +// name. If an expression does not result in a string after evaluation, Name is +// used as the substitution. For example: +// +// Var{ +// Name: "minutes", +// Message: plural.Select(1, "one", "minute"), +// } +// +// will resolve to minute for singular and minutes for plural forms. +type Var struct { + Name string + Message Message +} + +var errIsVar = errors.New("catmsg: variable used as message") + +// Compile implements Message. +// +// Note that this method merely registers a variable; it does not create an +// encoded message. +func (v *Var) Compile(e *Encoder) error { + if err := e.addVar(v.Name, v.Message); err != nil { + return err + } + // Using a Var by itself is an error. If it is in a sequence followed by + // other messages referring to it, this error will be ignored. + return errIsVar +} + +// Raw is a message consisting of a single format string that is passed as is +// to the Renderer. +// +// Note that a Renderer may still do its own variable substitution. +type Raw string + +// Compile implements Message. +func (r Raw) Compile(e *Encoder) (err error) { + e.EncodeMessageType(msgRaw) + // Special case: raw strings don't have a size encoding and so don't use + // EncodeString. + e.buf = append(e.buf, r...) + return nil +} + +// String is a message consisting of a single format string which contains +// placeholders that may be substituted with variables. +// +// Variable substitutions are marked with placeholders and a variable name of +// the form ${name}. Any other substitutions such as Go templates or +// printf-style substitutions are left to be done by the Renderer. +// +// When evaluation a string interpolation, a Renderer will receive separate +// calls for each placeholder and interstitial string. For example, for the +// message: "%[1]v ${invites} %[2]v to ${their} party." The sequence of calls +// is: +// +// d.Render("%[1]v ") +// d.Arg(1) +// d.Render(resultOfInvites) +// d.Render(" %[2]v to ") +// d.Arg(2) +// d.Render(resultOfTheir) +// d.Render(" party.") +// +// where the messages for "invites" and "their" both use a plural.Select +// referring to the first argument. +// +// Strings may also invoke macros. Macros are essentially variables that can be +// reused. Macros may, for instance, be used to make selections between +// different conjugations of a verb. See the catalog package description for an +// overview of macros. +type String string + +// Compile implements Message. It parses the placeholder formats and returns +// any error. +func (s String) Compile(e *Encoder) (err error) { + msg := string(s) + const subStart = "${" + hasHeader := false + p := 0 + b := []byte{} + for { + i := strings.Index(msg[p:], subStart) + if i == -1 { + break + } + b = append(b, msg[p:p+i]...) + p += i + len(subStart) + if i = strings.IndexByte(msg[p:], '}'); i == -1 { + b = append(b, "$!(MISSINGBRACE)"...) + err = fmt.Errorf("catmsg: missing '}'") + p = len(msg) + break + } + name := strings.TrimSpace(msg[p : p+i]) + if q := strings.IndexByte(name, '('); q == -1 { + if !hasHeader { + hasHeader = true + e.EncodeMessageType(msgString) + } + e.EncodeString(string(b)) + e.EncodeSubstitution(name) + b = b[:0] + } else if j := strings.IndexByte(name[q:], ')'); j == -1 { + // TODO: what should the error be? + b = append(b, "$!(MISSINGPAREN)"...) + err = fmt.Errorf("catmsg: missing ')'") + } else if x, sErr := strconv.ParseUint(strings.TrimSpace(name[q+1:q+j]), 10, 32); sErr != nil { + // TODO: handle more than one argument + b = append(b, "$!(BADNUM)"...) + err = fmt.Errorf("catmsg: invalid number %q", strings.TrimSpace(name[q+1:q+j])) + } else { + if !hasHeader { + hasHeader = true + e.EncodeMessageType(msgString) + } + e.EncodeString(string(b)) + e.EncodeSubstitution(name[:q], int(x)) + b = b[:0] + } + p += i + 1 + } + b = append(b, msg[p:]...) + if !hasHeader { + // Simplify string to a raw string. + Raw(string(b)).Compile(e) + } else if len(b) > 0 { + e.EncodeString(string(b)) + } + return err +} + +// Affix is a message that adds a prefix and suffix to another message. +// This is mostly used add back whitespace to a translation that was stripped +// before sending it out. +type Affix struct { + Message Message + Prefix string + Suffix string +} + +// Compile implements Message. +func (a Affix) Compile(e *Encoder) (err error) { + // TODO: consider adding a special message type that just adds a single + // return. This is probably common enough to handle the majority of cases. + // Get some stats first, though. + e.EncodeMessageType(msgAffix) + e.EncodeString(a.Prefix) + e.EncodeString(a.Suffix) + e.EncodeMessage(a.Message) + return nil +} diff --git a/vendor/golang.org/x/text/internal/catmsg/codec.go b/vendor/golang.org/x/text/internal/catmsg/codec.go new file mode 100644 index 00000000..547802b0 --- /dev/null +++ b/vendor/golang.org/x/text/internal/catmsg/codec.go @@ -0,0 +1,407 @@ +// Copyright 2017 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package catmsg + +import ( + "errors" + "fmt" + + "golang.org/x/text/language" +) + +// A Renderer renders a Message. +type Renderer interface { + // Render renders the given string. The given string may be interpreted as a + // format string, such as the one used by the fmt package or a template. + Render(s string) + + // Arg returns the i-th argument passed to format a message. This method + // should return nil if there is no such argument. Messages need access to + // arguments to allow selecting a message based on linguistic features of + // those arguments. + Arg(i int) interface{} +} + +// A Dictionary specifies a source of messages, including variables or macros. +type Dictionary interface { + // Lookup returns the message for the given key. It returns false for ok if + // such a message could not be found. + Lookup(key string) (data string, ok bool) + + // TODO: consider returning an interface, instead of a string. This will + // allow implementations to do their own message type decoding. +} + +// An Encoder serializes a Message to a string. +type Encoder struct { + // The root encoder is used for storing encoded variables. + root *Encoder + // The parent encoder provides the surrounding scopes for resolving variable + // names. + parent *Encoder + + tag language.Tag + + // buf holds the encoded message so far. After a message completes encoding, + // the contents of buf, prefixed by the encoded length, are flushed to the + // parent buffer. + buf []byte + + // vars is the lookup table of variables in the current scope. + vars []keyVal + + err error + inBody bool // if false next call must be EncodeMessageType +} + +type keyVal struct { + key string + offset int +} + +// Language reports the language for which the encoded message will be stored +// in the Catalog. +func (e *Encoder) Language() language.Tag { return e.tag } + +func (e *Encoder) setError(err error) { + if e.root.err == nil { + e.root.err = err + } +} + +// EncodeUint encodes x. +func (e *Encoder) EncodeUint(x uint64) { + e.checkInBody() + var buf [maxVarintBytes]byte + n := encodeUint(buf[:], x) + e.buf = append(e.buf, buf[:n]...) +} + +// EncodeString encodes s. +func (e *Encoder) EncodeString(s string) { + e.checkInBody() + e.EncodeUint(uint64(len(s))) + e.buf = append(e.buf, s...) +} + +// EncodeMessageType marks the current message to be of type h. +// +// It must be the first call of a Message's Compile method. +func (e *Encoder) EncodeMessageType(h Handle) { + if e.inBody { + panic("catmsg: EncodeMessageType not the first method called") + } + e.inBody = true + e.EncodeUint(uint64(h)) +} + +// EncodeMessage serializes the given message inline at the current position. +func (e *Encoder) EncodeMessage(m Message) error { + e = &Encoder{root: e.root, parent: e, tag: e.tag} + err := m.Compile(e) + if _, ok := m.(*Var); !ok { + e.flushTo(e.parent) + } + return err +} + +func (e *Encoder) checkInBody() { + if !e.inBody { + panic("catmsg: expected prior call to EncodeMessageType") + } +} + +// stripPrefix indicates the number of prefix bytes that must be stripped to +// turn a single-element sequence into a message that is just this single member +// without its size prefix. If the message can be stripped, b[1:n] contains the +// size prefix. +func stripPrefix(b []byte) (n int) { + if len(b) > 0 && Handle(b[0]) == msgFirst { + x, n, _ := decodeUint(b[1:]) + if 1+n+int(x) == len(b) { + return 1 + n + } + } + return 0 +} + +func (e *Encoder) flushTo(dst *Encoder) { + data := e.buf + p := stripPrefix(data) + if p > 0 { + data = data[1:] + } else { + // Prefix the size. + dst.EncodeUint(uint64(len(data))) + } + dst.buf = append(dst.buf, data...) +} + +func (e *Encoder) addVar(key string, m Message) error { + for _, v := range e.parent.vars { + if v.key == key { + err := fmt.Errorf("catmsg: duplicate variable %q", key) + e.setError(err) + return err + } + } + scope := e.parent + // If a variable message is Incomplete, and does not evaluate to a message + // during execution, we fall back to the variable name. We encode this by + // appending the variable name if the message reports it's incomplete. + + err := m.Compile(e) + if err != ErrIncomplete { + e.setError(err) + } + switch { + case len(e.buf) == 1 && Handle(e.buf[0]) == msgFirst: // empty sequence + e.buf = e.buf[:0] + e.inBody = false + fallthrough + case len(e.buf) == 0: + // Empty message. + if err := String(key).Compile(e); err != nil { + e.setError(err) + } + case err == ErrIncomplete: + if Handle(e.buf[0]) != msgFirst { + seq := &Encoder{root: e.root, parent: e} + seq.EncodeMessageType(msgFirst) + e.flushTo(seq) + e = seq + } + // e contains a sequence; append the fallback string. + e.EncodeMessage(String(key)) + } + + // Flush result to variable heap. + offset := len(e.root.buf) + e.flushTo(e.root) + e.buf = e.buf[:0] + + // Record variable offset in current scope. + scope.vars = append(scope.vars, keyVal{key: key, offset: offset}) + return err +} + +const ( + substituteVar = iota + substituteMacro + substituteError +) + +// EncodeSubstitution inserts a resolved reference to a variable or macro. +// +// This call must be matched with a call to ExecuteSubstitution at decoding +// time. +func (e *Encoder) EncodeSubstitution(name string, arguments ...int) { + if arity := len(arguments); arity > 0 { + // TODO: also resolve macros. + e.EncodeUint(substituteMacro) + e.EncodeString(name) + for _, a := range arguments { + e.EncodeUint(uint64(a)) + } + return + } + for scope := e; scope != nil; scope = scope.parent { + for _, v := range scope.vars { + if v.key != name { + continue + } + e.EncodeUint(substituteVar) // TODO: support arity > 0 + e.EncodeUint(uint64(v.offset)) + return + } + } + // TODO: refer to dictionary-wide scoped variables. + e.EncodeUint(substituteError) + e.EncodeString(name) + e.setError(fmt.Errorf("catmsg: unknown var %q", name)) +} + +// A Decoder deserializes and evaluates messages that are encoded by an encoder. +type Decoder struct { + tag language.Tag + dst Renderer + macros Dictionary + + err error + vars string + data string + + macroArg int // TODO: allow more than one argument +} + +// NewDecoder returns a new Decoder. +// +// Decoders are designed to be reused for multiple invocations of Execute. +// Only one goroutine may call Execute concurrently. +func NewDecoder(tag language.Tag, r Renderer, macros Dictionary) *Decoder { + return &Decoder{ + tag: tag, + dst: r, + macros: macros, + } +} + +func (d *Decoder) setError(err error) { + if d.err == nil { + d.err = err + } +} + +// Language returns the language in which the message is being rendered. +// +// The destination language may be a child language of the language used for +// encoding. For instance, a decoding language of "pt-PT" is consistent with an +// encoding language of "pt". +func (d *Decoder) Language() language.Tag { return d.tag } + +// Done reports whether there are more bytes to process in this message. +func (d *Decoder) Done() bool { return len(d.data) == 0 } + +// Render implements Renderer. +func (d *Decoder) Render(s string) { d.dst.Render(s) } + +// Arg implements Renderer. +// +// During evaluation of macros, the argument positions may be mapped to +// arguments that differ from the original call. +func (d *Decoder) Arg(i int) interface{} { + if d.macroArg != 0 { + if i != 1 { + panic("catmsg: only macros with single argument supported") + } + i = d.macroArg + } + return d.dst.Arg(i) +} + +// DecodeUint decodes a number that was encoded with EncodeUint and advances the +// position. +func (d *Decoder) DecodeUint() uint64 { + x, n, err := decodeUintString(d.data) + d.data = d.data[n:] + if err != nil { + d.setError(err) + } + return x +} + +// DecodeString decodes a string that was encoded with EncodeString and advances +// the position. +func (d *Decoder) DecodeString() string { + size := d.DecodeUint() + s := d.data[:size] + d.data = d.data[size:] + return s +} + +// SkipMessage skips the message at the current location and advances the +// position. +func (d *Decoder) SkipMessage() { + n := int(d.DecodeUint()) + d.data = d.data[n:] +} + +// Execute decodes and evaluates msg. +// +// Only one goroutine may call execute. +func (d *Decoder) Execute(msg string) error { + d.err = nil + if !d.execute(msg) { + return ErrNoMatch + } + return d.err +} + +func (d *Decoder) execute(msg string) bool { + saved := d.data + d.data = msg + ok := d.executeMessage() + d.data = saved + return ok +} + +// executeMessageFromData is like execute, but also decodes a leading message +// size and clips the given string accordingly. +// +// It reports the number of bytes consumed and whether a message was selected. +func (d *Decoder) executeMessageFromData(s string) (n int, ok bool) { + saved := d.data + d.data = s + size := int(d.DecodeUint()) + n = len(s) - len(d.data) + // Sanitize the setting. This allows skipping a size argument for + // RawString and method Done. + d.data = d.data[:size] + ok = d.executeMessage() + n += size - len(d.data) + d.data = saved + return n, ok +} + +var errUnknownHandler = errors.New("catmsg: string contains unsupported handler") + +// executeMessage reads the handle id, initializes the decoder and executes the +// message. It is assumed that all of d.data[d.p:] is the single message. +func (d *Decoder) executeMessage() bool { + if d.Done() { + // We interpret no data as a valid empty message. + return true + } + handle := d.DecodeUint() + + var fn Handler + mutex.Lock() + if int(handle) < len(handlers) { + fn = handlers[handle] + } + mutex.Unlock() + if fn == nil { + d.setError(errUnknownHandler) + d.execute(fmt.Sprintf("\x02$!(UNKNOWNMSGHANDLER=%#x)", handle)) + return true + } + return fn(d) +} + +// ExecuteMessage decodes and executes the message at the current position. +func (d *Decoder) ExecuteMessage() bool { + n, ok := d.executeMessageFromData(d.data) + d.data = d.data[n:] + return ok +} + +// ExecuteSubstitution executes the message corresponding to the substitution +// as encoded by EncodeSubstitution. +func (d *Decoder) ExecuteSubstitution() { + switch x := d.DecodeUint(); x { + case substituteVar: + offset := d.DecodeUint() + d.executeMessageFromData(d.vars[offset:]) + case substituteMacro: + name := d.DecodeString() + data, ok := d.macros.Lookup(name) + old := d.macroArg + // TODO: support macros of arity other than 1. + d.macroArg = int(d.DecodeUint()) + switch { + case !ok: + // TODO: detect this at creation time. + d.setError(fmt.Errorf("catmsg: undefined macro %q", name)) + fallthrough + case !d.execute(data): + d.dst.Render(name) // fall back to macro name. + } + d.macroArg = old + case substituteError: + d.dst.Render(d.DecodeString()) + default: + panic("catmsg: unreachable") + } +} diff --git a/vendor/golang.org/x/text/internal/catmsg/varint.go b/vendor/golang.org/x/text/internal/catmsg/varint.go new file mode 100644 index 00000000..a2cee2cf --- /dev/null +++ b/vendor/golang.org/x/text/internal/catmsg/varint.go @@ -0,0 +1,62 @@ +// Copyright 2017 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package catmsg + +// This file implements varint encoding analogous to the one in encoding/binary. +// We need a string version of this function, so we add that here and then add +// the rest for consistency. + +import "errors" + +var ( + errIllegalVarint = errors.New("catmsg: illegal varint") + errVarintTooLarge = errors.New("catmsg: varint too large for uint64") +) + +const maxVarintBytes = 10 // maximum length of a varint + +// encodeUint encodes x as a variable-sized integer into buf and returns the +// number of bytes written. buf must be at least maxVarintBytes long +func encodeUint(buf []byte, x uint64) (n int) { + for ; x > 127; n++ { + buf[n] = 0x80 | uint8(x&0x7F) + x >>= 7 + } + buf[n] = uint8(x) + n++ + return n +} + +func decodeUintString(s string) (x uint64, size int, err error) { + i := 0 + for shift := uint(0); shift < 64; shift += 7 { + if i >= len(s) { + return 0, i, errIllegalVarint + } + b := uint64(s[i]) + i++ + x |= (b & 0x7F) << shift + if b&0x80 == 0 { + return x, i, nil + } + } + return 0, i, errVarintTooLarge +} + +func decodeUint(b []byte) (x uint64, size int, err error) { + i := 0 + for shift := uint(0); shift < 64; shift += 7 { + if i >= len(b) { + return 0, i, errIllegalVarint + } + c := uint64(b[i]) + i++ + x |= (c & 0x7F) << shift + if c&0x80 == 0 { + return x, i, nil + } + } + return 0, i, errVarintTooLarge +} diff --git a/vendor/golang.org/x/text/internal/format/format.go b/vendor/golang.org/x/text/internal/format/format.go new file mode 100644 index 00000000..ee1c57a3 --- /dev/null +++ b/vendor/golang.org/x/text/internal/format/format.go @@ -0,0 +1,41 @@ +// Copyright 2015 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +// Package format contains types for defining language-specific formatting of +// values. +// +// This package is internal now, but will eventually be exposed after the API +// settles. +package format // import "golang.org/x/text/internal/format" + +import ( + "fmt" + + "golang.org/x/text/language" +) + +// State represents the printer state passed to custom formatters. It provides +// access to the fmt.State interface and the sentence and language-related +// context. +type State interface { + fmt.State + + // Language reports the requested language in which to render a message. + Language() language.Tag + + // TODO: consider this and removing rune from the Format method in the + // Formatter interface. + // + // Verb returns the format variant to render, analogous to the types used + // in fmt. Use 'v' for the default or only variant. + // Verb() rune + + // TODO: more info: + // - sentence context such as linguistic features passed by the translator. +} + +// Formatter is analogous to fmt.Formatter. +type Formatter interface { + Format(state State, verb rune) +} diff --git a/vendor/golang.org/x/text/internal/format/parser.go b/vendor/golang.org/x/text/internal/format/parser.go new file mode 100644 index 00000000..855aed71 --- /dev/null +++ b/vendor/golang.org/x/text/internal/format/parser.go @@ -0,0 +1,358 @@ +// Copyright 2017 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package format + +import ( + "reflect" + "unicode/utf8" +) + +// A Parser parses a format string. The result from the parse are set in the +// struct fields. +type Parser struct { + Verb rune + + WidthPresent bool + PrecPresent bool + Minus bool + Plus bool + Sharp bool + Space bool + Zero bool + + // For the formats %+v %#v, we set the plusV/sharpV flags + // and clear the plus/sharp flags since %+v and %#v are in effect + // different, flagless formats set at the top level. + PlusV bool + SharpV bool + + HasIndex bool + + Width int + Prec int // precision + + // retain arguments across calls. + Args []interface{} + // retain current argument number across calls + ArgNum int + + // reordered records whether the format string used argument reordering. + Reordered bool + // goodArgNum records whether the most recent reordering directive was valid. + goodArgNum bool + + // position info + format string + startPos int + endPos int + Status Status +} + +// Reset initializes a parser to scan format strings for the given args. +func (p *Parser) Reset(args []interface{}) { + p.Args = args + p.ArgNum = 0 + p.startPos = 0 + p.Reordered = false +} + +// Text returns the part of the format string that was parsed by the last call +// to Scan. It returns the original substitution clause if the current scan +// parsed a substitution. +func (p *Parser) Text() string { return p.format[p.startPos:p.endPos] } + +// SetFormat sets a new format string to parse. It does not reset the argument +// count. +func (p *Parser) SetFormat(format string) { + p.format = format + p.startPos = 0 + p.endPos = 0 +} + +// Status indicates the result type of a call to Scan. +type Status int + +const ( + StatusText Status = iota + StatusSubstitution + StatusBadWidthSubstitution + StatusBadPrecSubstitution + StatusNoVerb + StatusBadArgNum + StatusMissingArg +) + +// ClearFlags reset the parser to default behavior. +func (p *Parser) ClearFlags() { + p.WidthPresent = false + p.PrecPresent = false + p.Minus = false + p.Plus = false + p.Sharp = false + p.Space = false + p.Zero = false + + p.PlusV = false + p.SharpV = false + + p.HasIndex = false +} + +// Scan scans the next part of the format string and sets the status to +// indicate whether it scanned a string literal, substitution or error. +func (p *Parser) Scan() bool { + p.Status = StatusText + format := p.format + end := len(format) + if p.endPos >= end { + return false + } + afterIndex := false // previous item in format was an index like [3]. + + p.startPos = p.endPos + p.goodArgNum = true + i := p.startPos + for i < end && format[i] != '%' { + i++ + } + if i > p.startPos { + p.endPos = i + return true + } + // Process one verb + i++ + + p.Status = StatusSubstitution + + // Do we have flags? + p.ClearFlags() + +simpleFormat: + for ; i < end; i++ { + c := p.format[i] + switch c { + case '#': + p.Sharp = true + case '0': + p.Zero = !p.Minus // Only allow zero padding to the left. + case '+': + p.Plus = true + case '-': + p.Minus = true + p.Zero = false // Do not pad with zeros to the right. + case ' ': + p.Space = true + default: + // Fast path for common case of ascii lower case simple verbs + // without precision or width or argument indices. + if 'a' <= c && c <= 'z' && p.ArgNum < len(p.Args) { + if c == 'v' { + // Go syntax + p.SharpV = p.Sharp + p.Sharp = false + // Struct-field syntax + p.PlusV = p.Plus + p.Plus = false + } + p.Verb = rune(c) + p.ArgNum++ + p.endPos = i + 1 + return true + } + // Format is more complex than simple flags and a verb or is malformed. + break simpleFormat + } + } + + // Do we have an explicit argument index? + i, afterIndex = p.updateArgNumber(format, i) + + // Do we have width? + if i < end && format[i] == '*' { + i++ + p.Width, p.WidthPresent = p.intFromArg() + + if !p.WidthPresent { + p.Status = StatusBadWidthSubstitution + } + + // We have a negative width, so take its value and ensure + // that the minus flag is set + if p.Width < 0 { + p.Width = -p.Width + p.Minus = true + p.Zero = false // Do not pad with zeros to the right. + } + afterIndex = false + } else { + p.Width, p.WidthPresent, i = parsenum(format, i, end) + if afterIndex && p.WidthPresent { // "%[3]2d" + p.goodArgNum = false + } + } + + // Do we have precision? + if i+1 < end && format[i] == '.' { + i++ + if afterIndex { // "%[3].2d" + p.goodArgNum = false + } + i, afterIndex = p.updateArgNumber(format, i) + if i < end && format[i] == '*' { + i++ + p.Prec, p.PrecPresent = p.intFromArg() + // Negative precision arguments don't make sense + if p.Prec < 0 { + p.Prec = 0 + p.PrecPresent = false + } + if !p.PrecPresent { + p.Status = StatusBadPrecSubstitution + } + afterIndex = false + } else { + p.Prec, p.PrecPresent, i = parsenum(format, i, end) + if !p.PrecPresent { + p.Prec = 0 + p.PrecPresent = true + } + } + } + + if !afterIndex { + i, afterIndex = p.updateArgNumber(format, i) + } + p.HasIndex = afterIndex + + if i >= end { + p.endPos = i + p.Status = StatusNoVerb + return true + } + + verb, w := utf8.DecodeRuneInString(format[i:]) + p.endPos = i + w + p.Verb = verb + + switch { + case verb == '%': // Percent does not absorb operands and ignores f.wid and f.prec. + p.startPos = p.endPos - 1 + p.Status = StatusText + case !p.goodArgNum: + p.Status = StatusBadArgNum + case p.ArgNum >= len(p.Args): // No argument left over to print for the current verb. + p.Status = StatusMissingArg + p.ArgNum++ + case verb == 'v': + // Go syntax + p.SharpV = p.Sharp + p.Sharp = false + // Struct-field syntax + p.PlusV = p.Plus + p.Plus = false + fallthrough + default: + p.ArgNum++ + } + return true +} + +// intFromArg gets the ArgNumth element of Args. On return, isInt reports +// whether the argument has integer type. +func (p *Parser) intFromArg() (num int, isInt bool) { + if p.ArgNum < len(p.Args) { + arg := p.Args[p.ArgNum] + num, isInt = arg.(int) // Almost always OK. + if !isInt { + // Work harder. + switch v := reflect.ValueOf(arg); v.Kind() { + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: + n := v.Int() + if int64(int(n)) == n { + num = int(n) + isInt = true + } + case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64, reflect.Uintptr: + n := v.Uint() + if int64(n) >= 0 && uint64(int(n)) == n { + num = int(n) + isInt = true + } + default: + // Already 0, false. + } + } + p.ArgNum++ + if tooLarge(num) { + num = 0 + isInt = false + } + } + return +} + +// parseArgNumber returns the value of the bracketed number, minus 1 +// (explicit argument numbers are one-indexed but we want zero-indexed). +// The opening bracket is known to be present at format[0]. +// The returned values are the index, the number of bytes to consume +// up to the closing paren, if present, and whether the number parsed +// ok. The bytes to consume will be 1 if no closing paren is present. +func parseArgNumber(format string) (index int, wid int, ok bool) { + // There must be at least 3 bytes: [n]. + if len(format) < 3 { + return 0, 1, false + } + + // Find closing bracket. + for i := 1; i < len(format); i++ { + if format[i] == ']' { + width, ok, newi := parsenum(format, 1, i) + if !ok || newi != i { + return 0, i + 1, false + } + return width - 1, i + 1, true // arg numbers are one-indexed and skip paren. + } + } + return 0, 1, false +} + +// updateArgNumber returns the next argument to evaluate, which is either the value of the passed-in +// argNum or the value of the bracketed integer that begins format[i:]. It also returns +// the new value of i, that is, the index of the next byte of the format to process. +func (p *Parser) updateArgNumber(format string, i int) (newi int, found bool) { + if len(format) <= i || format[i] != '[' { + return i, false + } + p.Reordered = true + index, wid, ok := parseArgNumber(format[i:]) + if ok && 0 <= index && index < len(p.Args) { + p.ArgNum = index + return i + wid, true + } + p.goodArgNum = false + return i + wid, ok +} + +// tooLarge reports whether the magnitude of the integer is +// too large to be used as a formatting width or precision. +func tooLarge(x int) bool { + const max int = 1e6 + return x > max || x < -max +} + +// parsenum converts ASCII to integer. num is 0 (and isnum is false) if no number present. +func parsenum(s string, start, end int) (num int, isnum bool, newi int) { + if start >= end { + return 0, false, end + } + for newi = start; newi < end && '0' <= s[newi] && s[newi] <= '9'; newi++ { + if tooLarge(num) { + return 0, false, end // Overflow; crazy long number most likely. + } + num = num*10 + int(s[newi]-'0') + isnum = true + } + return +} diff --git a/vendor/golang.org/x/text/internal/number/common.go b/vendor/golang.org/x/text/internal/number/common.go new file mode 100644 index 00000000..a6e9c8e0 --- /dev/null +++ b/vendor/golang.org/x/text/internal/number/common.go @@ -0,0 +1,55 @@ +// Code generated by running "go generate" in golang.org/x/text. DO NOT EDIT. + +package number + +import ( + "unicode/utf8" + + "golang.org/x/text/internal/language/compact" +) + +// A system identifies a CLDR numbering system. +type system byte + +type systemData struct { + id system + digitSize byte // number of UTF-8 bytes per digit + zero [utf8.UTFMax]byte // UTF-8 sequence of zero digit. +} + +// A SymbolType identifies a symbol of a specific kind. +type SymbolType int + +const ( + SymDecimal SymbolType = iota + SymGroup + SymList + SymPercentSign + SymPlusSign + SymMinusSign + SymExponential + SymSuperscriptingExponent + SymPerMille + SymInfinity + SymNan + SymTimeSeparator + + NumSymbolTypes +) + +const hasNonLatnMask = 0x8000 + +// symOffset is an offset into altSymData if the bit indicated by hasNonLatnMask +// is not 0 (with this bit masked out), and an offset into symIndex otherwise. +// +// TODO: this type can be a byte again if we use an indirection into altsymData +// and introduce an alt -> offset slice (the length of this will be number of +// alternatives plus 1). This also allows getting rid of the compactTag field +// in altSymData. In total this will save about 1K. +type symOffset uint16 + +type altSymData struct { + compactTag compact.ID + symIndex symOffset + system system +} diff --git a/vendor/golang.org/x/text/internal/number/decimal.go b/vendor/golang.org/x/text/internal/number/decimal.go new file mode 100644 index 00000000..e128cf34 --- /dev/null +++ b/vendor/golang.org/x/text/internal/number/decimal.go @@ -0,0 +1,500 @@ +// Copyright 2017 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +//go:generate stringer -type RoundingMode + +package number + +import ( + "math" + "strconv" +) + +// RoundingMode determines how a number is rounded to the desired precision. +type RoundingMode byte + +const ( + ToNearestEven RoundingMode = iota // towards the nearest integer, or towards an even number if equidistant. + ToNearestZero // towards the nearest integer, or towards zero if equidistant. + ToNearestAway // towards the nearest integer, or away from zero if equidistant. + ToPositiveInf // towards infinity + ToNegativeInf // towards negative infinity + ToZero // towards zero + AwayFromZero // away from zero + numModes +) + +const maxIntDigits = 20 + +// A Decimal represents a floating point number in decimal format. +// Digits represents a number [0, 1.0), and the absolute value represented by +// Decimal is Digits * 10^Exp. Leading and trailing zeros may be omitted and Exp +// may point outside a valid position in Digits. +// +// Examples: +// +// Number Decimal +// 12345 Digits: [1, 2, 3, 4, 5], Exp: 5 +// 12.345 Digits: [1, 2, 3, 4, 5], Exp: 2 +// 12000 Digits: [1, 2], Exp: 5 +// 12000.00 Digits: [1, 2], Exp: 5 +// 0.00123 Digits: [1, 2, 3], Exp: -2 +// 0 Digits: [], Exp: 0 +type Decimal struct { + digits + + buf [maxIntDigits]byte +} + +type digits struct { + Digits []byte // mantissa digits, big-endian + Exp int32 // exponent + Neg bool + Inf bool // Takes precedence over Digits and Exp. + NaN bool // Takes precedence over Inf. +} + +// Digits represents a floating point number represented in digits of the +// base in which a number is to be displayed. It is similar to Decimal, but +// keeps track of trailing fraction zeros and the comma placement for +// engineering notation. Digits must have at least one digit. +// +// Examples: +// +// Number Decimal +// decimal +// 12345 Digits: [1, 2, 3, 4, 5], Exp: 5 End: 5 +// 12.345 Digits: [1, 2, 3, 4, 5], Exp: 2 End: 5 +// 12000 Digits: [1, 2], Exp: 5 End: 5 +// 12000.00 Digits: [1, 2], Exp: 5 End: 7 +// 0.00123 Digits: [1, 2, 3], Exp: -2 End: 3 +// 0 Digits: [], Exp: 0 End: 1 +// scientific (actual exp is Exp - Comma) +// 0e0 Digits: [0], Exp: 1, End: 1, Comma: 1 +// .0e0 Digits: [0], Exp: 0, End: 1, Comma: 0 +// 0.0e0 Digits: [0], Exp: 1, End: 2, Comma: 1 +// 1.23e4 Digits: [1, 2, 3], Exp: 5, End: 3, Comma: 1 +// .123e5 Digits: [1, 2, 3], Exp: 5, End: 3, Comma: 0 +// engineering +// 12.3e3 Digits: [1, 2, 3], Exp: 5, End: 3, Comma: 2 +type Digits struct { + digits + // End indicates the end position of the number. + End int32 // For decimals Exp <= End. For scientific len(Digits) <= End. + // Comma is used for the comma position for scientific (always 0 or 1) and + // engineering notation (always 0, 1, 2, or 3). + Comma uint8 + // IsScientific indicates whether this number is to be rendered as a + // scientific number. + IsScientific bool +} + +func (d *Digits) NumFracDigits() int { + if d.Exp >= d.End { + return 0 + } + return int(d.End - d.Exp) +} + +// normalize returns a new Decimal with leading and trailing zeros removed. +func (d *Decimal) normalize() (n Decimal) { + n = *d + b := n.Digits + // Strip leading zeros. Resulting number of digits is significant digits. + for len(b) > 0 && b[0] == 0 { + b = b[1:] + n.Exp-- + } + // Strip trailing zeros + for len(b) > 0 && b[len(b)-1] == 0 { + b = b[:len(b)-1] + } + if len(b) == 0 { + n.Exp = 0 + } + n.Digits = b + return n +} + +func (d *Decimal) clear() { + b := d.Digits + if b == nil { + b = d.buf[:0] + } + *d = Decimal{} + d.Digits = b[:0] +} + +func (x *Decimal) String() string { + if x.NaN { + return "NaN" + } + var buf []byte + if x.Neg { + buf = append(buf, '-') + } + if x.Inf { + buf = append(buf, "Inf"...) + return string(buf) + } + switch { + case len(x.Digits) == 0: + buf = append(buf, '0') + case x.Exp <= 0: + // 0.00ddd + buf = append(buf, "0."...) + buf = appendZeros(buf, -int(x.Exp)) + buf = appendDigits(buf, x.Digits) + + case /* 0 < */ int(x.Exp) < len(x.Digits): + // dd.ddd + buf = appendDigits(buf, x.Digits[:x.Exp]) + buf = append(buf, '.') + buf = appendDigits(buf, x.Digits[x.Exp:]) + + default: // len(x.Digits) <= x.Exp + // ddd00 + buf = appendDigits(buf, x.Digits) + buf = appendZeros(buf, int(x.Exp)-len(x.Digits)) + } + return string(buf) +} + +func appendDigits(buf []byte, digits []byte) []byte { + for _, c := range digits { + buf = append(buf, c+'0') + } + return buf +} + +// appendZeros appends n 0 digits to buf and returns buf. +func appendZeros(buf []byte, n int) []byte { + for ; n > 0; n-- { + buf = append(buf, '0') + } + return buf +} + +func (d *digits) round(mode RoundingMode, n int) { + if n >= len(d.Digits) { + return + } + // Make rounding decision: The result mantissa is truncated ("rounded down") + // by default. Decide if we need to increment, or "round up", the (unsigned) + // mantissa. + inc := false + switch mode { + case ToNegativeInf: + inc = d.Neg + case ToPositiveInf: + inc = !d.Neg + case ToZero: + // nothing to do + case AwayFromZero: + inc = true + case ToNearestEven: + inc = d.Digits[n] > 5 || d.Digits[n] == 5 && + (len(d.Digits) > n+1 || n == 0 || d.Digits[n-1]&1 != 0) + case ToNearestAway: + inc = d.Digits[n] >= 5 + case ToNearestZero: + inc = d.Digits[n] > 5 || d.Digits[n] == 5 && len(d.Digits) > n+1 + default: + panic("unreachable") + } + if inc { + d.roundUp(n) + } else { + d.roundDown(n) + } +} + +// roundFloat rounds a floating point number. +func (r RoundingMode) roundFloat(x float64) float64 { + // Make rounding decision: The result mantissa is truncated ("rounded down") + // by default. Decide if we need to increment, or "round up", the (unsigned) + // mantissa. + abs := x + if x < 0 { + abs = -x + } + i, f := math.Modf(abs) + if f == 0.0 { + return x + } + inc := false + switch r { + case ToNegativeInf: + inc = x < 0 + case ToPositiveInf: + inc = x >= 0 + case ToZero: + // nothing to do + case AwayFromZero: + inc = true + case ToNearestEven: + // TODO: check overflow + inc = f > 0.5 || f == 0.5 && int64(i)&1 != 0 + case ToNearestAway: + inc = f >= 0.5 + case ToNearestZero: + inc = f > 0.5 + default: + panic("unreachable") + } + if inc { + i += 1 + } + if abs != x { + i = -i + } + return i +} + +func (x *digits) roundUp(n int) { + if n < 0 || n >= len(x.Digits) { + return // nothing to do + } + // find first digit < 9 + for n > 0 && x.Digits[n-1] >= 9 { + n-- + } + + if n == 0 { + // all digits are 9s => round up to 1 and update exponent + x.Digits[0] = 1 // ok since len(x.Digits) > n + x.Digits = x.Digits[:1] + x.Exp++ + return + } + x.Digits[n-1]++ + x.Digits = x.Digits[:n] + // x already trimmed +} + +func (x *digits) roundDown(n int) { + if n < 0 || n >= len(x.Digits) { + return // nothing to do + } + x.Digits = x.Digits[:n] + trim(x) +} + +// trim cuts off any trailing zeros from x's mantissa; +// they are meaningless for the value of x. +func trim(x *digits) { + i := len(x.Digits) + for i > 0 && x.Digits[i-1] == 0 { + i-- + } + x.Digits = x.Digits[:i] + if i == 0 { + x.Exp = 0 + } +} + +// A Converter converts a number into decimals according to the given rounding +// criteria. +type Converter interface { + Convert(d *Decimal, r RoundingContext) +} + +const ( + signed = true + unsigned = false +) + +// Convert converts the given number to the decimal representation using the +// supplied RoundingContext. +func (d *Decimal) Convert(r RoundingContext, number interface{}) { + switch f := number.(type) { + case Converter: + d.clear() + f.Convert(d, r) + case float32: + d.ConvertFloat(r, float64(f), 32) + case float64: + d.ConvertFloat(r, f, 64) + case int: + d.ConvertInt(r, signed, uint64(f)) + case int8: + d.ConvertInt(r, signed, uint64(f)) + case int16: + d.ConvertInt(r, signed, uint64(f)) + case int32: + d.ConvertInt(r, signed, uint64(f)) + case int64: + d.ConvertInt(r, signed, uint64(f)) + case uint: + d.ConvertInt(r, unsigned, uint64(f)) + case uint8: + d.ConvertInt(r, unsigned, uint64(f)) + case uint16: + d.ConvertInt(r, unsigned, uint64(f)) + case uint32: + d.ConvertInt(r, unsigned, uint64(f)) + case uint64: + d.ConvertInt(r, unsigned, f) + + default: + d.NaN = true + // TODO: + // case string: if produced by strconv, allows for easy arbitrary pos. + // case reflect.Value: + // case big.Float + // case big.Int + // case big.Rat? + // catch underlyings using reflect or will this already be done by the + // message package? + } +} + +// ConvertInt converts an integer to decimals. +func (d *Decimal) ConvertInt(r RoundingContext, signed bool, x uint64) { + if r.Increment > 0 { + // TODO: if uint64 is too large, fall back to float64 + if signed { + d.ConvertFloat(r, float64(int64(x)), 64) + } else { + d.ConvertFloat(r, float64(x), 64) + } + return + } + d.clear() + if signed && int64(x) < 0 { + x = uint64(-int64(x)) + d.Neg = true + } + d.fillIntDigits(x) + d.Exp = int32(len(d.Digits)) +} + +// ConvertFloat converts a floating point number to decimals. +func (d *Decimal) ConvertFloat(r RoundingContext, x float64, size int) { + d.clear() + if math.IsNaN(x) { + d.NaN = true + return + } + // Simple case: decimal notation + if r.Increment > 0 { + scale := int(r.IncrementScale) + mult := 1.0 + if scale >= len(scales) { + mult = math.Pow(10, float64(scale)) + } else { + mult = scales[scale] + } + // We multiply x instead of dividing inc as it gives less rounding + // issues. + x *= mult + x /= float64(r.Increment) + x = r.Mode.roundFloat(x) + x *= float64(r.Increment) + x /= mult + } + + abs := x + if x < 0 { + d.Neg = true + abs = -x + } + if math.IsInf(abs, 1) { + d.Inf = true + return + } + + // By default we get the exact decimal representation. + verb := byte('g') + prec := -1 + // As the strconv API does not return the rounding accuracy, we can only + // round using ToNearestEven. + if r.Mode == ToNearestEven { + if n := r.RoundSignificantDigits(); n >= 0 { + prec = n + } else if n = r.RoundFractionDigits(); n >= 0 { + prec = n + verb = 'f' + } + } else { + // TODO: At this point strconv's rounding is imprecise to the point that + // it is not usable for this purpose. + // See https://github.com/golang/go/issues/21714 + // If rounding is requested, we ask for a large number of digits and + // round from there to simulate rounding only once. + // Ideally we would have strconv export an AppendDigits that would take + // a rounding mode and/or return an accuracy. Something like this would + // work: + // AppendDigits(dst []byte, x float64, base, size, prec int) (digits []byte, exp, accuracy int) + hasPrec := r.RoundSignificantDigits() >= 0 + hasScale := r.RoundFractionDigits() >= 0 + if hasPrec || hasScale { + // prec is the number of mantissa bits plus some extra for safety. + // We need at least the number of mantissa bits as decimals to + // accurately represent the floating point without rounding, as each + // bit requires one more decimal to represent: 0.5, 0.25, 0.125, ... + prec = 60 + } + } + + b := strconv.AppendFloat(d.Digits[:0], abs, verb, prec, size) + i := 0 + k := 0 + beforeDot := 1 + for i < len(b) { + if c := b[i]; '0' <= c && c <= '9' { + b[k] = c - '0' + k++ + d.Exp += int32(beforeDot) + } else if c == '.' { + beforeDot = 0 + d.Exp = int32(k) + } else { + break + } + i++ + } + d.Digits = b[:k] + if i != len(b) { + i += len("e") + pSign := i + exp := 0 + for i++; i < len(b); i++ { + exp *= 10 + exp += int(b[i] - '0') + } + if b[pSign] == '-' { + exp = -exp + } + d.Exp = int32(exp) + 1 + } +} + +func (d *Decimal) fillIntDigits(x uint64) { + if cap(d.Digits) < maxIntDigits { + d.Digits = d.buf[:] + } else { + d.Digits = d.buf[:maxIntDigits] + } + i := 0 + for ; x > 0; x /= 10 { + d.Digits[i] = byte(x % 10) + i++ + } + d.Digits = d.Digits[:i] + for p := 0; p < i; p++ { + i-- + d.Digits[p], d.Digits[i] = d.Digits[i], d.Digits[p] + } +} + +var scales [70]float64 + +func init() { + x := 1.0 + for i := range scales { + scales[i] = x + x *= 10 + } +} diff --git a/vendor/golang.org/x/text/internal/number/format.go b/vendor/golang.org/x/text/internal/number/format.go new file mode 100644 index 00000000..1aadcf40 --- /dev/null +++ b/vendor/golang.org/x/text/internal/number/format.go @@ -0,0 +1,533 @@ +// Copyright 2017 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package number + +import ( + "strconv" + "unicode/utf8" + + "golang.org/x/text/language" +) + +// TODO: +// - grouping of fractions +// - allow user-defined superscript notation (such as 4) +// - same for non-breaking spaces, like   + +// A VisibleDigits computes digits, comma placement and trailing zeros as they +// will be shown to the user. +type VisibleDigits interface { + Digits(buf []byte, t language.Tag, scale int) Digits + // TODO: Do we also need to add the verb or pass a format.State? +} + +// Formatting proceeds along the following lines: +// 0) Compose rounding information from format and context. +// 1) Convert a number into a Decimal. +// 2) Sanitize Decimal by adding trailing zeros, removing leading digits, and +// (non-increment) rounding. The Decimal that results from this is suitable +// for determining the plural form. +// 3) Render the Decimal in the localized form. + +// Formatter contains all the information needed to render a number. +type Formatter struct { + Pattern + Info +} + +func (f *Formatter) init(t language.Tag, index []uint8) { + f.Info = InfoFromTag(t) + f.Pattern = formats[index[tagToID(t)]] +} + +// InitPattern initializes a Formatter for the given Pattern. +func (f *Formatter) InitPattern(t language.Tag, pat *Pattern) { + f.Info = InfoFromTag(t) + f.Pattern = *pat +} + +// InitDecimal initializes a Formatter using the default Pattern for the given +// language. +func (f *Formatter) InitDecimal(t language.Tag) { + f.init(t, tagToDecimal) +} + +// InitScientific initializes a Formatter using the default Pattern for the +// given language. +func (f *Formatter) InitScientific(t language.Tag) { + f.init(t, tagToScientific) + f.Pattern.MinFractionDigits = 0 + f.Pattern.MaxFractionDigits = -1 +} + +// InitEngineering initializes a Formatter using the default Pattern for the +// given language. +func (f *Formatter) InitEngineering(t language.Tag) { + f.init(t, tagToScientific) + f.Pattern.MinFractionDigits = 0 + f.Pattern.MaxFractionDigits = -1 + f.Pattern.MaxIntegerDigits = 3 + f.Pattern.MinIntegerDigits = 1 +} + +// InitPercent initializes a Formatter using the default Pattern for the given +// language. +func (f *Formatter) InitPercent(t language.Tag) { + f.init(t, tagToPercent) +} + +// InitPerMille initializes a Formatter using the default Pattern for the given +// language. +func (f *Formatter) InitPerMille(t language.Tag) { + f.init(t, tagToPercent) + f.Pattern.DigitShift = 3 +} + +func (f *Formatter) Append(dst []byte, x interface{}) []byte { + var d Decimal + r := f.RoundingContext + d.Convert(r, x) + return f.Render(dst, FormatDigits(&d, r)) +} + +func FormatDigits(d *Decimal, r RoundingContext) Digits { + if r.isScientific() { + return scientificVisibleDigits(r, d) + } + return decimalVisibleDigits(r, d) +} + +func (f *Formatter) Format(dst []byte, d *Decimal) []byte { + return f.Render(dst, FormatDigits(d, f.RoundingContext)) +} + +func (f *Formatter) Render(dst []byte, d Digits) []byte { + var result []byte + var postPrefix, preSuffix int + if d.IsScientific { + result, postPrefix, preSuffix = appendScientific(dst, f, &d) + } else { + result, postPrefix, preSuffix = appendDecimal(dst, f, &d) + } + if f.PadRune == 0 { + return result + } + width := int(f.FormatWidth) + if count := utf8.RuneCount(result); count < width { + insertPos := 0 + switch f.Flags & PadMask { + case PadAfterPrefix: + insertPos = postPrefix + case PadBeforeSuffix: + insertPos = preSuffix + case PadAfterSuffix: + insertPos = len(result) + } + num := width - count + pad := [utf8.UTFMax]byte{' '} + sz := 1 + if r := f.PadRune; r != 0 { + sz = utf8.EncodeRune(pad[:], r) + } + extra := sz * num + if n := len(result) + extra; n < cap(result) { + result = result[:n] + copy(result[insertPos+extra:], result[insertPos:]) + } else { + buf := make([]byte, n) + copy(buf, result[:insertPos]) + copy(buf[insertPos+extra:], result[insertPos:]) + result = buf + } + for ; num > 0; num-- { + insertPos += copy(result[insertPos:], pad[:sz]) + } + } + return result +} + +// decimalVisibleDigits converts d according to the RoundingContext. Note that +// the exponent may change as a result of this operation. +func decimalVisibleDigits(r RoundingContext, d *Decimal) Digits { + if d.NaN || d.Inf { + return Digits{digits: digits{Neg: d.Neg, NaN: d.NaN, Inf: d.Inf}} + } + n := Digits{digits: d.normalize().digits} + + exp := n.Exp + exp += int32(r.DigitShift) + + // Cap integer digits. Remove *most-significant* digits. + if r.MaxIntegerDigits > 0 { + if p := int(exp) - int(r.MaxIntegerDigits); p > 0 { + if p > len(n.Digits) { + p = len(n.Digits) + } + if n.Digits = n.Digits[p:]; len(n.Digits) == 0 { + exp = 0 + } else { + exp -= int32(p) + } + // Strip leading zeros. + for len(n.Digits) > 0 && n.Digits[0] == 0 { + n.Digits = n.Digits[1:] + exp-- + } + } + } + + // Rounding if not already done by Convert. + p := len(n.Digits) + if maxSig := int(r.MaxSignificantDigits); maxSig > 0 { + p = maxSig + } + if maxFrac := int(r.MaxFractionDigits); maxFrac >= 0 { + if cap := int(exp) + maxFrac; cap < p { + p = int(exp) + maxFrac + } + if p < 0 { + p = 0 + } + } + n.round(r.Mode, p) + + // set End (trailing zeros) + n.End = int32(len(n.Digits)) + if n.End == 0 { + exp = 0 + if r.MinFractionDigits > 0 { + n.End = int32(r.MinFractionDigits) + } + if p := int32(r.MinSignificantDigits) - 1; p > n.End { + n.End = p + } + } else { + if end := exp + int32(r.MinFractionDigits); end > n.End { + n.End = end + } + if n.End < int32(r.MinSignificantDigits) { + n.End = int32(r.MinSignificantDigits) + } + } + n.Exp = exp + return n +} + +// appendDecimal appends a formatted number to dst. It returns two possible +// insertion points for padding. +func appendDecimal(dst []byte, f *Formatter, n *Digits) (b []byte, postPre, preSuf int) { + if dst, ok := f.renderSpecial(dst, n); ok { + return dst, 0, len(dst) + } + digits := n.Digits + exp := n.Exp + + // Split in integer and fraction part. + var intDigits, fracDigits []byte + numInt := 0 + numFrac := int(n.End - n.Exp) + if exp > 0 { + numInt = int(exp) + if int(exp) >= len(digits) { // ddddd | ddddd00 + intDigits = digits + } else { // ddd.dd + intDigits = digits[:exp] + fracDigits = digits[exp:] + } + } else { + fracDigits = digits + } + + neg := n.Neg + affix, suffix := f.getAffixes(neg) + dst = appendAffix(dst, f, affix, neg) + savedLen := len(dst) + + minInt := int(f.MinIntegerDigits) + if minInt == 0 && f.MinSignificantDigits > 0 { + minInt = 1 + } + // add leading zeros + for i := minInt; i > numInt; i-- { + dst = f.AppendDigit(dst, 0) + if f.needsSep(i) { + dst = append(dst, f.Symbol(SymGroup)...) + } + } + i := 0 + for ; i < len(intDigits); i++ { + dst = f.AppendDigit(dst, intDigits[i]) + if f.needsSep(numInt - i) { + dst = append(dst, f.Symbol(SymGroup)...) + } + } + for ; i < numInt; i++ { + dst = f.AppendDigit(dst, 0) + if f.needsSep(numInt - i) { + dst = append(dst, f.Symbol(SymGroup)...) + } + } + + if numFrac > 0 || f.Flags&AlwaysDecimalSeparator != 0 { + dst = append(dst, f.Symbol(SymDecimal)...) + } + // Add trailing zeros + i = 0 + for n := -int(n.Exp); i < n; i++ { + dst = f.AppendDigit(dst, 0) + } + for _, d := range fracDigits { + i++ + dst = f.AppendDigit(dst, d) + } + for ; i < numFrac; i++ { + dst = f.AppendDigit(dst, 0) + } + return appendAffix(dst, f, suffix, neg), savedLen, len(dst) +} + +func scientificVisibleDigits(r RoundingContext, d *Decimal) Digits { + if d.NaN || d.Inf { + return Digits{digits: digits{Neg: d.Neg, NaN: d.NaN, Inf: d.Inf}} + } + n := Digits{digits: d.normalize().digits, IsScientific: true} + + // Normalize to have at least one digit. This simplifies engineering + // notation. + if len(n.Digits) == 0 { + n.Digits = append(n.Digits, 0) + n.Exp = 1 + } + + // Significant digits are transformed by the parser for scientific notation + // and do not need to be handled here. + maxInt, numInt := int(r.MaxIntegerDigits), int(r.MinIntegerDigits) + if numInt == 0 { + numInt = 1 + } + + // If a maximum number of integers is specified, the minimum must be 1 + // and the exponent is grouped by this number (e.g. for engineering) + if maxInt > numInt { + // Correct the exponent to reflect a single integer digit. + numInt = 1 + // engineering + // 0.01234 ([12345]e-1) -> 1.2345e-2 12.345e-3 + // 12345 ([12345]e+5) -> 1.2345e4 12.345e3 + d := int(n.Exp-1) % maxInt + if d < 0 { + d += maxInt + } + numInt += d + } + + p := len(n.Digits) + if maxSig := int(r.MaxSignificantDigits); maxSig > 0 { + p = maxSig + } + if maxFrac := int(r.MaxFractionDigits); maxFrac >= 0 && numInt+maxFrac < p { + p = numInt + maxFrac + } + n.round(r.Mode, p) + + n.Comma = uint8(numInt) + n.End = int32(len(n.Digits)) + if minSig := int32(r.MinFractionDigits) + int32(numInt); n.End < minSig { + n.End = minSig + } + return n +} + +// appendScientific appends a formatted number to dst. It returns two possible +// insertion points for padding. +func appendScientific(dst []byte, f *Formatter, n *Digits) (b []byte, postPre, preSuf int) { + if dst, ok := f.renderSpecial(dst, n); ok { + return dst, 0, 0 + } + digits := n.Digits + numInt := int(n.Comma) + numFrac := int(n.End) - int(n.Comma) + + var intDigits, fracDigits []byte + if numInt <= len(digits) { + intDigits = digits[:numInt] + fracDigits = digits[numInt:] + } else { + intDigits = digits + } + neg := n.Neg + affix, suffix := f.getAffixes(neg) + dst = appendAffix(dst, f, affix, neg) + savedLen := len(dst) + + i := 0 + for ; i < len(intDigits); i++ { + dst = f.AppendDigit(dst, intDigits[i]) + if f.needsSep(numInt - i) { + dst = append(dst, f.Symbol(SymGroup)...) + } + } + for ; i < numInt; i++ { + dst = f.AppendDigit(dst, 0) + if f.needsSep(numInt - i) { + dst = append(dst, f.Symbol(SymGroup)...) + } + } + + if numFrac > 0 || f.Flags&AlwaysDecimalSeparator != 0 { + dst = append(dst, f.Symbol(SymDecimal)...) + } + i = 0 + for ; i < len(fracDigits); i++ { + dst = f.AppendDigit(dst, fracDigits[i]) + } + for ; i < numFrac; i++ { + dst = f.AppendDigit(dst, 0) + } + + // exp + buf := [12]byte{} + // TODO: use exponential if superscripting is not available (no Latin + // numbers or no tags) and use exponential in all other cases. + exp := n.Exp - int32(n.Comma) + exponential := f.Symbol(SymExponential) + if exponential == "E" { + dst = append(dst, f.Symbol(SymSuperscriptingExponent)...) + dst = f.AppendDigit(dst, 1) + dst = f.AppendDigit(dst, 0) + switch { + case exp < 0: + dst = append(dst, superMinus...) + exp = -exp + case f.Flags&AlwaysExpSign != 0: + dst = append(dst, superPlus...) + } + b = strconv.AppendUint(buf[:0], uint64(exp), 10) + for i := len(b); i < int(f.MinExponentDigits); i++ { + dst = append(dst, superDigits[0]...) + } + for _, c := range b { + dst = append(dst, superDigits[c-'0']...) + } + } else { + dst = append(dst, exponential...) + switch { + case exp < 0: + dst = append(dst, f.Symbol(SymMinusSign)...) + exp = -exp + case f.Flags&AlwaysExpSign != 0: + dst = append(dst, f.Symbol(SymPlusSign)...) + } + b = strconv.AppendUint(buf[:0], uint64(exp), 10) + for i := len(b); i < int(f.MinExponentDigits); i++ { + dst = f.AppendDigit(dst, 0) + } + for _, c := range b { + dst = f.AppendDigit(dst, c-'0') + } + } + return appendAffix(dst, f, suffix, neg), savedLen, len(dst) +} + +const ( + superMinus = "\u207B" // SUPERSCRIPT HYPHEN-MINUS + superPlus = "\u207A" // SUPERSCRIPT PLUS SIGN +) + +var ( + // Note: the digits are not sequential!!! + superDigits = []string{ + "\u2070", // SUPERSCRIPT DIGIT ZERO + "\u00B9", // SUPERSCRIPT DIGIT ONE + "\u00B2", // SUPERSCRIPT DIGIT TWO + "\u00B3", // SUPERSCRIPT DIGIT THREE + "\u2074", // SUPERSCRIPT DIGIT FOUR + "\u2075", // SUPERSCRIPT DIGIT FIVE + "\u2076", // SUPERSCRIPT DIGIT SIX + "\u2077", // SUPERSCRIPT DIGIT SEVEN + "\u2078", // SUPERSCRIPT DIGIT EIGHT + "\u2079", // SUPERSCRIPT DIGIT NINE + } +) + +func (f *Formatter) getAffixes(neg bool) (affix, suffix string) { + str := f.Affix + if str != "" { + if f.NegOffset > 0 { + if neg { + str = str[f.NegOffset:] + } else { + str = str[:f.NegOffset] + } + } + sufStart := 1 + str[0] + affix = str[1:sufStart] + suffix = str[sufStart+1:] + } + // TODO: introduce a NeedNeg sign to indicate if the left pattern already + // has a sign marked? + if f.NegOffset == 0 && (neg || f.Flags&AlwaysSign != 0) { + affix = "-" + affix + } + return affix, suffix +} + +func (f *Formatter) renderSpecial(dst []byte, d *Digits) (b []byte, ok bool) { + if d.NaN { + return fmtNaN(dst, f), true + } + if d.Inf { + return fmtInfinite(dst, f, d), true + } + return dst, false +} + +func fmtNaN(dst []byte, f *Formatter) []byte { + return append(dst, f.Symbol(SymNan)...) +} + +func fmtInfinite(dst []byte, f *Formatter, d *Digits) []byte { + affix, suffix := f.getAffixes(d.Neg) + dst = appendAffix(dst, f, affix, d.Neg) + dst = append(dst, f.Symbol(SymInfinity)...) + dst = appendAffix(dst, f, suffix, d.Neg) + return dst +} + +func appendAffix(dst []byte, f *Formatter, affix string, neg bool) []byte { + quoting := false + escaping := false + for _, r := range affix { + switch { + case escaping: + // escaping occurs both inside and outside of quotes + dst = append(dst, string(r)...) + escaping = false + case r == '\\': + escaping = true + case r == '\'': + quoting = !quoting + case quoting: + dst = append(dst, string(r)...) + case r == '%': + if f.DigitShift == 3 { + dst = append(dst, f.Symbol(SymPerMille)...) + } else { + dst = append(dst, f.Symbol(SymPercentSign)...) + } + case r == '-' || r == '+': + if neg { + dst = append(dst, f.Symbol(SymMinusSign)...) + } else if f.Flags&ElideSign == 0 { + dst = append(dst, f.Symbol(SymPlusSign)...) + } else { + dst = append(dst, ' ') + } + default: + dst = append(dst, string(r)...) + } + } + return dst +} diff --git a/vendor/golang.org/x/text/internal/number/number.go b/vendor/golang.org/x/text/internal/number/number.go new file mode 100644 index 00000000..e1d933c3 --- /dev/null +++ b/vendor/golang.org/x/text/internal/number/number.go @@ -0,0 +1,152 @@ +// Copyright 2016 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +//go:generate go run gen.go gen_common.go + +// Package number contains tools and data for formatting numbers. +package number + +import ( + "unicode/utf8" + + "golang.org/x/text/internal/language/compact" + "golang.org/x/text/language" +) + +// Info holds number formatting configuration data. +type Info struct { + system systemData // numbering system information + symIndex symOffset // index to symbols +} + +// InfoFromLangID returns a Info for the given compact language identifier and +// numbering system identifier. If system is the empty string, the default +// numbering system will be taken for that language. +func InfoFromLangID(compactIndex compact.ID, numberSystem string) Info { + p := langToDefaults[compactIndex] + // Lookup the entry for the language. + pSymIndex := symOffset(0) // Default: Latin, default symbols + system, ok := systemMap[numberSystem] + if !ok { + // Take the value for the default numbering system. This is by far the + // most common case as an alternative numbering system is hardly used. + if p&hasNonLatnMask == 0 { // Latn digits. + pSymIndex = p + } else { // Non-Latn or multiple numbering systems. + // Take the first entry from the alternatives list. + data := langToAlt[p&^hasNonLatnMask] + pSymIndex = data.symIndex + system = data.system + } + } else { + langIndex := compactIndex + ns := system + outerLoop: + for ; ; p = langToDefaults[langIndex] { + if p&hasNonLatnMask == 0 { + if ns == 0 { + // The index directly points to the symbol data. + pSymIndex = p + break + } + // Move to the parent and retry. + langIndex = langIndex.Parent() + } else { + // The index points to a list of symbol data indexes. + for _, e := range langToAlt[p&^hasNonLatnMask:] { + if e.compactTag != langIndex { + if langIndex == 0 { + // The CLDR root defines full symbol information for + // all numbering systems (even though mostly by + // means of aliases). Fall back to the default entry + // for Latn if there is no data for the numbering + // system of this language. + if ns == 0 { + break + } + // Fall back to Latin and start from the original + // language. See + // https://unicode.org/reports/tr35/#Locale_Inheritance. + ns = numLatn + langIndex = compactIndex + continue outerLoop + } + // Fall back to parent. + langIndex = langIndex.Parent() + } else if e.system == ns { + pSymIndex = e.symIndex + break outerLoop + } + } + } + } + } + if int(system) >= len(numSysData) { // algorithmic + // Will generate ASCII digits in case the user inadvertently calls + // WriteDigit or Digit on it. + d := numSysData[0] + d.id = system + return Info{ + system: d, + symIndex: pSymIndex, + } + } + return Info{ + system: numSysData[system], + symIndex: pSymIndex, + } +} + +// InfoFromTag returns a Info for the given language tag. +func InfoFromTag(t language.Tag) Info { + return InfoFromLangID(tagToID(t), t.TypeForKey("nu")) +} + +// IsDecimal reports if the numbering system can convert decimal to native +// symbols one-to-one. +func (n Info) IsDecimal() bool { + return int(n.system.id) < len(numSysData) +} + +// WriteDigit writes the UTF-8 sequence for n corresponding to the given ASCII +// digit to dst and reports the number of bytes written. dst must be large +// enough to hold the rune (can be up to utf8.UTFMax bytes). +func (n Info) WriteDigit(dst []byte, asciiDigit rune) int { + copy(dst, n.system.zero[:n.system.digitSize]) + dst[n.system.digitSize-1] += byte(asciiDigit - '0') + return int(n.system.digitSize) +} + +// AppendDigit appends the UTF-8 sequence for n corresponding to the given digit +// to dst and reports the number of bytes written. dst must be large enough to +// hold the rune (can be up to utf8.UTFMax bytes). +func (n Info) AppendDigit(dst []byte, digit byte) []byte { + dst = append(dst, n.system.zero[:n.system.digitSize]...) + dst[len(dst)-1] += digit + return dst +} + +// Digit returns the digit for the numbering system for the corresponding ASCII +// value. For example, ni.Digit('3') could return '三'. Note that the argument +// is the rune constant '3', which equals 51, not the integer constant 3. +func (n Info) Digit(asciiDigit rune) rune { + var x [utf8.UTFMax]byte + n.WriteDigit(x[:], asciiDigit) + r, _ := utf8.DecodeRune(x[:]) + return r +} + +// Symbol returns the string for the given symbol type. +func (n Info) Symbol(t SymbolType) string { + return symData.Elem(int(symIndex[n.symIndex][t])) +} + +func formatForLang(t language.Tag, index []byte) *Pattern { + return &formats[index[tagToID(t)]] +} + +func tagToID(t language.Tag) compact.ID { + id, _ := compact.RegionalID(compact.Tag(t)) + return id +} diff --git a/vendor/golang.org/x/text/internal/number/pattern.go b/vendor/golang.org/x/text/internal/number/pattern.go new file mode 100644 index 00000000..06e59559 --- /dev/null +++ b/vendor/golang.org/x/text/internal/number/pattern.go @@ -0,0 +1,485 @@ +// Copyright 2015 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package number + +import ( + "errors" + "unicode/utf8" +) + +// This file contains a parser for the CLDR number patterns as described in +// https://unicode.org/reports/tr35/tr35-numbers.html#Number_Format_Patterns. +// +// The following BNF is derived from this standard. +// +// pattern := subpattern (';' subpattern)? +// subpattern := affix? number exponent? affix? +// number := decimal | sigDigits +// decimal := '#'* '0'* ('.' fraction)? | '#' | '0' +// fraction := '0'* '#'* +// sigDigits := '#'* '@' '@'* '#'* +// exponent := 'E' '+'? '0'* '0' +// padSpec := '*' \L +// +// Notes: +// - An affix pattern may contain any runes, but runes with special meaning +// should be escaped. +// - Sequences of digits, '#', and '@' in decimal and sigDigits may have +// interstitial commas. + +// TODO: replace special characters in affixes (-, +, ¤) with control codes. + +// Pattern holds information for formatting numbers. It is designed to hold +// information from CLDR number patterns. +// +// This pattern is precompiled for all patterns for all languages. Even though +// the number of patterns is not very large, we want to keep this small. +// +// This type is only intended for internal use. +type Pattern struct { + RoundingContext + + Affix string // includes prefix and suffix. First byte is prefix length. + Offset uint16 // Offset into Affix for prefix and suffix + NegOffset uint16 // Offset into Affix for negative prefix and suffix or 0. + PadRune rune + FormatWidth uint16 + + GroupingSize [2]uint8 + Flags PatternFlag +} + +// A RoundingContext indicates how a number should be converted to digits. +// It contains all information needed to determine the "visible digits" as +// required by the pluralization rules. +type RoundingContext struct { + // TODO: unify these two fields so that there is a more unambiguous meaning + // of how precision is handled. + MaxSignificantDigits int16 // -1 is unlimited + MaxFractionDigits int16 // -1 is unlimited + + Increment uint32 + IncrementScale uint8 // May differ from printed scale. + + Mode RoundingMode + + DigitShift uint8 // Number of decimals to shift. Used for % and ‰. + + // Number of digits. + MinIntegerDigits uint8 + + MaxIntegerDigits uint8 + MinFractionDigits uint8 + MinSignificantDigits uint8 + + MinExponentDigits uint8 +} + +// RoundSignificantDigits returns the number of significant digits an +// implementation of Convert may round to or n < 0 if there is no maximum or +// a maximum is not recommended. +func (r *RoundingContext) RoundSignificantDigits() (n int) { + if r.MaxFractionDigits == 0 && r.MaxSignificantDigits > 0 { + return int(r.MaxSignificantDigits) + } else if r.isScientific() && r.MaxIntegerDigits == 1 { + if r.MaxSignificantDigits == 0 || + int(r.MaxFractionDigits+1) == int(r.MaxSignificantDigits) { + // Note: don't add DigitShift: it is only used for decimals. + return int(r.MaxFractionDigits) + 1 + } + } + return -1 +} + +// RoundFractionDigits returns the number of fraction digits an implementation +// of Convert may round to or n < 0 if there is no maximum or a maximum is not +// recommended. +func (r *RoundingContext) RoundFractionDigits() (n int) { + if r.MinExponentDigits == 0 && + r.MaxSignificantDigits == 0 && + r.MaxFractionDigits >= 0 { + return int(r.MaxFractionDigits) + int(r.DigitShift) + } + return -1 +} + +// SetScale fixes the RoundingContext to a fixed number of fraction digits. +func (r *RoundingContext) SetScale(scale int) { + r.MinFractionDigits = uint8(scale) + r.MaxFractionDigits = int16(scale) +} + +func (r *RoundingContext) SetPrecision(prec int) { + r.MaxSignificantDigits = int16(prec) +} + +func (r *RoundingContext) isScientific() bool { + return r.MinExponentDigits > 0 +} + +func (f *Pattern) needsSep(pos int) bool { + p := pos - 1 + size := int(f.GroupingSize[0]) + if size == 0 || p == 0 { + return false + } + if p == size { + return true + } + if p -= size; p < 0 { + return false + } + // TODO: make second groupingsize the same as first if 0 so that we can + // avoid this check. + if x := int(f.GroupingSize[1]); x != 0 { + size = x + } + return p%size == 0 +} + +// A PatternFlag is a bit mask for the flag field of a Pattern. +type PatternFlag uint8 + +const ( + AlwaysSign PatternFlag = 1 << iota + ElideSign // Use space instead of plus sign. AlwaysSign must be true. + AlwaysExpSign + AlwaysDecimalSeparator + ParenthesisForNegative // Common pattern. Saves space. + + PadAfterNumber + PadAfterAffix + + PadBeforePrefix = 0 // Default + PadAfterPrefix = PadAfterAffix + PadBeforeSuffix = PadAfterNumber + PadAfterSuffix = PadAfterNumber | PadAfterAffix + PadMask = PadAfterNumber | PadAfterAffix +) + +type parser struct { + *Pattern + + leadingSharps int + + pos int + err error + doNotTerminate bool + groupingCount uint + hasGroup bool + buf []byte +} + +func (p *parser) setError(err error) { + if p.err == nil { + p.err = err + } +} + +func (p *parser) updateGrouping() { + if p.hasGroup && + 0 < p.groupingCount && p.groupingCount < 255 { + p.GroupingSize[1] = p.GroupingSize[0] + p.GroupingSize[0] = uint8(p.groupingCount) + } + p.groupingCount = 0 + p.hasGroup = true +} + +var ( + // TODO: more sensible and localizeable error messages. + errMultiplePadSpecifiers = errors.New("format: pattern has multiple pad specifiers") + errInvalidPadSpecifier = errors.New("format: invalid pad specifier") + errInvalidQuote = errors.New("format: invalid quote") + errAffixTooLarge = errors.New("format: prefix or suffix exceeds maximum UTF-8 length of 256 bytes") + errDuplicatePercentSign = errors.New("format: duplicate percent sign") + errDuplicatePermilleSign = errors.New("format: duplicate permille sign") + errUnexpectedEnd = errors.New("format: unexpected end of pattern") +) + +// ParsePattern extracts formatting information from a CLDR number pattern. +// +// See https://unicode.org/reports/tr35/tr35-numbers.html#Number_Format_Patterns. +func ParsePattern(s string) (f *Pattern, err error) { + p := parser{Pattern: &Pattern{}} + + s = p.parseSubPattern(s) + + if s != "" { + // Parse negative sub pattern. + if s[0] != ';' { + p.setError(errors.New("format: error parsing first sub pattern")) + return nil, p.err + } + neg := parser{Pattern: &Pattern{}} // just for extracting the affixes. + s = neg.parseSubPattern(s[len(";"):]) + p.NegOffset = uint16(len(p.buf)) + p.buf = append(p.buf, neg.buf...) + } + if s != "" { + p.setError(errors.New("format: spurious characters at end of pattern")) + } + if p.err != nil { + return nil, p.err + } + if affix := string(p.buf); affix == "\x00\x00" || affix == "\x00\x00\x00\x00" { + // No prefix or suffixes. + p.NegOffset = 0 + } else { + p.Affix = affix + } + if p.Increment == 0 { + p.IncrementScale = 0 + } + return p.Pattern, nil +} + +func (p *parser) parseSubPattern(s string) string { + s = p.parsePad(s, PadBeforePrefix) + s = p.parseAffix(s) + s = p.parsePad(s, PadAfterPrefix) + + s = p.parse(p.number, s) + p.updateGrouping() + + s = p.parsePad(s, PadBeforeSuffix) + s = p.parseAffix(s) + s = p.parsePad(s, PadAfterSuffix) + return s +} + +func (p *parser) parsePad(s string, f PatternFlag) (tail string) { + if len(s) >= 2 && s[0] == '*' { + r, sz := utf8.DecodeRuneInString(s[1:]) + if p.PadRune != 0 { + p.err = errMultiplePadSpecifiers + } else { + p.Flags |= f + p.PadRune = r + } + return s[1+sz:] + } + return s +} + +func (p *parser) parseAffix(s string) string { + x := len(p.buf) + p.buf = append(p.buf, 0) // placeholder for affix length + + s = p.parse(p.affix, s) + + n := len(p.buf) - x - 1 + if n > 0xFF { + p.setError(errAffixTooLarge) + } + p.buf[x] = uint8(n) + return s +} + +// state implements a state transition. It returns the new state. A state +// function may set an error on the parser or may simply return on an incorrect +// token and let the next phase fail. +type state func(r rune) state + +// parse repeatedly applies a state function on the given string until a +// termination condition is reached. +func (p *parser) parse(fn state, s string) (tail string) { + for i, r := range s { + p.doNotTerminate = false + if fn = fn(r); fn == nil || p.err != nil { + return s[i:] + } + p.FormatWidth++ + } + if p.doNotTerminate { + p.setError(errUnexpectedEnd) + } + return "" +} + +func (p *parser) affix(r rune) state { + switch r { + case '0', '1', '2', '3', '4', '5', '6', '7', '8', '9', + '#', '@', '.', '*', ',', ';': + return nil + case '\'': + p.FormatWidth-- + return p.escapeFirst + case '%': + if p.DigitShift != 0 { + p.setError(errDuplicatePercentSign) + } + p.DigitShift = 2 + case '\u2030': // ‰ Per mille + if p.DigitShift != 0 { + p.setError(errDuplicatePermilleSign) + } + p.DigitShift = 3 + // TODO: handle currency somehow: ¤, ¤¤, ¤¤¤, ¤¤¤¤ + } + p.buf = append(p.buf, string(r)...) + return p.affix +} + +func (p *parser) escapeFirst(r rune) state { + switch r { + case '\'': + p.buf = append(p.buf, "\\'"...) + return p.affix + default: + p.buf = append(p.buf, '\'') + p.buf = append(p.buf, string(r)...) + } + return p.escape +} + +func (p *parser) escape(r rune) state { + switch r { + case '\'': + p.FormatWidth-- + p.buf = append(p.buf, '\'') + return p.affix + default: + p.buf = append(p.buf, string(r)...) + } + return p.escape +} + +// number parses a number. The BNF says the integer part should always have +// a '0', but that does not appear to be the case according to the rest of the +// documentation. We will allow having only '#' numbers. +func (p *parser) number(r rune) state { + switch r { + case '#': + p.groupingCount++ + p.leadingSharps++ + case '@': + p.groupingCount++ + p.leadingSharps = 0 + p.MaxFractionDigits = -1 + return p.sigDigits(r) + case ',': + if p.leadingSharps == 0 { // no leading commas + return nil + } + p.updateGrouping() + case 'E': + p.MaxIntegerDigits = uint8(p.leadingSharps) + return p.exponent + case '.': // allow ".##" etc. + p.updateGrouping() + return p.fraction + case '0', '1', '2', '3', '4', '5', '6', '7', '8', '9': + return p.integer(r) + default: + return nil + } + return p.number +} + +func (p *parser) integer(r rune) state { + if !('0' <= r && r <= '9') { + var next state + switch r { + case 'E': + if p.leadingSharps > 0 { + p.MaxIntegerDigits = uint8(p.leadingSharps) + p.MinIntegerDigits + } + next = p.exponent + case '.': + next = p.fraction + case ',': + next = p.integer + } + p.updateGrouping() + return next + } + p.Increment = p.Increment*10 + uint32(r-'0') + p.groupingCount++ + p.MinIntegerDigits++ + return p.integer +} + +func (p *parser) sigDigits(r rune) state { + switch r { + case '@': + p.groupingCount++ + p.MaxSignificantDigits++ + p.MinSignificantDigits++ + case '#': + return p.sigDigitsFinal(r) + case 'E': + p.updateGrouping() + return p.normalizeSigDigitsWithExponent() + default: + p.updateGrouping() + return nil + } + return p.sigDigits +} + +func (p *parser) sigDigitsFinal(r rune) state { + switch r { + case '#': + p.groupingCount++ + p.MaxSignificantDigits++ + case 'E': + p.updateGrouping() + return p.normalizeSigDigitsWithExponent() + default: + p.updateGrouping() + return nil + } + return p.sigDigitsFinal +} + +func (p *parser) normalizeSigDigitsWithExponent() state { + p.MinIntegerDigits, p.MaxIntegerDigits = 1, 1 + p.MinFractionDigits = p.MinSignificantDigits - 1 + p.MaxFractionDigits = p.MaxSignificantDigits - 1 + p.MinSignificantDigits, p.MaxSignificantDigits = 0, 0 + return p.exponent +} + +func (p *parser) fraction(r rune) state { + switch r { + case '0', '1', '2', '3', '4', '5', '6', '7', '8', '9': + p.Increment = p.Increment*10 + uint32(r-'0') + p.IncrementScale++ + p.MinFractionDigits++ + p.MaxFractionDigits++ + case '#': + p.MaxFractionDigits++ + case 'E': + if p.leadingSharps > 0 { + p.MaxIntegerDigits = uint8(p.leadingSharps) + p.MinIntegerDigits + } + return p.exponent + default: + return nil + } + return p.fraction +} + +func (p *parser) exponent(r rune) state { + switch r { + case '+': + // Set mode and check it wasn't already set. + if p.Flags&AlwaysExpSign != 0 || p.MinExponentDigits > 0 { + break + } + p.Flags |= AlwaysExpSign + p.doNotTerminate = true + return p.exponent + case '0': + p.MinExponentDigits++ + return p.exponent + } + // termination condition + if p.MinExponentDigits == 0 { + p.setError(errors.New("format: need at least one digit")) + } + return nil +} diff --git a/vendor/golang.org/x/text/internal/number/roundingmode_string.go b/vendor/golang.org/x/text/internal/number/roundingmode_string.go new file mode 100644 index 00000000..bcc22471 --- /dev/null +++ b/vendor/golang.org/x/text/internal/number/roundingmode_string.go @@ -0,0 +1,30 @@ +// Code generated by "stringer -type RoundingMode"; DO NOT EDIT. + +package number + +import "strconv" + +func _() { + // An "invalid array index" compiler error signifies that the constant values have changed. + // Re-run the stringer command to generate them again. + var x [1]struct{} + _ = x[ToNearestEven-0] + _ = x[ToNearestZero-1] + _ = x[ToNearestAway-2] + _ = x[ToPositiveInf-3] + _ = x[ToNegativeInf-4] + _ = x[ToZero-5] + _ = x[AwayFromZero-6] + _ = x[numModes-7] +} + +const _RoundingMode_name = "ToNearestEvenToNearestZeroToNearestAwayToPositiveInfToNegativeInfToZeroAwayFromZeronumModes" + +var _RoundingMode_index = [...]uint8{0, 13, 26, 39, 52, 65, 71, 83, 91} + +func (i RoundingMode) String() string { + if i >= RoundingMode(len(_RoundingMode_index)-1) { + return "RoundingMode(" + strconv.FormatInt(int64(i), 10) + ")" + } + return _RoundingMode_name[_RoundingMode_index[i]:_RoundingMode_index[i+1]] +} diff --git a/vendor/golang.org/x/text/internal/number/tables.go b/vendor/golang.org/x/text/internal/number/tables.go new file mode 100644 index 00000000..8efce81b --- /dev/null +++ b/vendor/golang.org/x/text/internal/number/tables.go @@ -0,0 +1,1219 @@ +// Code generated by running "go generate" in golang.org/x/text. DO NOT EDIT. + +package number + +import "golang.org/x/text/internal/stringset" + +// CLDRVersion is the CLDR version from which the tables in this package are derived. +const CLDRVersion = "32" + +var numSysData = []systemData{ // 59 elements + 0: {id: 0x0, digitSize: 0x1, zero: [4]uint8{0x30, 0x0, 0x0, 0x0}}, + 1: {id: 0x1, digitSize: 0x4, zero: [4]uint8{0xf0, 0x9e, 0xa5, 0x90}}, + 2: {id: 0x2, digitSize: 0x4, zero: [4]uint8{0xf0, 0x91, 0x9c, 0xb0}}, + 3: {id: 0x3, digitSize: 0x2, zero: [4]uint8{0xd9, 0xa0, 0x0, 0x0}}, + 4: {id: 0x4, digitSize: 0x2, zero: [4]uint8{0xdb, 0xb0, 0x0, 0x0}}, + 5: {id: 0x5, digitSize: 0x3, zero: [4]uint8{0xe1, 0xad, 0x90, 0x0}}, + 6: {id: 0x6, digitSize: 0x3, zero: [4]uint8{0xe0, 0xa7, 0xa6, 0x0}}, + 7: {id: 0x7, digitSize: 0x4, zero: [4]uint8{0xf0, 0x91, 0xb1, 0x90}}, + 8: {id: 0x8, digitSize: 0x4, zero: [4]uint8{0xf0, 0x91, 0x81, 0xa6}}, + 9: {id: 0x9, digitSize: 0x4, zero: [4]uint8{0xf0, 0x91, 0x84, 0xb6}}, + 10: {id: 0xa, digitSize: 0x3, zero: [4]uint8{0xea, 0xa9, 0x90, 0x0}}, + 11: {id: 0xb, digitSize: 0x3, zero: [4]uint8{0xe0, 0xa5, 0xa6, 0x0}}, + 12: {id: 0xc, digitSize: 0x3, zero: [4]uint8{0xef, 0xbc, 0x90, 0x0}}, + 13: {id: 0xd, digitSize: 0x4, zero: [4]uint8{0xf0, 0x91, 0xb5, 0x90}}, + 14: {id: 0xe, digitSize: 0x3, zero: [4]uint8{0xe0, 0xab, 0xa6, 0x0}}, + 15: {id: 0xf, digitSize: 0x3, zero: [4]uint8{0xe0, 0xa9, 0xa6, 0x0}}, + 16: {id: 0x10, digitSize: 0x4, zero: [4]uint8{0xf0, 0x96, 0xad, 0x90}}, + 17: {id: 0x11, digitSize: 0x3, zero: [4]uint8{0xea, 0xa7, 0x90, 0x0}}, + 18: {id: 0x12, digitSize: 0x3, zero: [4]uint8{0xea, 0xa4, 0x80, 0x0}}, + 19: {id: 0x13, digitSize: 0x3, zero: [4]uint8{0xe1, 0x9f, 0xa0, 0x0}}, + 20: {id: 0x14, digitSize: 0x3, zero: [4]uint8{0xe0, 0xb3, 0xa6, 0x0}}, + 21: {id: 0x15, digitSize: 0x3, zero: [4]uint8{0xe1, 0xaa, 0x80, 0x0}}, + 22: {id: 0x16, digitSize: 0x3, zero: [4]uint8{0xe1, 0xaa, 0x90, 0x0}}, + 23: {id: 0x17, digitSize: 0x3, zero: [4]uint8{0xe0, 0xbb, 0x90, 0x0}}, + 24: {id: 0x18, digitSize: 0x3, zero: [4]uint8{0xe1, 0xb1, 0x80, 0x0}}, + 25: {id: 0x19, digitSize: 0x3, zero: [4]uint8{0xe1, 0xa5, 0x86, 0x0}}, + 26: {id: 0x1a, digitSize: 0x4, zero: [4]uint8{0xf0, 0x9d, 0x9f, 0x8e}}, + 27: {id: 0x1b, digitSize: 0x4, zero: [4]uint8{0xf0, 0x9d, 0x9f, 0x98}}, + 28: {id: 0x1c, digitSize: 0x4, zero: [4]uint8{0xf0, 0x9d, 0x9f, 0xb6}}, + 29: {id: 0x1d, digitSize: 0x4, zero: [4]uint8{0xf0, 0x9d, 0x9f, 0xac}}, + 30: {id: 0x1e, digitSize: 0x4, zero: [4]uint8{0xf0, 0x9d, 0x9f, 0xa2}}, + 31: {id: 0x1f, digitSize: 0x3, zero: [4]uint8{0xe0, 0xb5, 0xa6, 0x0}}, + 32: {id: 0x20, digitSize: 0x4, zero: [4]uint8{0xf0, 0x91, 0x99, 0x90}}, + 33: {id: 0x21, digitSize: 0x3, zero: [4]uint8{0xe1, 0xa0, 0x90, 0x0}}, + 34: {id: 0x22, digitSize: 0x4, zero: [4]uint8{0xf0, 0x96, 0xa9, 0xa0}}, + 35: {id: 0x23, digitSize: 0x3, zero: [4]uint8{0xea, 0xaf, 0xb0, 0x0}}, + 36: {id: 0x24, digitSize: 0x3, zero: [4]uint8{0xe1, 0x81, 0x80, 0x0}}, + 37: {id: 0x25, digitSize: 0x3, zero: [4]uint8{0xe1, 0x82, 0x90, 0x0}}, + 38: {id: 0x26, digitSize: 0x3, zero: [4]uint8{0xea, 0xa7, 0xb0, 0x0}}, + 39: {id: 0x27, digitSize: 0x4, zero: [4]uint8{0xf0, 0x91, 0x91, 0x90}}, + 40: {id: 0x28, digitSize: 0x2, zero: [4]uint8{0xdf, 0x80, 0x0, 0x0}}, + 41: {id: 0x29, digitSize: 0x3, zero: [4]uint8{0xe1, 0xb1, 0x90, 0x0}}, + 42: {id: 0x2a, digitSize: 0x3, zero: [4]uint8{0xe0, 0xad, 0xa6, 0x0}}, + 43: {id: 0x2b, digitSize: 0x4, zero: [4]uint8{0xf0, 0x90, 0x92, 0xa0}}, + 44: {id: 0x2c, digitSize: 0x3, zero: [4]uint8{0xea, 0xa3, 0x90, 0x0}}, + 45: {id: 0x2d, digitSize: 0x4, zero: [4]uint8{0xf0, 0x91, 0x87, 0x90}}, + 46: {id: 0x2e, digitSize: 0x4, zero: [4]uint8{0xf0, 0x91, 0x8b, 0xb0}}, + 47: {id: 0x2f, digitSize: 0x3, zero: [4]uint8{0xe0, 0xb7, 0xa6, 0x0}}, + 48: {id: 0x30, digitSize: 0x4, zero: [4]uint8{0xf0, 0x91, 0x83, 0xb0}}, + 49: {id: 0x31, digitSize: 0x3, zero: [4]uint8{0xe1, 0xae, 0xb0, 0x0}}, + 50: {id: 0x32, digitSize: 0x4, zero: [4]uint8{0xf0, 0x91, 0x9b, 0x80}}, + 51: {id: 0x33, digitSize: 0x3, zero: [4]uint8{0xe1, 0xa7, 0x90, 0x0}}, + 52: {id: 0x34, digitSize: 0x3, zero: [4]uint8{0xe0, 0xaf, 0xa6, 0x0}}, + 53: {id: 0x35, digitSize: 0x3, zero: [4]uint8{0xe0, 0xb1, 0xa6, 0x0}}, + 54: {id: 0x36, digitSize: 0x3, zero: [4]uint8{0xe0, 0xb9, 0x90, 0x0}}, + 55: {id: 0x37, digitSize: 0x3, zero: [4]uint8{0xe0, 0xbc, 0xa0, 0x0}}, + 56: {id: 0x38, digitSize: 0x4, zero: [4]uint8{0xf0, 0x91, 0x93, 0x90}}, + 57: {id: 0x39, digitSize: 0x3, zero: [4]uint8{0xea, 0x98, 0xa0, 0x0}}, + 58: {id: 0x3a, digitSize: 0x4, zero: [4]uint8{0xf0, 0x91, 0xa3, 0xa0}}, +} // Size: 378 bytes + +const ( + numAdlm = 0x1 + numAhom = 0x2 + numArab = 0x3 + numArabext = 0x4 + numArmn = 0x3b + numArmnlow = 0x3c + numBali = 0x5 + numBeng = 0x6 + numBhks = 0x7 + numBrah = 0x8 + numCakm = 0x9 + numCham = 0xa + numCyrl = 0x3d + numDeva = 0xb + numEthi = 0x3e + numFullwide = 0xc + numGeor = 0x3f + numGonm = 0xd + numGrek = 0x40 + numGreklow = 0x41 + numGujr = 0xe + numGuru = 0xf + numHanidays = 0x42 + numHanidec = 0x43 + numHans = 0x44 + numHansfin = 0x45 + numHant = 0x46 + numHantfin = 0x47 + numHebr = 0x48 + numHmng = 0x10 + numJava = 0x11 + numJpan = 0x49 + numJpanfin = 0x4a + numKali = 0x12 + numKhmr = 0x13 + numKnda = 0x14 + numLana = 0x15 + numLanatham = 0x16 + numLaoo = 0x17 + numLatn = 0x0 + numLepc = 0x18 + numLimb = 0x19 + numMathbold = 0x1a + numMathdbl = 0x1b + numMathmono = 0x1c + numMathsanb = 0x1d + numMathsans = 0x1e + numMlym = 0x1f + numModi = 0x20 + numMong = 0x21 + numMroo = 0x22 + numMtei = 0x23 + numMymr = 0x24 + numMymrshan = 0x25 + numMymrtlng = 0x26 + numNewa = 0x27 + numNkoo = 0x28 + numOlck = 0x29 + numOrya = 0x2a + numOsma = 0x2b + numRoman = 0x4b + numRomanlow = 0x4c + numSaur = 0x2c + numShrd = 0x2d + numSind = 0x2e + numSinh = 0x2f + numSora = 0x30 + numSund = 0x31 + numTakr = 0x32 + numTalu = 0x33 + numTaml = 0x4d + numTamldec = 0x34 + numTelu = 0x35 + numThai = 0x36 + numTibt = 0x37 + numTirh = 0x38 + numVaii = 0x39 + numWara = 0x3a + numNumberSystems +) + +var systemMap = map[string]system{ + "adlm": numAdlm, + "ahom": numAhom, + "arab": numArab, + "arabext": numArabext, + "armn": numArmn, + "armnlow": numArmnlow, + "bali": numBali, + "beng": numBeng, + "bhks": numBhks, + "brah": numBrah, + "cakm": numCakm, + "cham": numCham, + "cyrl": numCyrl, + "deva": numDeva, + "ethi": numEthi, + "fullwide": numFullwide, + "geor": numGeor, + "gonm": numGonm, + "grek": numGrek, + "greklow": numGreklow, + "gujr": numGujr, + "guru": numGuru, + "hanidays": numHanidays, + "hanidec": numHanidec, + "hans": numHans, + "hansfin": numHansfin, + "hant": numHant, + "hantfin": numHantfin, + "hebr": numHebr, + "hmng": numHmng, + "java": numJava, + "jpan": numJpan, + "jpanfin": numJpanfin, + "kali": numKali, + "khmr": numKhmr, + "knda": numKnda, + "lana": numLana, + "lanatham": numLanatham, + "laoo": numLaoo, + "latn": numLatn, + "lepc": numLepc, + "limb": numLimb, + "mathbold": numMathbold, + "mathdbl": numMathdbl, + "mathmono": numMathmono, + "mathsanb": numMathsanb, + "mathsans": numMathsans, + "mlym": numMlym, + "modi": numModi, + "mong": numMong, + "mroo": numMroo, + "mtei": numMtei, + "mymr": numMymr, + "mymrshan": numMymrshan, + "mymrtlng": numMymrtlng, + "newa": numNewa, + "nkoo": numNkoo, + "olck": numOlck, + "orya": numOrya, + "osma": numOsma, + "roman": numRoman, + "romanlow": numRomanlow, + "saur": numSaur, + "shrd": numShrd, + "sind": numSind, + "sinh": numSinh, + "sora": numSora, + "sund": numSund, + "takr": numTakr, + "talu": numTalu, + "taml": numTaml, + "tamldec": numTamldec, + "telu": numTelu, + "thai": numThai, + "tibt": numTibt, + "tirh": numTirh, + "vaii": numVaii, + "wara": numWara, +} + +var symIndex = [][12]uint8{ // 81 elements + 0: [12]uint8{0x0, 0x1, 0x2, 0x3, 0x4, 0x5, 0x6, 0x7, 0x8, 0x9, 0xa, 0xb}, + 1: [12]uint8{0x1, 0xc, 0x2, 0x3, 0x4, 0x5, 0x6, 0x7, 0x8, 0x9, 0xa, 0xb}, + 2: [12]uint8{0x0, 0x1, 0x2, 0xd, 0xe, 0xf, 0x6, 0x7, 0x8, 0x9, 0x10, 0xb}, + 3: [12]uint8{0x1, 0x0, 0x2, 0xd, 0xe, 0xf, 0x6, 0x7, 0x8, 0x9, 0x10, 0xb}, + 4: [12]uint8{0x0, 0x1, 0x2, 0x11, 0xe, 0xf, 0x6, 0x7, 0x8, 0x9, 0x10, 0xb}, + 5: [12]uint8{0x1, 0x0, 0x2, 0x3, 0x4, 0x5, 0x6, 0x7, 0x8, 0x9, 0x12, 0xb}, + 6: [12]uint8{0x1, 0x0, 0x2, 0x3, 0x4, 0x5, 0x6, 0x7, 0x8, 0x9, 0xa, 0xb}, + 7: [12]uint8{0x0, 0x1, 0x2, 0x3, 0x4, 0x5, 0x6, 0x7, 0x8, 0x9, 0x13, 0xb}, + 8: [12]uint8{0x0, 0x1, 0x2, 0x3, 0xe, 0x5, 0x6, 0x7, 0x8, 0x9, 0xa, 0xb}, + 9: [12]uint8{0x1, 0x0, 0x2, 0x3, 0x4, 0x5, 0x6, 0x7, 0x8, 0x9, 0xa, 0x0}, + 10: [12]uint8{0x1, 0x0, 0x2, 0x3, 0x4, 0x5, 0x6, 0x14, 0x8, 0x9, 0xa, 0xb}, + 11: [12]uint8{0x1, 0xc, 0x2, 0x3, 0x4, 0x5, 0x6, 0x14, 0x8, 0x9, 0xa, 0xb}, + 12: [12]uint8{0x0, 0x15, 0x2, 0x3, 0x4, 0x5, 0x6, 0x14, 0x8, 0x9, 0xa, 0xb}, + 13: [12]uint8{0x0, 0xc, 0x2, 0x3, 0x4, 0x5, 0x6, 0x7, 0x8, 0x9, 0xa, 0xb}, + 14: [12]uint8{0x0, 0x1, 0x2, 0x3, 0x4, 0x5, 0x6, 0x7, 0x8, 0x9, 0x16, 0xb}, + 15: [12]uint8{0x1, 0x0, 0x2, 0x3, 0x4, 0x5, 0x17, 0x7, 0x8, 0x9, 0xa, 0xb}, + 16: [12]uint8{0x0, 0x1, 0x2, 0x3, 0x4, 0x5, 0x17, 0x7, 0x8, 0x9, 0xa, 0x0}, + 17: [12]uint8{0x0, 0x1, 0x2, 0x3, 0x4, 0x5, 0x17, 0x7, 0x8, 0x9, 0xa, 0xb}, + 18: [12]uint8{0x1, 0xc, 0x2, 0x3, 0x4, 0x5, 0x6, 0x7, 0x8, 0x9, 0xa, 0x0}, + 19: [12]uint8{0x1, 0xc, 0x2, 0x3, 0x4, 0x5, 0x18, 0x7, 0x8, 0x9, 0xa, 0xb}, + 20: [12]uint8{0x0, 0x1, 0x2, 0x3, 0x4, 0x5, 0x6, 0x7, 0x19, 0x1a, 0xa, 0xb}, + 21: [12]uint8{0x1, 0xc, 0x2, 0x3, 0x4, 0x1b, 0x6, 0x7, 0x8, 0x9, 0xa, 0xb}, + 22: [12]uint8{0x1, 0xc, 0x2, 0x3, 0x4, 0x1b, 0x18, 0x7, 0x8, 0x9, 0xa, 0xb}, + 23: [12]uint8{0x1, 0x0, 0x2, 0x3, 0x4, 0x1b, 0x6, 0x7, 0x8, 0x9, 0xa, 0xb}, + 24: [12]uint8{0x0, 0x1, 0x2, 0x3, 0xe, 0x1c, 0x6, 0x7, 0x8, 0x9, 0x1d, 0xb}, + 25: [12]uint8{0x1, 0xc, 0x2, 0x3, 0x4, 0x1b, 0x6, 0x7, 0x8, 0x9, 0x1e, 0x0}, + 26: [12]uint8{0x0, 0x15, 0x2, 0x3, 0x4, 0x1b, 0x6, 0x7, 0x8, 0x9, 0xa, 0xb}, + 27: [12]uint8{0x0, 0x1, 0x2, 0x3, 0xe, 0xf, 0x6, 0x7, 0x8, 0x9, 0xa, 0xb}, + 28: [12]uint8{0x1, 0xc, 0x2, 0x3, 0x4, 0x5, 0x6, 0x7, 0x8, 0x9, 0x1f, 0xb}, + 29: [12]uint8{0x0, 0x15, 0x2, 0x3, 0x4, 0x5, 0x6, 0x7, 0x8, 0x9, 0xa, 0xb}, + 30: [12]uint8{0x1, 0xc, 0x2, 0x3, 0x4, 0x5, 0x6, 0x7, 0x8, 0x9, 0x20, 0xb}, + 31: [12]uint8{0x1, 0xc, 0x2, 0x3, 0x4, 0x5, 0x21, 0x7, 0x8, 0x9, 0x22, 0xb}, + 32: [12]uint8{0x1, 0xc, 0x2, 0x3, 0x4, 0x5, 0x6, 0x7, 0x8, 0x9, 0x23, 0xb}, + 33: [12]uint8{0x1, 0x0, 0x2, 0x3, 0x4, 0x1b, 0x18, 0x14, 0x8, 0x9, 0x24, 0xb}, + 34: [12]uint8{0x1, 0xc, 0x2, 0x3, 0x4, 0x1b, 0x18, 0x7, 0x8, 0x9, 0x24, 0xb}, + 35: [12]uint8{0x1, 0xc, 0x2, 0x3, 0x4, 0x5, 0x6, 0x7, 0x8, 0x9, 0x25, 0xb}, + 36: [12]uint8{0x1, 0x0, 0x2, 0x3, 0x4, 0x5, 0x6, 0x7, 0x8, 0x9, 0x26, 0xb}, + 37: [12]uint8{0x1, 0xc, 0x2, 0x3, 0x4, 0x5, 0x6, 0x7, 0x8, 0x9, 0x27, 0xb}, + 38: [12]uint8{0x0, 0x1, 0x2, 0x3, 0x4, 0x5, 0x6, 0x7, 0x8, 0x9, 0x28, 0xb}, + 39: [12]uint8{0x1, 0xc, 0x2, 0x3, 0x4, 0x5, 0x6, 0x7, 0x8, 0x9, 0x29, 0xb}, + 40: [12]uint8{0x1, 0x0, 0x2, 0x3, 0xe, 0x1c, 0x6, 0x7, 0x8, 0x9, 0xa, 0xb}, + 41: [12]uint8{0x1, 0xc, 0x2, 0x3, 0x4, 0x5, 0x6, 0x7, 0x8, 0x9, 0x2a, 0xb}, + 42: [12]uint8{0x1, 0xc, 0x2, 0x3, 0x4, 0x5, 0x6, 0x7, 0x8, 0x9, 0x2b, 0xb}, + 43: [12]uint8{0x1, 0xc, 0x2, 0x3, 0x4, 0x1b, 0x2c, 0x14, 0x8, 0x9, 0x24, 0xb}, + 44: [12]uint8{0x0, 0x1, 0x2, 0x3, 0x4, 0x5, 0x6, 0x7, 0x8, 0x9, 0xa, 0x0}, + 45: [12]uint8{0x1, 0xc, 0x2, 0x3, 0x4, 0x5, 0x17, 0x7, 0x8, 0x9, 0xa, 0xb}, + 46: [12]uint8{0x1, 0x0, 0x2, 0x3, 0x4, 0x1b, 0x17, 0x7, 0x8, 0x9, 0xa, 0xb}, + 47: [12]uint8{0x1, 0xc, 0x2, 0x3, 0x4, 0x5, 0x6, 0x7, 0x8, 0x9, 0x2d, 0x0}, + 48: [12]uint8{0x1, 0xc, 0x2, 0x3, 0x4, 0x5, 0x6, 0x7, 0x8, 0x9, 0x2e, 0xb}, + 49: [12]uint8{0x0, 0x1, 0x2, 0x3, 0x4, 0x5, 0x6, 0x7, 0x8, 0x9, 0x2f, 0xb}, + 50: [12]uint8{0x1, 0xc, 0x2, 0x3, 0x4, 0x5, 0x30, 0x7, 0x8, 0x9, 0xa, 0xb}, + 51: [12]uint8{0x1, 0xc, 0x2, 0x3, 0x4, 0x5, 0x6, 0x7, 0x8, 0x9, 0x31, 0xb}, + 52: [12]uint8{0x1, 0xc, 0x2, 0x3, 0x4, 0x5, 0x6, 0x7, 0x8, 0x9, 0x32, 0xb}, + 53: [12]uint8{0x1, 0x15, 0x2, 0x3, 0x4, 0x5, 0x6, 0x7, 0x8, 0x9, 0xa, 0xb}, + 54: [12]uint8{0x0, 0x1, 0x2, 0x3, 0x4, 0x5, 0x6, 0x7, 0x8, 0x9, 0x33, 0xb}, + 55: [12]uint8{0x0, 0x1, 0x2, 0x3, 0x4, 0x5, 0x6, 0x7, 0x8, 0x9, 0x34, 0xb}, + 56: [12]uint8{0x35, 0x36, 0x37, 0x38, 0x39, 0x3a, 0x3b, 0x7, 0x3c, 0x9, 0xa, 0xb}, + 57: [12]uint8{0x35, 0x36, 0x37, 0x38, 0x39, 0x3a, 0x3b, 0x7, 0x3c, 0x9, 0x3d, 0xb}, + 58: [12]uint8{0x35, 0x36, 0x37, 0x11, 0x3e, 0x3f, 0x3b, 0x7, 0x3c, 0x9, 0xa, 0xb}, + 59: [12]uint8{0x35, 0x36, 0x37, 0x11, 0x39, 0x3a, 0x3b, 0x7, 0x3c, 0x9, 0xa, 0xb}, + 60: [12]uint8{0x35, 0x36, 0x37, 0x11, 0x39, 0x40, 0x3b, 0x7, 0x3c, 0x9, 0xa, 0xb}, + 61: [12]uint8{0x35, 0x36, 0x37, 0x41, 0x3e, 0x3f, 0x3b, 0x7, 0x3c, 0x9, 0xa, 0xb}, + 62: [12]uint8{0x35, 0x36, 0x37, 0x38, 0x3e, 0x3f, 0x3b, 0x7, 0x3c, 0x9, 0xa, 0xb}, + 63: [12]uint8{0x35, 0xc, 0x37, 0x38, 0x39, 0x42, 0x3b, 0x7, 0x3c, 0x9, 0xa, 0x0}, + 64: [12]uint8{0x35, 0xc, 0x37, 0x38, 0x39, 0x42, 0x43, 0x7, 0x44, 0x9, 0x24, 0xb}, + 65: [12]uint8{0x35, 0x36, 0x37, 0x38, 0x39, 0x5, 0x3b, 0x7, 0x3c, 0x9, 0x33, 0xb}, + 66: [12]uint8{0x35, 0x36, 0x37, 0x11, 0x45, 0x46, 0x43, 0x7, 0x3c, 0x9, 0xa, 0x35}, + 67: [12]uint8{0x35, 0x36, 0x37, 0x11, 0xe, 0x1c, 0x43, 0x7, 0x3c, 0x9, 0x1d, 0xb}, + 68: [12]uint8{0x35, 0x36, 0x37, 0x11, 0xe, 0x1c, 0x43, 0x7, 0x3c, 0x9, 0xa, 0x35}, + 69: [12]uint8{0x35, 0x36, 0x37, 0x11, 0x45, 0x5, 0x43, 0x7, 0x3c, 0x9, 0xa, 0x35}, + 70: [12]uint8{0x1, 0xc, 0x37, 0x11, 0x45, 0x47, 0x43, 0x7, 0x3c, 0x9, 0xa, 0x0}, + 71: [12]uint8{0x35, 0x1, 0x37, 0x11, 0x4, 0x5, 0x43, 0x7, 0x3c, 0x9, 0xa, 0x35}, + 72: [12]uint8{0x1, 0xc, 0x37, 0x11, 0x45, 0x47, 0x43, 0x7, 0x3c, 0x9, 0x24, 0xb}, + 73: [12]uint8{0x35, 0x36, 0x2, 0x3, 0x45, 0x46, 0x43, 0x7, 0x8, 0x9, 0xa, 0x35}, + 74: [12]uint8{0x35, 0x36, 0x37, 0x11, 0x4, 0x5, 0x43, 0x7, 0x3c, 0x9, 0x31, 0x35}, + 75: [12]uint8{0x35, 0x36, 0x37, 0x11, 0x4, 0x5, 0x43, 0x7, 0x3c, 0x9, 0x32, 0x35}, + 76: [12]uint8{0x35, 0x36, 0x37, 0x11, 0x48, 0x46, 0x43, 0x7, 0x3c, 0x9, 0x33, 0x35}, + 77: [12]uint8{0x0, 0x1, 0x2, 0x3, 0x4, 0x5, 0x6, 0x7, 0x8, 0x9, 0xa, 0x49}, + 78: [12]uint8{0x0, 0x1, 0x4a, 0x3, 0x4, 0x5, 0x6, 0x7, 0x8, 0x9, 0x28, 0xb}, + 79: [12]uint8{0x0, 0x1, 0x2, 0x3, 0x4, 0x5, 0x6, 0x7, 0x8, 0x9, 0x4b, 0xb}, + 80: [12]uint8{0x0, 0x1, 0x2, 0x3, 0x4, 0x5, 0x6, 0x7, 0x8, 0x4c, 0x4d, 0xb}, +} // Size: 996 bytes + +var symData = stringset.Set{ + Data: "" + // Size: 599 bytes + ".,;%+-E׉∞NaN:\u00a0\u200e%\u200e\u200e+\u200e-ليس\u00a0رقمًا٪NDТерхьаш" + + "\u00a0дац·’mnne×10^0/00INF−\u200e−ناعددepälukuՈչԹარ\u00a0არის\u00a0რიცხვ" + + "იZMdMсан\u00a0емес¤¤¤сан\u00a0эмесບໍ່\u200bແມ່ນ\u200bໂຕ\u200bເລກNSဂဏန်" + + "းမဟုတ်သောННне\u00a0числочыыһыла\u00a0буотах·10^epilohosan\u00a0dälTFЕs" + + "on\u00a0emasҳақиқий\u00a0сон\u00a0эмас非數值非数值٫٬؛٪\u061c\u061c+\u061c-اس؉ل" + + "يس\u00a0رقم\u200f+\u200f-\u200f−٪\u200f\u061c−×۱۰^؉\u200f\u200e+\u200e" + + "\u200e-\u200e\u200e−\u200e+\u200e:၊ཨང་མེན་གྲངས་མེདཨང་མད", + Index: []uint16{ // 79 elements + // Entry 0 - 3F + 0x0000, 0x0001, 0x0002, 0x0003, 0x0004, 0x0005, 0x0006, 0x0007, + 0x0009, 0x000c, 0x000f, 0x0012, 0x0013, 0x0015, 0x001c, 0x0020, + 0x0024, 0x0036, 0x0038, 0x003a, 0x0050, 0x0052, 0x0055, 0x0058, + 0x0059, 0x005e, 0x0062, 0x0065, 0x0068, 0x006e, 0x0078, 0x0080, + 0x0086, 0x00ae, 0x00af, 0x00b2, 0x00c2, 0x00c8, 0x00d8, 0x0105, + 0x0107, 0x012e, 0x0132, 0x0142, 0x015e, 0x0163, 0x016a, 0x0173, + 0x0175, 0x0177, 0x0180, 0x01a0, 0x01a9, 0x01b2, 0x01b4, 0x01b6, + 0x01b8, 0x01bc, 0x01bf, 0x01c2, 0x01c6, 0x01c8, 0x01d6, 0x01da, + // Entry 40 - 7F + 0x01de, 0x01e4, 0x01e9, 0x01ee, 0x01f5, 0x01fa, 0x0201, 0x0208, + 0x0211, 0x0215, 0x0218, 0x021b, 0x0230, 0x0248, 0x0257, + }, +} // Size: 797 bytes + +// langToDefaults maps a compact language index to the default numbering system +// and default symbol set +var langToDefaults = [775]symOffset{ + // Entry 0 - 3F + 0x8000, 0x0001, 0x0001, 0x0001, 0x0001, 0x0001, 0x0000, 0x0000, + 0x0000, 0x0000, 0x8003, 0x0002, 0x0002, 0x0002, 0x0002, 0x0003, + 0x0002, 0x0002, 0x0002, 0x0002, 0x0002, 0x0002, 0x0002, 0x0002, + 0x0003, 0x0003, 0x0003, 0x0003, 0x0002, 0x0002, 0x0002, 0x0004, + 0x0002, 0x0004, 0x0002, 0x0002, 0x0002, 0x0003, 0x0002, 0x0000, + 0x8005, 0x0000, 0x0000, 0x0000, 0x8006, 0x0005, 0x0006, 0x0006, + 0x0006, 0x0006, 0x0006, 0x0001, 0x0001, 0x0001, 0x0001, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0001, 0x0001, 0x0000, 0x0000, 0x0000, + // Entry 40 - 7F + 0x8009, 0x0000, 0x0000, 0x800a, 0x0000, 0x0000, 0x800c, 0x0001, + 0x0000, 0x0000, 0x0006, 0x0006, 0x0006, 0x0006, 0x0006, 0x0006, + 0x0006, 0x0006, 0x0006, 0x0006, 0x800e, 0x0000, 0x0000, 0x0007, + 0x0007, 0x0000, 0x0000, 0x0000, 0x0000, 0x800f, 0x0008, 0x0008, + 0x8011, 0x0001, 0x0001, 0x0001, 0x803c, 0x0000, 0x0009, 0x0009, + 0x0009, 0x0000, 0x0000, 0x000a, 0x000b, 0x000a, 0x000c, 0x000a, + 0x000a, 0x000c, 0x000a, 0x000d, 0x000d, 0x000a, 0x000a, 0x0001, + 0x0001, 0x0000, 0x0001, 0x0001, 0x803f, 0x0000, 0x0000, 0x0000, + // Entry 80 - BF + 0x000e, 0x000e, 0x000e, 0x000f, 0x000f, 0x000f, 0x0000, 0x0000, + 0x0006, 0x0000, 0x0000, 0x0000, 0x000a, 0x0010, 0x0000, 0x0006, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0011, 0x0000, 0x000a, + 0x0000, 0x0000, 0x0000, 0x0000, 0x000a, 0x0000, 0x0009, 0x0000, + 0x0000, 0x0012, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + // Entry C0 - FF + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0006, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0013, 0x0000, + 0x0000, 0x000f, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0001, 0x0000, 0x0000, 0x0015, + 0x0015, 0x0006, 0x0000, 0x0006, 0x0006, 0x0000, 0x0000, 0x0006, + 0x0006, 0x0001, 0x0000, 0x0000, 0x0006, 0x0006, 0x0006, 0x0006, + // Entry 100 - 13F + 0x0000, 0x0000, 0x0006, 0x0000, 0x0000, 0x0000, 0x0000, 0x0006, + 0x0000, 0x0006, 0x0000, 0x0000, 0x0006, 0x0006, 0x0016, 0x0016, + 0x0017, 0x0017, 0x0001, 0x0001, 0x8041, 0x0018, 0x0018, 0x0001, + 0x0001, 0x0001, 0x0001, 0x0001, 0x0019, 0x0019, 0x0000, 0x0000, + 0x0017, 0x0017, 0x0017, 0x8044, 0x0001, 0x0001, 0x0001, 0x0001, + 0x0001, 0x0001, 0x0001, 0x0001, 0x0001, 0x0001, 0x0001, 0x0001, + 0x0001, 0x0001, 0x0001, 0x0001, 0x0001, 0x0001, 0x0001, 0x0001, + 0x0001, 0x0001, 0x0006, 0x0006, 0x0001, 0x0001, 0x0001, 0x0001, + // Entry 140 - 17F + 0x0001, 0x0001, 0x0001, 0x0001, 0x0001, 0x0001, 0x0001, 0x0001, + 0x0001, 0x0001, 0x0001, 0x0001, 0x0001, 0x0001, 0x0001, 0x0001, + 0x0001, 0x0001, 0x0006, 0x0006, 0x0006, 0x0006, 0x0000, 0x0000, + 0x8047, 0x0000, 0x0006, 0x0006, 0x001a, 0x001a, 0x001a, 0x001a, + 0x804a, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x804c, 0x001b, 0x0000, + 0x0000, 0x0006, 0x0006, 0x0006, 0x000a, 0x000a, 0x0001, 0x0001, + 0x001c, 0x001c, 0x0009, 0x0009, 0x804f, 0x0000, 0x0000, 0x0000, + // Entry 180 - 1BF + 0x0000, 0x0000, 0x8052, 0x0006, 0x0006, 0x001d, 0x0006, 0x0006, + 0x0006, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0006, 0x0006, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x001e, 0x001e, 0x001f, + 0x001f, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0001, + 0x0001, 0x000d, 0x000d, 0x0000, 0x0000, 0x0020, 0x0020, 0x0006, + 0x0006, 0x0021, 0x0021, 0x0000, 0x0000, 0x0006, 0x0006, 0x0000, + 0x0000, 0x8054, 0x0000, 0x0000, 0x0000, 0x0000, 0x8056, 0x001b, + 0x0000, 0x0000, 0x0001, 0x0001, 0x0022, 0x0022, 0x0000, 0x0000, + // Entry 1C0 - 1FF + 0x0000, 0x0023, 0x0023, 0x0000, 0x0000, 0x0006, 0x0006, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0006, 0x0006, 0x0006, 0x0006, 0x0006, + 0x0024, 0x0024, 0x8058, 0x0000, 0x0000, 0x0016, 0x0016, 0x0006, + 0x0006, 0x0000, 0x0000, 0x0000, 0x0000, 0x0025, 0x0025, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x000d, 0x000d, 0x0000, 0x0000, + 0x0006, 0x0006, 0x0000, 0x0000, 0x0006, 0x0006, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x805a, 0x0000, 0x0000, 0x0006, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0006, 0x0006, 0x805b, 0x0026, 0x805d, + // Entry 200 - 23F + 0x0000, 0x0000, 0x0000, 0x0000, 0x805e, 0x0015, 0x0015, 0x0000, + 0x0000, 0x0006, 0x0006, 0x0006, 0x8061, 0x0000, 0x0000, 0x8062, + 0x0006, 0x0006, 0x0006, 0x0006, 0x0006, 0x0006, 0x0006, 0x0001, + 0x0001, 0x0015, 0x0015, 0x0006, 0x0006, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0027, 0x0027, 0x0027, 0x8065, 0x8067, + 0x001b, 0x0000, 0x0000, 0x0000, 0x0001, 0x0001, 0x0001, 0x0001, + 0x8069, 0x0028, 0x0006, 0x0001, 0x0006, 0x0001, 0x0001, 0x0001, + // Entry 240 - 27F + 0x0001, 0x0001, 0x0001, 0x0001, 0x0001, 0x0001, 0x0001, 0x0000, + 0x0006, 0x0000, 0x0000, 0x001a, 0x001a, 0x0006, 0x0006, 0x0006, + 0x0006, 0x0006, 0x0000, 0x0000, 0x0029, 0x0029, 0x0029, 0x0029, + 0x0029, 0x0029, 0x0029, 0x0006, 0x0006, 0x0000, 0x0000, 0x002a, + 0x002a, 0x0000, 0x0000, 0x0000, 0x0000, 0x806b, 0x0000, 0x0000, + 0x002b, 0x002b, 0x002b, 0x002b, 0x0006, 0x0006, 0x000d, 0x000d, + 0x0006, 0x0006, 0x0000, 0x0001, 0x0001, 0x0001, 0x0001, 0x0001, + 0x002c, 0x002c, 0x002d, 0x002d, 0x002e, 0x002e, 0x0000, 0x0000, + // Entry 280 - 2BF + 0x0000, 0x002f, 0x002f, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0001, 0x0001, 0x0001, 0x0001, 0x0006, + 0x0006, 0x0006, 0x0006, 0x0006, 0x0006, 0x0006, 0x0006, 0x0006, + 0x0006, 0x0006, 0x0000, 0x0000, 0x0000, 0x806d, 0x0022, 0x0022, + 0x0022, 0x0000, 0x0006, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0001, 0x0001, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0030, 0x0030, 0x0000, 0x0000, 0x8071, 0x0031, 0x0006, + // Entry 2C0 - 2FF + 0x0006, 0x0006, 0x0000, 0x0001, 0x0001, 0x000d, 0x000d, 0x0001, + 0x0001, 0x0000, 0x0000, 0x0032, 0x0032, 0x8074, 0x8076, 0x001b, + 0x8077, 0x8079, 0x0028, 0x807b, 0x0034, 0x0033, 0x0033, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0006, 0x0006, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0035, 0x0035, 0x0006, 0x0006, + 0x0000, 0x0000, 0x0000, 0x0001, 0x0001, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0036, 0x0037, 0x0037, 0x0036, 0x0036, 0x0001, + 0x0001, 0x807d, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x8080, + // Entry 300 - 33F + 0x0036, 0x0036, 0x0036, 0x0000, 0x0000, 0x0006, 0x0014, +} // Size: 1550 bytes + +// langToAlt is a list of numbering system and symbol set pairs, sorted and +// marked by compact language index. +var langToAlt = []altSymData{ // 131 elements + 1: {compactTag: 0x0, symIndex: 0x38, system: 0x3}, + 2: {compactTag: 0x0, symIndex: 0x42, system: 0x4}, + 3: {compactTag: 0xa, symIndex: 0x39, system: 0x3}, + 4: {compactTag: 0xa, symIndex: 0x2, system: 0x0}, + 5: {compactTag: 0x28, symIndex: 0x0, system: 0x6}, + 6: {compactTag: 0x2c, symIndex: 0x5, system: 0x0}, + 7: {compactTag: 0x2c, symIndex: 0x3a, system: 0x3}, + 8: {compactTag: 0x2c, symIndex: 0x42, system: 0x4}, + 9: {compactTag: 0x40, symIndex: 0x0, system: 0x6}, + 10: {compactTag: 0x43, symIndex: 0x0, system: 0x0}, + 11: {compactTag: 0x43, symIndex: 0x4f, system: 0x37}, + 12: {compactTag: 0x46, symIndex: 0x1, system: 0x0}, + 13: {compactTag: 0x46, symIndex: 0x38, system: 0x3}, + 14: {compactTag: 0x54, symIndex: 0x0, system: 0x9}, + 15: {compactTag: 0x5d, symIndex: 0x3a, system: 0x3}, + 16: {compactTag: 0x5d, symIndex: 0x8, system: 0x0}, + 17: {compactTag: 0x60, symIndex: 0x1, system: 0x0}, + 18: {compactTag: 0x60, symIndex: 0x38, system: 0x3}, + 19: {compactTag: 0x60, symIndex: 0x42, system: 0x4}, + 20: {compactTag: 0x60, symIndex: 0x0, system: 0x5}, + 21: {compactTag: 0x60, symIndex: 0x0, system: 0x6}, + 22: {compactTag: 0x60, symIndex: 0x0, system: 0x8}, + 23: {compactTag: 0x60, symIndex: 0x0, system: 0x9}, + 24: {compactTag: 0x60, symIndex: 0x0, system: 0xa}, + 25: {compactTag: 0x60, symIndex: 0x0, system: 0xb}, + 26: {compactTag: 0x60, symIndex: 0x0, system: 0xc}, + 27: {compactTag: 0x60, symIndex: 0x0, system: 0xd}, + 28: {compactTag: 0x60, symIndex: 0x0, system: 0xe}, + 29: {compactTag: 0x60, symIndex: 0x0, system: 0xf}, + 30: {compactTag: 0x60, symIndex: 0x0, system: 0x11}, + 31: {compactTag: 0x60, symIndex: 0x0, system: 0x12}, + 32: {compactTag: 0x60, symIndex: 0x0, system: 0x13}, + 33: {compactTag: 0x60, symIndex: 0x0, system: 0x14}, + 34: {compactTag: 0x60, symIndex: 0x0, system: 0x15}, + 35: {compactTag: 0x60, symIndex: 0x0, system: 0x16}, + 36: {compactTag: 0x60, symIndex: 0x0, system: 0x17}, + 37: {compactTag: 0x60, symIndex: 0x0, system: 0x18}, + 38: {compactTag: 0x60, symIndex: 0x0, system: 0x19}, + 39: {compactTag: 0x60, symIndex: 0x0, system: 0x1f}, + 40: {compactTag: 0x60, symIndex: 0x0, system: 0x21}, + 41: {compactTag: 0x60, symIndex: 0x0, system: 0x23}, + 42: {compactTag: 0x60, symIndex: 0x0, system: 0x24}, + 43: {compactTag: 0x60, symIndex: 0x0, system: 0x25}, + 44: {compactTag: 0x60, symIndex: 0x0, system: 0x28}, + 45: {compactTag: 0x60, symIndex: 0x0, system: 0x29}, + 46: {compactTag: 0x60, symIndex: 0x0, system: 0x2a}, + 47: {compactTag: 0x60, symIndex: 0x0, system: 0x2b}, + 48: {compactTag: 0x60, symIndex: 0x0, system: 0x2c}, + 49: {compactTag: 0x60, symIndex: 0x0, system: 0x2d}, + 50: {compactTag: 0x60, symIndex: 0x0, system: 0x30}, + 51: {compactTag: 0x60, symIndex: 0x0, system: 0x31}, + 52: {compactTag: 0x60, symIndex: 0x0, system: 0x32}, + 53: {compactTag: 0x60, symIndex: 0x0, system: 0x33}, + 54: {compactTag: 0x60, symIndex: 0x0, system: 0x34}, + 55: {compactTag: 0x60, symIndex: 0x0, system: 0x35}, + 56: {compactTag: 0x60, symIndex: 0x0, system: 0x36}, + 57: {compactTag: 0x60, symIndex: 0x0, system: 0x37}, + 58: {compactTag: 0x60, symIndex: 0x0, system: 0x39}, + 59: {compactTag: 0x60, symIndex: 0x0, system: 0x43}, + 60: {compactTag: 0x64, symIndex: 0x0, system: 0x0}, + 61: {compactTag: 0x64, symIndex: 0x38, system: 0x3}, + 62: {compactTag: 0x64, symIndex: 0x42, system: 0x4}, + 63: {compactTag: 0x7c, symIndex: 0x50, system: 0x37}, + 64: {compactTag: 0x7c, symIndex: 0x0, system: 0x0}, + 65: {compactTag: 0x114, symIndex: 0x43, system: 0x4}, + 66: {compactTag: 0x114, symIndex: 0x18, system: 0x0}, + 67: {compactTag: 0x114, symIndex: 0x3b, system: 0x3}, + 68: {compactTag: 0x123, symIndex: 0x1, system: 0x0}, + 69: {compactTag: 0x123, symIndex: 0x3c, system: 0x3}, + 70: {compactTag: 0x123, symIndex: 0x44, system: 0x4}, + 71: {compactTag: 0x158, symIndex: 0x0, system: 0x0}, + 72: {compactTag: 0x158, symIndex: 0x3b, system: 0x3}, + 73: {compactTag: 0x158, symIndex: 0x45, system: 0x4}, + 74: {compactTag: 0x160, symIndex: 0x0, system: 0x0}, + 75: {compactTag: 0x160, symIndex: 0x38, system: 0x3}, + 76: {compactTag: 0x16d, symIndex: 0x1b, system: 0x0}, + 77: {compactTag: 0x16d, symIndex: 0x0, system: 0x9}, + 78: {compactTag: 0x16d, symIndex: 0x0, system: 0xa}, + 79: {compactTag: 0x17c, symIndex: 0x0, system: 0x0}, + 80: {compactTag: 0x17c, symIndex: 0x3d, system: 0x3}, + 81: {compactTag: 0x17c, symIndex: 0x42, system: 0x4}, + 82: {compactTag: 0x182, symIndex: 0x6, system: 0x0}, + 83: {compactTag: 0x182, symIndex: 0x38, system: 0x3}, + 84: {compactTag: 0x1b1, symIndex: 0x0, system: 0x0}, + 85: {compactTag: 0x1b1, symIndex: 0x3e, system: 0x3}, + 86: {compactTag: 0x1b6, symIndex: 0x42, system: 0x4}, + 87: {compactTag: 0x1b6, symIndex: 0x1b, system: 0x0}, + 88: {compactTag: 0x1d2, symIndex: 0x42, system: 0x4}, + 89: {compactTag: 0x1d2, symIndex: 0x0, system: 0x0}, + 90: {compactTag: 0x1f3, symIndex: 0x0, system: 0xb}, + 91: {compactTag: 0x1fd, symIndex: 0x4e, system: 0x24}, + 92: {compactTag: 0x1fd, symIndex: 0x26, system: 0x0}, + 93: {compactTag: 0x1ff, symIndex: 0x42, system: 0x4}, + 94: {compactTag: 0x204, symIndex: 0x15, system: 0x0}, + 95: {compactTag: 0x204, symIndex: 0x3f, system: 0x3}, + 96: {compactTag: 0x204, symIndex: 0x46, system: 0x4}, + 97: {compactTag: 0x20c, symIndex: 0x0, system: 0xb}, + 98: {compactTag: 0x20f, symIndex: 0x6, system: 0x0}, + 99: {compactTag: 0x20f, symIndex: 0x38, system: 0x3}, + 100: {compactTag: 0x20f, symIndex: 0x42, system: 0x4}, + 101: {compactTag: 0x22e, symIndex: 0x0, system: 0x0}, + 102: {compactTag: 0x22e, symIndex: 0x47, system: 0x4}, + 103: {compactTag: 0x22f, symIndex: 0x42, system: 0x4}, + 104: {compactTag: 0x22f, symIndex: 0x1b, system: 0x0}, + 105: {compactTag: 0x238, symIndex: 0x42, system: 0x4}, + 106: {compactTag: 0x238, symIndex: 0x28, system: 0x0}, + 107: {compactTag: 0x265, symIndex: 0x38, system: 0x3}, + 108: {compactTag: 0x265, symIndex: 0x0, system: 0x0}, + 109: {compactTag: 0x29d, symIndex: 0x22, system: 0x0}, + 110: {compactTag: 0x29d, symIndex: 0x40, system: 0x3}, + 111: {compactTag: 0x29d, symIndex: 0x48, system: 0x4}, + 112: {compactTag: 0x29d, symIndex: 0x4d, system: 0xc}, + 113: {compactTag: 0x2bd, symIndex: 0x31, system: 0x0}, + 114: {compactTag: 0x2bd, symIndex: 0x3e, system: 0x3}, + 115: {compactTag: 0x2bd, symIndex: 0x42, system: 0x4}, + 116: {compactTag: 0x2cd, symIndex: 0x1b, system: 0x0}, + 117: {compactTag: 0x2cd, symIndex: 0x49, system: 0x4}, + 118: {compactTag: 0x2ce, symIndex: 0x49, system: 0x4}, + 119: {compactTag: 0x2d0, symIndex: 0x33, system: 0x0}, + 120: {compactTag: 0x2d0, symIndex: 0x4a, system: 0x4}, + 121: {compactTag: 0x2d1, symIndex: 0x42, system: 0x4}, + 122: {compactTag: 0x2d1, symIndex: 0x28, system: 0x0}, + 123: {compactTag: 0x2d3, symIndex: 0x34, system: 0x0}, + 124: {compactTag: 0x2d3, symIndex: 0x4b, system: 0x4}, + 125: {compactTag: 0x2f9, symIndex: 0x0, system: 0x0}, + 126: {compactTag: 0x2f9, symIndex: 0x38, system: 0x3}, + 127: {compactTag: 0x2f9, symIndex: 0x42, system: 0x4}, + 128: {compactTag: 0x2ff, symIndex: 0x36, system: 0x0}, + 129: {compactTag: 0x2ff, symIndex: 0x41, system: 0x3}, + 130: {compactTag: 0x2ff, symIndex: 0x4c, system: 0x4}, +} // Size: 810 bytes + +var tagToDecimal = []uint8{ // 775 elements + // Entry 0 - 3F + 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, + 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, + 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, + 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, + 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, + 0x05, 0x05, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, + 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, + 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, + // Entry 40 - 7F + 0x05, 0x05, 0x05, 0x01, 0x01, 0x01, 0x01, 0x01, + 0x05, 0x05, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, + 0x01, 0x01, 0x01, 0x01, 0x05, 0x05, 0x05, 0x01, + 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, + 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, + 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, + 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, + 0x01, 0x01, 0x01, 0x01, 0x05, 0x05, 0x01, 0x01, + // Entry 80 - BF + 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, + 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, + 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, + 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, + 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, + 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, + 0x01, 0x05, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, + 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, + // Entry C0 - FF + 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, + 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, + 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, + 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, + 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, + 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, + 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, + 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, + // Entry 100 - 13F + 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, + 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, + 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, + 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, + 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, + 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, + 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, + 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, + // Entry 140 - 17F + 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, + 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, + 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, + 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, + 0x05, 0x05, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, + 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x05, + 0x05, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, + 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, + // Entry 180 - 1BF + 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, + 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, + 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, + 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, + 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, + 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, + 0x01, 0x01, 0x01, 0x01, 0x05, 0x05, 0x05, 0x05, + 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, + // Entry 1C0 - 1FF + 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, + 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, + 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, + 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, + 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, + 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x05, 0x05, + 0x01, 0x01, 0x01, 0x05, 0x05, 0x01, 0x01, 0x01, + 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, + // Entry 200 - 23F + 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, + 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, + 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, + 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, + 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, + 0x01, 0x05, 0x05, 0x01, 0x01, 0x01, 0x05, 0x01, + 0x01, 0x05, 0x05, 0x01, 0x01, 0x01, 0x01, 0x01, + 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, + // Entry 240 - 27F + 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, + 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, + 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, + 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, + 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, + 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, + 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, + 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, + // Entry 280 - 2BF + 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, + 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, + 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, + 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, + 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x05, + 0x05, 0x05, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, + 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, + 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, + // Entry 2C0 - 2FF + 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, + 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, + 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, + 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, + 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, + 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, + 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, + 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, + // Entry 300 - 33F + 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x08, +} // Size: 799 bytes + +var tagToScientific = []uint8{ // 775 elements + // Entry 0 - 3F + 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, + 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, + 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, + 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, + 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, + 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, + 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, + 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, + // Entry 40 - 7F + 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, + 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, + 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, + 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, + 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, + 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, + 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, + 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, + // Entry 80 - BF + 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, + 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, + 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, + 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, + 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, + 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, + 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, + 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, + // Entry C0 - FF + 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, + 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, + 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, + 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, + 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, + 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, + 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, + 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, + // Entry 100 - 13F + 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, + 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, + 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, + 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, + 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, + 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, + 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, + 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, + // Entry 140 - 17F + 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, + 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, + 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, + 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, + 0x0c, 0x0c, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, + 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x0c, + 0x0c, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, + 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, + // Entry 180 - 1BF + 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, + 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, + 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, + 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, + 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, + 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, + 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, + 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, + // Entry 1C0 - 1FF + 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, + 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, + 0x0d, 0x0d, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, + 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, + 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, + 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, + 0x02, 0x02, 0x02, 0x0c, 0x0c, 0x02, 0x02, 0x02, + 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, + // Entry 200 - 23F + 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, + 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, + 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, + 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, + 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, + 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x0c, 0x02, + 0x02, 0x0c, 0x0c, 0x02, 0x02, 0x02, 0x02, 0x02, + 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, + // Entry 240 - 27F + 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, + 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, + 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, + 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, + 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, + 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, + 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, + 0x0d, 0x0d, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, + // Entry 280 - 2BF + 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, + 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, + 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, + 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, + 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, + 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, + 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, + 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, + // Entry 2C0 - 2FF + 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, + 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, + 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, + 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, + 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, + 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, + 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, + 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, + // Entry 300 - 33F + 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x09, +} // Size: 799 bytes + +var tagToPercent = []uint8{ // 775 elements + // Entry 0 - 3F + 0x04, 0x04, 0x04, 0x04, 0x04, 0x04, 0x04, 0x04, + 0x04, 0x04, 0x04, 0x04, 0x04, 0x04, 0x04, 0x04, + 0x04, 0x04, 0x04, 0x04, 0x04, 0x04, 0x04, 0x04, + 0x04, 0x04, 0x04, 0x04, 0x04, 0x04, 0x04, 0x04, + 0x04, 0x04, 0x04, 0x04, 0x04, 0x04, 0x04, 0x04, + 0x06, 0x06, 0x04, 0x04, 0x04, 0x04, 0x04, 0x04, + 0x04, 0x04, 0x04, 0x03, 0x03, 0x03, 0x03, 0x04, + 0x04, 0x04, 0x04, 0x04, 0x04, 0x04, 0x04, 0x04, + // Entry 40 - 7F + 0x06, 0x06, 0x06, 0x04, 0x04, 0x04, 0x03, 0x03, + 0x06, 0x06, 0x03, 0x04, 0x04, 0x03, 0x03, 0x04, + 0x04, 0x04, 0x04, 0x04, 0x06, 0x06, 0x06, 0x03, + 0x03, 0x04, 0x04, 0x04, 0x04, 0x04, 0x04, 0x04, + 0x03, 0x03, 0x04, 0x04, 0x04, 0x04, 0x03, 0x03, + 0x03, 0x04, 0x04, 0x03, 0x03, 0x03, 0x04, 0x03, + 0x03, 0x04, 0x03, 0x04, 0x04, 0x03, 0x03, 0x03, + 0x03, 0x04, 0x04, 0x04, 0x07, 0x07, 0x04, 0x04, + // Entry 80 - BF + 0x04, 0x04, 0x04, 0x04, 0x04, 0x04, 0x04, 0x04, + 0x04, 0x04, 0x04, 0x04, 0x03, 0x04, 0x04, 0x04, + 0x04, 0x04, 0x04, 0x04, 0x04, 0x04, 0x04, 0x04, + 0x04, 0x04, 0x04, 0x04, 0x03, 0x04, 0x03, 0x04, + 0x04, 0x03, 0x04, 0x04, 0x04, 0x04, 0x04, 0x04, + 0x04, 0x04, 0x04, 0x04, 0x04, 0x04, 0x04, 0x04, + 0x04, 0x06, 0x04, 0x04, 0x04, 0x04, 0x04, 0x04, + 0x04, 0x04, 0x04, 0x04, 0x04, 0x04, 0x04, 0x04, + // Entry C0 - FF + 0x04, 0x04, 0x04, 0x04, 0x04, 0x04, 0x04, 0x04, + 0x04, 0x04, 0x04, 0x04, 0x04, 0x04, 0x04, 0x04, + 0x04, 0x04, 0x04, 0x04, 0x04, 0x04, 0x03, 0x04, + 0x04, 0x04, 0x04, 0x04, 0x04, 0x04, 0x04, 0x04, + 0x04, 0x04, 0x04, 0x04, 0x04, 0x04, 0x04, 0x04, + 0x04, 0x04, 0x04, 0x04, 0x04, 0x04, 0x04, 0x04, + 0x04, 0x03, 0x03, 0x03, 0x03, 0x03, 0x03, 0x03, + 0x03, 0x03, 0x03, 0x03, 0x03, 0x03, 0x03, 0x03, + // Entry 100 - 13F + 0x03, 0x03, 0x03, 0x03, 0x03, 0x03, 0x03, 0x03, + 0x03, 0x03, 0x03, 0x03, 0x03, 0x03, 0x04, 0x04, + 0x0b, 0x0b, 0x04, 0x04, 0x04, 0x04, 0x04, 0x04, + 0x04, 0x04, 0x04, 0x04, 0x03, 0x03, 0x04, 0x04, + 0x03, 0x03, 0x03, 0x03, 0x03, 0x03, 0x03, 0x03, + 0x03, 0x03, 0x03, 0x03, 0x03, 0x04, 0x03, 0x03, + 0x03, 0x03, 0x03, 0x03, 0x03, 0x03, 0x03, 0x03, + 0x03, 0x03, 0x03, 0x03, 0x03, 0x03, 0x03, 0x03, + // Entry 140 - 17F + 0x03, 0x03, 0x03, 0x03, 0x03, 0x03, 0x03, 0x03, + 0x03, 0x03, 0x03, 0x03, 0x03, 0x03, 0x03, 0x03, + 0x03, 0x03, 0x04, 0x04, 0x04, 0x04, 0x04, 0x04, + 0x04, 0x04, 0x03, 0x03, 0x03, 0x03, 0x03, 0x03, + 0x06, 0x06, 0x04, 0x04, 0x04, 0x04, 0x04, 0x04, + 0x04, 0x04, 0x04, 0x04, 0x04, 0x04, 0x04, 0x06, + 0x06, 0x04, 0x04, 0x04, 0x03, 0x03, 0x04, 0x04, + 0x04, 0x04, 0x04, 0x04, 0x04, 0x04, 0x04, 0x04, + // Entry 180 - 1BF + 0x04, 0x04, 0x04, 0x04, 0x04, 0x04, 0x04, 0x04, + 0x04, 0x04, 0x04, 0x04, 0x04, 0x04, 0x04, 0x04, + 0x04, 0x04, 0x04, 0x04, 0x04, 0x04, 0x04, 0x04, + 0x04, 0x04, 0x04, 0x04, 0x04, 0x04, 0x04, 0x04, + 0x04, 0x04, 0x04, 0x04, 0x04, 0x04, 0x04, 0x04, + 0x04, 0x03, 0x03, 0x04, 0x04, 0x04, 0x04, 0x04, + 0x04, 0x04, 0x04, 0x04, 0x06, 0x06, 0x06, 0x06, + 0x04, 0x04, 0x04, 0x04, 0x03, 0x03, 0x04, 0x04, + // Entry 1C0 - 1FF + 0x04, 0x04, 0x04, 0x04, 0x04, 0x03, 0x03, 0x04, + 0x04, 0x04, 0x04, 0x04, 0x04, 0x04, 0x04, 0x04, + 0x04, 0x04, 0x04, 0x04, 0x04, 0x03, 0x03, 0x04, + 0x04, 0x04, 0x04, 0x04, 0x04, 0x04, 0x04, 0x04, + 0x04, 0x04, 0x04, 0x04, 0x04, 0x04, 0x04, 0x04, + 0x04, 0x04, 0x04, 0x04, 0x04, 0x04, 0x04, 0x04, + 0x04, 0x04, 0x04, 0x04, 0x04, 0x04, 0x04, 0x04, + 0x04, 0x04, 0x04, 0x04, 0x04, 0x04, 0x04, 0x04, + // Entry 200 - 23F + 0x04, 0x04, 0x04, 0x04, 0x03, 0x03, 0x03, 0x04, + 0x04, 0x04, 0x04, 0x04, 0x04, 0x04, 0x04, 0x04, + 0x04, 0x04, 0x04, 0x04, 0x04, 0x04, 0x04, 0x04, + 0x04, 0x03, 0x03, 0x04, 0x04, 0x04, 0x04, 0x04, + 0x04, 0x04, 0x04, 0x04, 0x04, 0x04, 0x04, 0x04, + 0x04, 0x06, 0x06, 0x04, 0x04, 0x04, 0x06, 0x04, + 0x04, 0x06, 0x06, 0x04, 0x04, 0x04, 0x04, 0x04, + 0x04, 0x04, 0x04, 0x04, 0x04, 0x04, 0x04, 0x04, + // Entry 240 - 27F + 0x04, 0x04, 0x04, 0x04, 0x04, 0x04, 0x04, 0x03, + 0x03, 0x03, 0x03, 0x03, 0x03, 0x03, 0x03, 0x03, + 0x03, 0x03, 0x04, 0x04, 0x03, 0x03, 0x03, 0x03, + 0x03, 0x03, 0x03, 0x04, 0x04, 0x04, 0x04, 0x04, + 0x04, 0x04, 0x04, 0x04, 0x04, 0x04, 0x04, 0x04, + 0x03, 0x03, 0x03, 0x03, 0x04, 0x04, 0x04, 0x04, + 0x04, 0x04, 0x04, 0x04, 0x04, 0x04, 0x04, 0x04, + 0x04, 0x04, 0x03, 0x03, 0x03, 0x03, 0x04, 0x04, + // Entry 280 - 2BF + 0x04, 0x03, 0x03, 0x04, 0x04, 0x04, 0x04, 0x04, + 0x04, 0x04, 0x04, 0x04, 0x04, 0x04, 0x04, 0x04, + 0x04, 0x04, 0x04, 0x04, 0x04, 0x04, 0x04, 0x04, + 0x04, 0x04, 0x04, 0x04, 0x04, 0x03, 0x03, 0x03, + 0x03, 0x04, 0x04, 0x04, 0x04, 0x04, 0x04, 0x06, + 0x06, 0x06, 0x04, 0x04, 0x04, 0x04, 0x04, 0x04, + 0x04, 0x04, 0x04, 0x04, 0x04, 0x04, 0x04, 0x04, + 0x04, 0x03, 0x03, 0x04, 0x04, 0x04, 0x04, 0x0e, + // Entry 2C0 - 2FF + 0x0e, 0x0e, 0x04, 0x03, 0x03, 0x04, 0x04, 0x04, + 0x04, 0x04, 0x04, 0x04, 0x04, 0x04, 0x04, 0x04, + 0x04, 0x04, 0x04, 0x04, 0x04, 0x04, 0x04, 0x04, + 0x04, 0x04, 0x04, 0x04, 0x04, 0x04, 0x04, 0x04, + 0x04, 0x04, 0x04, 0x04, 0x04, 0x04, 0x04, 0x04, + 0x04, 0x04, 0x04, 0x04, 0x04, 0x04, 0x04, 0x04, + 0x04, 0x04, 0x04, 0x04, 0x04, 0x04, 0x04, 0x03, + 0x03, 0x04, 0x04, 0x04, 0x04, 0x04, 0x04, 0x04, + // Entry 300 - 33F + 0x04, 0x04, 0x04, 0x04, 0x04, 0x04, 0x0a, +} // Size: 799 bytes + +var formats = []Pattern{Pattern{RoundingContext: RoundingContext{MaxSignificantDigits: 0, + MaxFractionDigits: 0, + Increment: 0x0, + IncrementScale: 0x0, + Mode: 0x0, + DigitShift: 0x0, + MinIntegerDigits: 0x0, + MaxIntegerDigits: 0x0, + MinFractionDigits: 0x0, + MinSignificantDigits: 0x0, + MinExponentDigits: 0x0}, + Affix: "", + Offset: 0x0, + NegOffset: 0x0, + PadRune: 0, + FormatWidth: 0x0, + GroupingSize: [2]uint8{0x0, + 0x0}, + Flags: 0x0}, + Pattern{RoundingContext: RoundingContext{MaxSignificantDigits: 0, + MaxFractionDigits: 3, + Increment: 0x0, + IncrementScale: 0x0, + Mode: 0x0, + DigitShift: 0x0, + MinIntegerDigits: 0x1, + MaxIntegerDigits: 0x0, + MinFractionDigits: 0x0, + MinSignificantDigits: 0x0, + MinExponentDigits: 0x0}, + Affix: "", + Offset: 0x0, + NegOffset: 0x0, + PadRune: 0, + FormatWidth: 0x9, + GroupingSize: [2]uint8{0x3, + 0x0}, + Flags: 0x0}, + Pattern{RoundingContext: RoundingContext{MaxSignificantDigits: 0, + MaxFractionDigits: 0, + Increment: 0x0, + IncrementScale: 0x0, + Mode: 0x0, + DigitShift: 0x0, + MinIntegerDigits: 0x0, + MaxIntegerDigits: 0x1, + MinFractionDigits: 0x0, + MinSignificantDigits: 0x0, + MinExponentDigits: 0x1}, + Affix: "", + Offset: 0x0, + NegOffset: 0x0, + PadRune: 0, + FormatWidth: 0x3, + GroupingSize: [2]uint8{0x0, + 0x0}, + Flags: 0x0}, + Pattern{RoundingContext: RoundingContext{MaxSignificantDigits: 0, + MaxFractionDigits: 0, + Increment: 0x0, + IncrementScale: 0x0, + Mode: 0x0, + DigitShift: 0x2, + MinIntegerDigits: 0x1, + MaxIntegerDigits: 0x0, + MinFractionDigits: 0x0, + MinSignificantDigits: 0x0, + MinExponentDigits: 0x0}, + Affix: "\x00\x03\u00a0%", + Offset: 0x0, + NegOffset: 0x0, + PadRune: 0, + FormatWidth: 0x7, + GroupingSize: [2]uint8{0x3, + 0x0}, + Flags: 0x0}, + Pattern{RoundingContext: RoundingContext{MaxSignificantDigits: 0, + MaxFractionDigits: 0, + Increment: 0x0, + IncrementScale: 0x0, + Mode: 0x0, + DigitShift: 0x2, + MinIntegerDigits: 0x1, + MaxIntegerDigits: 0x0, + MinFractionDigits: 0x0, + MinSignificantDigits: 0x0, + MinExponentDigits: 0x0}, + Affix: "\x00\x01%", + Offset: 0x0, + NegOffset: 0x0, + PadRune: 0, + FormatWidth: 0x6, + GroupingSize: [2]uint8{0x3, + 0x0}, + Flags: 0x0}, + Pattern{RoundingContext: RoundingContext{MaxSignificantDigits: 0, + MaxFractionDigits: 3, + Increment: 0x0, + IncrementScale: 0x0, + Mode: 0x0, + DigitShift: 0x0, + MinIntegerDigits: 0x1, + MaxIntegerDigits: 0x0, + MinFractionDigits: 0x0, + MinSignificantDigits: 0x0, + MinExponentDigits: 0x0}, + Affix: "", + Offset: 0x0, + NegOffset: 0x0, + PadRune: 0, + FormatWidth: 0xc, + GroupingSize: [2]uint8{0x3, + 0x2}, + Flags: 0x0}, + Pattern{RoundingContext: RoundingContext{MaxSignificantDigits: 0, + MaxFractionDigits: 0, + Increment: 0x0, + IncrementScale: 0x0, + Mode: 0x0, + DigitShift: 0x2, + MinIntegerDigits: 0x1, + MaxIntegerDigits: 0x0, + MinFractionDigits: 0x0, + MinSignificantDigits: 0x0, + MinExponentDigits: 0x0}, + Affix: "\x00\x01%", + Offset: 0x0, + NegOffset: 0x0, + PadRune: 0, + FormatWidth: 0x9, + GroupingSize: [2]uint8{0x3, + 0x2}, + Flags: 0x0}, + Pattern{RoundingContext: RoundingContext{MaxSignificantDigits: 0, + MaxFractionDigits: 0, + Increment: 0x0, + IncrementScale: 0x0, + Mode: 0x0, + DigitShift: 0x2, + MinIntegerDigits: 0x1, + MaxIntegerDigits: 0x0, + MinFractionDigits: 0x0, + MinSignificantDigits: 0x0, + MinExponentDigits: 0x0}, + Affix: "\x00\x03\u00a0%", + Offset: 0x0, + NegOffset: 0x0, + PadRune: 0, + FormatWidth: 0xa, + GroupingSize: [2]uint8{0x3, + 0x2}, + Flags: 0x0}, + Pattern{RoundingContext: RoundingContext{MaxSignificantDigits: 0, + MaxFractionDigits: 6, + Increment: 0x0, + IncrementScale: 0x0, + Mode: 0x0, + DigitShift: 0x0, + MinIntegerDigits: 0x1, + MaxIntegerDigits: 0x0, + MinFractionDigits: 0x0, + MinSignificantDigits: 0x0, + MinExponentDigits: 0x0}, + Affix: "", + Offset: 0x0, + NegOffset: 0x0, + PadRune: 0, + FormatWidth: 0x8, + GroupingSize: [2]uint8{0x0, + 0x0}, + Flags: 0x0}, + Pattern{RoundingContext: RoundingContext{MaxSignificantDigits: 0, + MaxFractionDigits: 6, + Increment: 0x0, + IncrementScale: 0x0, + Mode: 0x0, + DigitShift: 0x0, + MinIntegerDigits: 0x1, + MaxIntegerDigits: 0x0, + MinFractionDigits: 0x6, + MinSignificantDigits: 0x0, + MinExponentDigits: 0x3}, + Affix: "", + Offset: 0x0, + NegOffset: 0x0, + PadRune: 0, + FormatWidth: 0xd, + GroupingSize: [2]uint8{0x0, + 0x0}, + Flags: 0x4}, + Pattern{RoundingContext: RoundingContext{MaxSignificantDigits: 0, + MaxFractionDigits: 0, + Increment: 0x0, + IncrementScale: 0x0, + Mode: 0x0, + DigitShift: 0x2, + MinIntegerDigits: 0x1, + MaxIntegerDigits: 0x0, + MinFractionDigits: 0x0, + MinSignificantDigits: 0x0, + MinExponentDigits: 0x0}, + Affix: "\x00\x01%", + Offset: 0x0, + NegOffset: 0x0, + PadRune: 0, + FormatWidth: 0x2, + GroupingSize: [2]uint8{0x0, + 0x0}, + Flags: 0x0}, + Pattern{RoundingContext: RoundingContext{MaxSignificantDigits: 0, + MaxFractionDigits: 0, + Increment: 0x0, + IncrementScale: 0x0, + Mode: 0x0, + DigitShift: 0x2, + MinIntegerDigits: 0x1, + MaxIntegerDigits: 0x0, + MinFractionDigits: 0x0, + MinSignificantDigits: 0x0, + MinExponentDigits: 0x0}, + Affix: "\x03%\u00a0\x00", + Offset: 0x0, + NegOffset: 0x0, + PadRune: 0, + FormatWidth: 0x7, + GroupingSize: [2]uint8{0x3, + 0x0}, + Flags: 0x0}, + Pattern{RoundingContext: RoundingContext{MaxSignificantDigits: 0, + MaxFractionDigits: 0, + Increment: 0x0, + IncrementScale: 0x0, + Mode: 0x0, + DigitShift: 0x0, + MinIntegerDigits: 0x0, + MaxIntegerDigits: 0x1, + MinFractionDigits: 0x0, + MinSignificantDigits: 0x0, + MinExponentDigits: 0x1}, + Affix: "\x01[\x01]", + Offset: 0x0, + NegOffset: 0x0, + PadRune: 0, + FormatWidth: 0x5, + GroupingSize: [2]uint8{0x0, + 0x0}, + Flags: 0x0}, + Pattern{RoundingContext: RoundingContext{MaxSignificantDigits: 0, + MaxFractionDigits: 0, + Increment: 0x0, + IncrementScale: 0x0, + Mode: 0x0, + DigitShift: 0x0, + MinIntegerDigits: 0x0, + MaxIntegerDigits: 0x0, + MinFractionDigits: 0x0, + MinSignificantDigits: 0x0, + MinExponentDigits: 0x0}, + Affix: "", + Offset: 0x0, + NegOffset: 0x0, + PadRune: 0, + FormatWidth: 0x1, + GroupingSize: [2]uint8{0x0, + 0x0}, + Flags: 0x0}, + Pattern{RoundingContext: RoundingContext{MaxSignificantDigits: 0, + MaxFractionDigits: 0, + Increment: 0x0, + IncrementScale: 0x0, + Mode: 0x0, + DigitShift: 0x2, + MinIntegerDigits: 0x1, + MaxIntegerDigits: 0x0, + MinFractionDigits: 0x0, + MinSignificantDigits: 0x0, + MinExponentDigits: 0x0}, + Affix: "\x01%\x00", + Offset: 0x0, + NegOffset: 0x0, + PadRune: 0, + FormatWidth: 0x6, + GroupingSize: [2]uint8{0x3, + 0x0}, + Flags: 0x0}} + +// Total table size 8634 bytes (8KiB); checksum: 8F23386D diff --git a/vendor/golang.org/x/text/internal/stringset/set.go b/vendor/golang.org/x/text/internal/stringset/set.go new file mode 100644 index 00000000..bb2fffbc --- /dev/null +++ b/vendor/golang.org/x/text/internal/stringset/set.go @@ -0,0 +1,86 @@ +// Copyright 2016 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +// Package stringset provides a way to represent a collection of strings +// compactly. +package stringset + +import "sort" + +// A Set holds a collection of strings that can be looked up by an index number. +type Set struct { + // These fields are exported to allow for code generation. + + Data string + Index []uint16 +} + +// Elem returns the string with index i. It panics if i is out of range. +func (s *Set) Elem(i int) string { + return s.Data[s.Index[i]:s.Index[i+1]] +} + +// Len returns the number of strings in the set. +func (s *Set) Len() int { + return len(s.Index) - 1 +} + +// Search returns the index of the given string or -1 if it is not in the set. +// The Set must have been created with strings in sorted order. +func Search(s *Set, str string) int { + // TODO: optimize this if it gets used a lot. + n := len(s.Index) - 1 + p := sort.Search(n, func(i int) bool { + return s.Elem(i) >= str + }) + if p == n || str != s.Elem(p) { + return -1 + } + return p +} + +// A Builder constructs Sets. +type Builder struct { + set Set + index map[string]int +} + +// NewBuilder returns a new and initialized Builder. +func NewBuilder() *Builder { + return &Builder{ + set: Set{ + Index: []uint16{0}, + }, + index: map[string]int{}, + } +} + +// Set creates the set created so far. +func (b *Builder) Set() Set { + return b.set +} + +// Index returns the index for the given string, which must have been added +// before. +func (b *Builder) Index(s string) int { + return b.index[s] +} + +// Add adds a string to the index. Strings that are added by a single Add will +// be stored together, unless they match an existing string. +func (b *Builder) Add(ss ...string) { + // First check if the string already exists. + for _, s := range ss { + if _, ok := b.index[s]; ok { + continue + } + b.index[s] = len(b.set.Index) - 1 + b.set.Data += s + x := len(b.set.Data) + if x > 0xFFFF { + panic("Index too > 0xFFFF") + } + b.set.Index = append(b.set.Index, uint16(x)) + } +} diff --git a/vendor/golang.org/x/text/message/catalog.go b/vendor/golang.org/x/text/message/catalog.go new file mode 100644 index 00000000..068271de --- /dev/null +++ b/vendor/golang.org/x/text/message/catalog.go @@ -0,0 +1,36 @@ +// Copyright 2015 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package message + +// TODO: some types in this file will need to be made public at some time. +// Documentation and method names will reflect this by using the exported name. + +import ( + "golang.org/x/text/language" + "golang.org/x/text/message/catalog" +) + +// MatchLanguage reports the matched tag obtained from language.MatchStrings for +// the Matcher of the DefaultCatalog. +func MatchLanguage(preferred ...string) language.Tag { + c := DefaultCatalog + tag, _ := language.MatchStrings(c.Matcher(), preferred...) + return tag +} + +// DefaultCatalog is used by SetString. +var DefaultCatalog catalog.Catalog = defaultCatalog + +var defaultCatalog = catalog.NewBuilder() + +// SetString calls SetString on the initial default Catalog. +func SetString(tag language.Tag, key string, msg string) error { + return defaultCatalog.SetString(tag, key, msg) +} + +// Set calls Set on the initial default Catalog. +func Set(tag language.Tag, key string, msg ...catalog.Message) error { + return defaultCatalog.Set(tag, key, msg...) +} diff --git a/vendor/golang.org/x/text/message/catalog/catalog.go b/vendor/golang.org/x/text/message/catalog/catalog.go new file mode 100644 index 00000000..96955d07 --- /dev/null +++ b/vendor/golang.org/x/text/message/catalog/catalog.go @@ -0,0 +1,365 @@ +// Copyright 2017 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +// Package catalog defines collections of translated format strings. +// +// This package mostly defines types for populating catalogs with messages. The +// catmsg package contains further definitions for creating custom message and +// dictionary types as well as packages that use Catalogs. +// +// Package catalog defines various interfaces: Dictionary, Loader, and Message. +// A Dictionary maintains a set of translations of format strings for a single +// language. The Loader interface defines a source of dictionaries. A +// translation of a format string is represented by a Message. +// +// # Catalogs +// +// A Catalog defines a programmatic interface for setting message translations. +// It maintains a set of per-language dictionaries with translations for a set +// of keys. For message translation to function properly, a translation should +// be defined for each key for each supported language. A dictionary may be +// underspecified, though, if there is a parent language that already defines +// the key. For example, a Dictionary for "en-GB" could leave out entries that +// are identical to those in a dictionary for "en". +// +// # Messages +// +// A Message is a format string which varies on the value of substitution +// variables. For instance, to indicate the number of results one could want "no +// results" if there are none, "1 result" if there is 1, and "%d results" for +// any other number. Catalog is agnostic to the kind of format strings that are +// used: for instance, messages can follow either the printf-style substitution +// from package fmt or use templates. +// +// A Message does not substitute arguments in the format string. This job is +// reserved for packages that render strings, such as message, that use Catalogs +// to selected string. This separation of concerns allows Catalog to be used to +// store any kind of formatting strings. +// +// # Selecting messages based on linguistic features of substitution arguments +// +// Messages may vary based on any linguistic features of the argument values. +// The most common one is plural form, but others exist. +// +// Selection messages are provided in packages that provide support for a +// specific linguistic feature. The following snippet uses plural.Selectf: +// +// catalog.Set(language.English, "You are %d minute(s) late.", +// plural.Selectf(1, "", +// plural.One, "You are 1 minute late.", +// plural.Other, "You are %d minutes late.")) +// +// In this example, a message is stored in the Catalog where one of two messages +// is selected based on the first argument, a number. The first message is +// selected if the argument is singular (identified by the selector "one") and +// the second message is selected in all other cases. The selectors are defined +// by the plural rules defined in CLDR. The selector "other" is special and will +// always match. Each language always defines one of the linguistic categories +// to be "other." For English, singular is "one" and plural is "other". +// +// Selects can be nested. This allows selecting sentences based on features of +// multiple arguments or multiple linguistic properties of a single argument. +// +// # String interpolation +// +// There is often a lot of commonality between the possible variants of a +// message. For instance, in the example above the word "minute" varies based on +// the plural catogory of the argument, but the rest of the sentence is +// identical. Using interpolation the above message can be rewritten as: +// +// catalog.Set(language.English, "You are %d minute(s) late.", +// catalog.Var("minutes", +// plural.Selectf(1, "", plural.One, "minute", plural.Other, "minutes")), +// catalog.String("You are %[1]d ${minutes} late.")) +// +// Var is defined to return the variable name if the message does not yield a +// match. This allows us to further simplify this snippet to +// +// catalog.Set(language.English, "You are %d minute(s) late.", +// catalog.Var("minutes", plural.Selectf(1, "", plural.One, "minute")), +// catalog.String("You are %d ${minutes} late.")) +// +// Overall this is still only a minor improvement, but things can get a lot more +// unwieldy if more than one linguistic feature is used to determine a message +// variant. Consider the following example: +// +// // argument 1: list of hosts, argument 2: list of guests +// catalog.Set(language.English, "%[1]v invite(s) %[2]v to their party.", +// catalog.Var("their", +// plural.Selectf(1, "" +// plural.One, gender.Select(1, "female", "her", "other", "his"))), +// catalog.Var("invites", plural.Selectf(1, "", plural.One, "invite")) +// catalog.String("%[1]v ${invites} %[2]v to ${their} party.")), +// +// Without variable substitution, this would have to be written as +// +// // argument 1: list of hosts, argument 2: list of guests +// catalog.Set(language.English, "%[1]v invite(s) %[2]v to their party.", +// plural.Selectf(1, "", +// plural.One, gender.Select(1, +// "female", "%[1]v invites %[2]v to her party." +// "other", "%[1]v invites %[2]v to his party."), +// plural.Other, "%[1]v invites %[2]v to their party.")) +// +// Not necessarily shorter, but using variables there is less duplication and +// the messages are more maintenance friendly. Moreover, languages may have up +// to six plural forms. This makes the use of variables more welcome. +// +// Different messages using the same inflections can reuse variables by moving +// them to macros. Using macros we can rewrite the message as: +// +// // argument 1: list of hosts, argument 2: list of guests +// catalog.SetString(language.English, "%[1]v invite(s) %[2]v to their party.", +// "%[1]v ${invites(1)} %[2]v to ${their(1)} party.") +// +// Where the following macros were defined separately. +// +// catalog.SetMacro(language.English, "invites", plural.Selectf(1, "", +// plural.One, "invite")) +// catalog.SetMacro(language.English, "their", plural.Selectf(1, "", +// plural.One, gender.Select(1, "female", "her", "other", "his"))), +// +// Placeholders use parentheses and the arguments to invoke a macro. +// +// # Looking up messages +// +// Message lookup using Catalogs is typically only done by specialized packages +// and is not something the user should be concerned with. For instance, to +// express the tardiness of a user using the related message we defined earlier, +// the user may use the package message like so: +// +// p := message.NewPrinter(language.English) +// p.Printf("You are %d minute(s) late.", 5) +// +// Which would print: +// +// You are 5 minutes late. +// +// This package is UNDER CONSTRUCTION and its API may change. +package catalog // import "golang.org/x/text/message/catalog" + +// TODO: +// Some way to freeze a catalog. +// - Locking on each lockup turns out to be about 50% of the total running time +// for some of the benchmarks in the message package. +// Consider these: +// - Sequence type to support sequences in user-defined messages. +// - Garbage collection: Remove dictionaries that can no longer be reached +// as other dictionaries have been added that cover all possible keys. + +import ( + "errors" + "fmt" + + "golang.org/x/text/internal" + + "golang.org/x/text/internal/catmsg" + "golang.org/x/text/language" +) + +// A Catalog allows lookup of translated messages. +type Catalog interface { + // Languages returns all languages for which the Catalog contains variants. + Languages() []language.Tag + + // Matcher returns a Matcher for languages from this Catalog. + Matcher() language.Matcher + + // A Context is used for evaluating Messages. + Context(tag language.Tag, r catmsg.Renderer) *Context + + // This method also makes Catalog a private interface. + lookup(tag language.Tag, key string) (data string, ok bool) +} + +// NewFromMap creates a Catalog from the given map. If a Dictionary is +// underspecified the entry is retrieved from a parent language. +func NewFromMap(dictionaries map[string]Dictionary, opts ...Option) (Catalog, error) { + options := options{} + for _, o := range opts { + o(&options) + } + c := &catalog{ + dicts: map[language.Tag]Dictionary{}, + } + _, hasFallback := dictionaries[options.fallback.String()] + if hasFallback { + // TODO: Should it be okay to not have a fallback language? + // Catalog generators could enforce there is always a fallback. + c.langs = append(c.langs, options.fallback) + } + for lang, dict := range dictionaries { + tag, err := language.Parse(lang) + if err != nil { + return nil, fmt.Errorf("catalog: invalid language tag %q", lang) + } + if _, ok := c.dicts[tag]; ok { + return nil, fmt.Errorf("catalog: duplicate entry for tag %q after normalization", tag) + } + c.dicts[tag] = dict + if !hasFallback || tag != options.fallback { + c.langs = append(c.langs, tag) + } + } + if hasFallback { + internal.SortTags(c.langs[1:]) + } else { + internal.SortTags(c.langs) + } + c.matcher = language.NewMatcher(c.langs) + return c, nil +} + +// A Dictionary is a source of translations for a single language. +type Dictionary interface { + // Lookup returns a message compiled with catmsg.Compile for the given key. + // It returns false for ok if such a message could not be found. + Lookup(key string) (data string, ok bool) +} + +type catalog struct { + langs []language.Tag + dicts map[language.Tag]Dictionary + macros store + matcher language.Matcher +} + +func (c *catalog) Languages() []language.Tag { return c.langs } +func (c *catalog) Matcher() language.Matcher { return c.matcher } + +func (c *catalog) lookup(tag language.Tag, key string) (data string, ok bool) { + for ; ; tag = tag.Parent() { + if dict, ok := c.dicts[tag]; ok { + if data, ok := dict.Lookup(key); ok { + return data, true + } + } + if tag == language.Und { + break + } + } + return "", false +} + +// Context returns a Context for formatting messages. +// Only one Message may be formatted per context at any given time. +func (c *catalog) Context(tag language.Tag, r catmsg.Renderer) *Context { + return &Context{ + cat: c, + tag: tag, + dec: catmsg.NewDecoder(tag, r, &dict{&c.macros, tag}), + } +} + +// A Builder allows building a Catalog programmatically. +type Builder struct { + options + matcher language.Matcher + + index store + macros store +} + +type options struct { + fallback language.Tag +} + +// An Option configures Catalog behavior. +type Option func(*options) + +// Fallback specifies the default fallback language. The default is Und. +func Fallback(tag language.Tag) Option { + return func(o *options) { o.fallback = tag } +} + +// TODO: +// // Catalogs specifies one or more sources for a Catalog. +// // Lookups are in order. +// // This can be changed inserting a Catalog used for setting, which implements +// // Loader, used for setting in the chain. +// func Catalogs(d ...Loader) Option { +// return nil +// } +// +// func Delims(start, end string) Option {} +// +// func Dict(tag language.Tag, d ...Dictionary) Option + +// NewBuilder returns an empty mutable Catalog. +func NewBuilder(opts ...Option) *Builder { + c := &Builder{} + for _, o := range opts { + o(&c.options) + } + return c +} + +// SetString is shorthand for Set(tag, key, String(msg)). +func (c *Builder) SetString(tag language.Tag, key string, msg string) error { + return c.set(tag, key, &c.index, String(msg)) +} + +// Set sets the translation for the given language and key. +// +// When evaluation this message, the first Message in the sequence to msgs to +// evaluate to a string will be the message returned. +func (c *Builder) Set(tag language.Tag, key string, msg ...Message) error { + return c.set(tag, key, &c.index, msg...) +} + +// SetMacro defines a Message that may be substituted in another message. +// The arguments to a macro Message are passed as arguments in the +// placeholder the form "${foo(arg1, arg2)}". +func (c *Builder) SetMacro(tag language.Tag, name string, msg ...Message) error { + return c.set(tag, name, &c.macros, msg...) +} + +// ErrNotFound indicates there was no message for the given key. +var ErrNotFound = errors.New("catalog: message not found") + +// String specifies a plain message string. It can be used as fallback if no +// other strings match or as a simple standalone message. +// +// It is an error to pass more than one String in a message sequence. +func String(name string) Message { + return catmsg.String(name) +} + +// Var sets a variable that may be substituted in formatting patterns using +// named substitution of the form "${name}". The name argument is used as a +// fallback if the statements do not produce a match. The statement sequence may +// not contain any Var calls. +// +// The name passed to a Var must be unique within message sequence. +func Var(name string, msg ...Message) Message { + return &catmsg.Var{Name: name, Message: firstInSequence(msg)} +} + +// Context returns a Context for formatting messages. +// Only one Message may be formatted per context at any given time. +func (b *Builder) Context(tag language.Tag, r catmsg.Renderer) *Context { + return &Context{ + cat: b, + tag: tag, + dec: catmsg.NewDecoder(tag, r, &dict{&b.macros, tag}), + } +} + +// A Context is used for evaluating Messages. +// Only one Message may be formatted per context at any given time. +type Context struct { + cat Catalog + tag language.Tag // TODO: use compact index. + dec *catmsg.Decoder +} + +// Execute looks up and executes the message with the given key. +// It returns ErrNotFound if no message could be found in the index. +func (c *Context) Execute(key string) error { + data, ok := c.cat.lookup(c.tag, key) + if !ok { + return ErrNotFound + } + return c.dec.Execute(data) +} diff --git a/vendor/golang.org/x/text/message/catalog/dict.go b/vendor/golang.org/x/text/message/catalog/dict.go new file mode 100644 index 00000000..a0eb8181 --- /dev/null +++ b/vendor/golang.org/x/text/message/catalog/dict.go @@ -0,0 +1,129 @@ +// Copyright 2017 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package catalog + +import ( + "sync" + + "golang.org/x/text/internal" + "golang.org/x/text/internal/catmsg" + "golang.org/x/text/language" +) + +// TODO: +// Dictionary returns a Dictionary that returns the first Message, using the +// given language tag, that matches: +// 1. the last one registered by one of the Set methods +// 2. returned by one of the Loaders +// 3. repeat from 1. using the parent language +// This approach allows messages to be underspecified. +// func (c *Catalog) Dictionary(tag language.Tag) (Dictionary, error) { +// // TODO: verify dictionary exists. +// return &dict{&c.index, tag}, nil +// } + +type dict struct { + s *store + tag language.Tag // TODO: make compact tag. +} + +func (d *dict) Lookup(key string) (data string, ok bool) { + return d.s.lookup(d.tag, key) +} + +func (b *Builder) lookup(tag language.Tag, key string) (data string, ok bool) { + return b.index.lookup(tag, key) +} + +func (c *Builder) set(tag language.Tag, key string, s *store, msg ...Message) error { + data, err := catmsg.Compile(tag, &dict{&c.macros, tag}, firstInSequence(msg)) + + s.mutex.Lock() + defer s.mutex.Unlock() + + m := s.index[tag] + if m == nil { + m = msgMap{} + if s.index == nil { + s.index = map[language.Tag]msgMap{} + } + c.matcher = nil + s.index[tag] = m + } + + m[key] = data + return err +} + +func (c *Builder) Matcher() language.Matcher { + c.index.mutex.RLock() + m := c.matcher + c.index.mutex.RUnlock() + if m != nil { + return m + } + + c.index.mutex.Lock() + if c.matcher == nil { + c.matcher = language.NewMatcher(c.unlockedLanguages()) + } + m = c.matcher + c.index.mutex.Unlock() + return m +} + +type store struct { + mutex sync.RWMutex + index map[language.Tag]msgMap +} + +type msgMap map[string]string + +func (s *store) lookup(tag language.Tag, key string) (data string, ok bool) { + s.mutex.RLock() + defer s.mutex.RUnlock() + + for ; ; tag = tag.Parent() { + if msgs, ok := s.index[tag]; ok { + if msg, ok := msgs[key]; ok { + return msg, true + } + } + if tag == language.Und { + break + } + } + return "", false +} + +// Languages returns all languages for which the Catalog contains variants. +func (b *Builder) Languages() []language.Tag { + s := &b.index + s.mutex.RLock() + defer s.mutex.RUnlock() + + return b.unlockedLanguages() +} + +func (b *Builder) unlockedLanguages() []language.Tag { + s := &b.index + if len(s.index) == 0 { + return nil + } + tags := make([]language.Tag, 0, len(s.index)) + _, hasFallback := s.index[b.options.fallback] + offset := 0 + if hasFallback { + tags = append(tags, b.options.fallback) + offset = 1 + } + for t := range s.index { + if t != b.options.fallback { + tags = append(tags, t) + } + } + internal.SortTags(tags[offset:]) + return tags +} diff --git a/vendor/golang.org/x/text/message/catalog/go19.go b/vendor/golang.org/x/text/message/catalog/go19.go new file mode 100644 index 00000000..291a4df9 --- /dev/null +++ b/vendor/golang.org/x/text/message/catalog/go19.go @@ -0,0 +1,15 @@ +// Copyright 2017 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +//go:build go1.9 + +package catalog + +import "golang.org/x/text/internal/catmsg" + +// A Message holds a collection of translations for the same phrase that may +// vary based on the values of substitution arguments. +type Message = catmsg.Message + +type firstInSequence = catmsg.FirstOf diff --git a/vendor/golang.org/x/text/message/catalog/gopre19.go b/vendor/golang.org/x/text/message/catalog/gopre19.go new file mode 100644 index 00000000..da44ebb8 --- /dev/null +++ b/vendor/golang.org/x/text/message/catalog/gopre19.go @@ -0,0 +1,23 @@ +// Copyright 2017 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +//go:build !go1.9 + +package catalog + +import "golang.org/x/text/internal/catmsg" + +// A Message holds a collection of translations for the same phrase that may +// vary based on the values of substitution arguments. +type Message interface { + catmsg.Message +} + +func firstInSequence(m []Message) catmsg.Message { + a := []catmsg.Message{} + for _, m := range m { + a = append(a, m) + } + return catmsg.FirstOf(a) +} diff --git a/vendor/golang.org/x/text/message/doc.go b/vendor/golang.org/x/text/message/doc.go new file mode 100644 index 00000000..4bf7bdca --- /dev/null +++ b/vendor/golang.org/x/text/message/doc.go @@ -0,0 +1,99 @@ +// Copyright 2017 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +// Package message implements formatted I/O for localized strings with functions +// analogous to the fmt's print functions. It is a drop-in replacement for fmt. +// +// # Localized Formatting +// +// A format string can be localized by replacing any of the print functions of +// fmt with an equivalent call to a Printer. +// +// p := message.NewPrinter(message.MatchLanguage("en")) +// p.Println(123456.78) // Prints 123,456.78 +// +// p.Printf("%d ducks in a row", 4331) // Prints 4,331 ducks in a row +// +// p := message.NewPrinter(message.MatchLanguage("nl")) +// p.Printf("Hoogte: %.1f meter", 1244.9) // Prints Hoogte: 1,244.9 meter +// +// p := message.NewPrinter(message.MatchLanguage("bn")) +// p.Println(123456.78) // Prints ১,২৩,৪৫৬.৭৮ +// +// Printer currently supports numbers and specialized types for which packages +// exist in x/text. Other builtin types such as time.Time and slices are +// planned. +// +// Format strings largely have the same meaning as with fmt with the following +// notable exceptions: +// - flag # always resorts to fmt for printing +// - verb 'f', 'e', 'g', 'd' use localized formatting unless the '#' flag is +// specified. +// - verb 'm' inserts a translation of a string argument. +// +// See package fmt for more options. +// +// # Translation +// +// The format strings that are passed to Printf, Sprintf, Fprintf, or Errorf +// are used as keys to look up translations for the specified languages. +// More on how these need to be specified below. +// +// One can use arbitrary keys to distinguish between otherwise ambiguous +// strings: +// +// p := message.NewPrinter(language.English) +// p.Printf("archive(noun)") // Prints "archive" +// p.Printf("archive(verb)") // Prints "archive" +// +// p := message.NewPrinter(language.German) +// p.Printf("archive(noun)") // Prints "Archiv" +// p.Printf("archive(verb)") // Prints "archivieren" +// +// To retain the fallback functionality, use Key: +// +// p.Printf(message.Key("archive(noun)", "archive")) +// p.Printf(message.Key("archive(verb)", "archive")) +// +// # Translation Pipeline +// +// Format strings that contain text need to be translated to support different +// locales. The first step is to extract strings that need to be translated. +// +// 1. Install gotext +// +// go get -u golang.org/x/text/cmd/gotext +// gotext -help +// +// 2. Mark strings in your source to be translated by using message.Printer, +// instead of the functions of the fmt package. +// +// 3. Extract the strings from your source +// +// gotext extract +// +// The output will be written to the textdata directory. +// +// 4. Send the files for translation +// +// It is planned to support multiple formats, but for now one will have to +// rewrite the JSON output to the desired format. +// +// 5. Inject translations into program +// +// 6. Repeat from 2 +// +// Right now this has to be done programmatically with calls to Set or +// SetString. These functions as well as the methods defined in +// see also package golang.org/x/text/message/catalog can be used to implement +// either dynamic or static loading of messages. +// +// # Plural and Gender Forms +// +// Translated messages can vary based on the plural and gender forms of +// substitution values. In general, it is up to the translators to provide +// alternative translations for such forms. See the packages in +// golang.org/x/text/feature and golang.org/x/text/message/catalog for more +// information. +package message diff --git a/vendor/golang.org/x/text/message/format.go b/vendor/golang.org/x/text/message/format.go new file mode 100644 index 00000000..a47d17dd --- /dev/null +++ b/vendor/golang.org/x/text/message/format.go @@ -0,0 +1,510 @@ +// Copyright 2017 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package message + +import ( + "bytes" + "strconv" + "unicode/utf8" + + "golang.org/x/text/internal/format" +) + +const ( + ldigits = "0123456789abcdefx" + udigits = "0123456789ABCDEFX" +) + +const ( + signed = true + unsigned = false +) + +// A formatInfo is the raw formatter used by Printf etc. +// It prints into a buffer that must be set up separately. +type formatInfo struct { + buf *bytes.Buffer + + format.Parser + + // intbuf is large enough to store %b of an int64 with a sign and + // avoids padding at the end of the struct on 32 bit architectures. + intbuf [68]byte +} + +func (f *formatInfo) init(buf *bytes.Buffer) { + f.ClearFlags() + f.buf = buf +} + +// writePadding generates n bytes of padding. +func (f *formatInfo) writePadding(n int) { + if n <= 0 { // No padding bytes needed. + return + } + f.buf.Grow(n) + // Decide which byte the padding should be filled with. + padByte := byte(' ') + if f.Zero { + padByte = byte('0') + } + // Fill padding with padByte. + for i := 0; i < n; i++ { + f.buf.WriteByte(padByte) // TODO: make more efficient. + } +} + +// pad appends b to f.buf, padded on left (!f.minus) or right (f.minus). +func (f *formatInfo) pad(b []byte) { + if !f.WidthPresent || f.Width == 0 { + f.buf.Write(b) + return + } + width := f.Width - utf8.RuneCount(b) + if !f.Minus { + // left padding + f.writePadding(width) + f.buf.Write(b) + } else { + // right padding + f.buf.Write(b) + f.writePadding(width) + } +} + +// padString appends s to f.buf, padded on left (!f.minus) or right (f.minus). +func (f *formatInfo) padString(s string) { + if !f.WidthPresent || f.Width == 0 { + f.buf.WriteString(s) + return + } + width := f.Width - utf8.RuneCountInString(s) + if !f.Minus { + // left padding + f.writePadding(width) + f.buf.WriteString(s) + } else { + // right padding + f.buf.WriteString(s) + f.writePadding(width) + } +} + +// fmt_boolean formats a boolean. +func (f *formatInfo) fmt_boolean(v bool) { + if v { + f.padString("true") + } else { + f.padString("false") + } +} + +// fmt_unicode formats a uint64 as "U+0078" or with f.sharp set as "U+0078 'x'". +func (f *formatInfo) fmt_unicode(u uint64) { + buf := f.intbuf[0:] + + // With default precision set the maximum needed buf length is 18 + // for formatting -1 with %#U ("U+FFFFFFFFFFFFFFFF") which fits + // into the already allocated intbuf with a capacity of 68 bytes. + prec := 4 + if f.PrecPresent && f.Prec > 4 { + prec = f.Prec + // Compute space needed for "U+" , number, " '", character, "'". + width := 2 + prec + 2 + utf8.UTFMax + 1 + if width > len(buf) { + buf = make([]byte, width) + } + } + + // Format into buf, ending at buf[i]. Formatting numbers is easier right-to-left. + i := len(buf) + + // For %#U we want to add a space and a quoted character at the end of the buffer. + if f.Sharp && u <= utf8.MaxRune && strconv.IsPrint(rune(u)) { + i-- + buf[i] = '\'' + i -= utf8.RuneLen(rune(u)) + utf8.EncodeRune(buf[i:], rune(u)) + i-- + buf[i] = '\'' + i-- + buf[i] = ' ' + } + // Format the Unicode code point u as a hexadecimal number. + for u >= 16 { + i-- + buf[i] = udigits[u&0xF] + prec-- + u >>= 4 + } + i-- + buf[i] = udigits[u] + prec-- + // Add zeros in front of the number until requested precision is reached. + for prec > 0 { + i-- + buf[i] = '0' + prec-- + } + // Add a leading "U+". + i-- + buf[i] = '+' + i-- + buf[i] = 'U' + + oldZero := f.Zero + f.Zero = false + f.pad(buf[i:]) + f.Zero = oldZero +} + +// fmt_integer formats signed and unsigned integers. +func (f *formatInfo) fmt_integer(u uint64, base int, isSigned bool, digits string) { + negative := isSigned && int64(u) < 0 + if negative { + u = -u + } + + buf := f.intbuf[0:] + // The already allocated f.intbuf with a capacity of 68 bytes + // is large enough for integer formatting when no precision or width is set. + if f.WidthPresent || f.PrecPresent { + // Account 3 extra bytes for possible addition of a sign and "0x". + width := 3 + f.Width + f.Prec // wid and prec are always positive. + if width > len(buf) { + // We're going to need a bigger boat. + buf = make([]byte, width) + } + } + + // Two ways to ask for extra leading zero digits: %.3d or %03d. + // If both are specified the f.zero flag is ignored and + // padding with spaces is used instead. + prec := 0 + if f.PrecPresent { + prec = f.Prec + // Precision of 0 and value of 0 means "print nothing" but padding. + if prec == 0 && u == 0 { + oldZero := f.Zero + f.Zero = false + f.writePadding(f.Width) + f.Zero = oldZero + return + } + } else if f.Zero && f.WidthPresent { + prec = f.Width + if negative || f.Plus || f.Space { + prec-- // leave room for sign + } + } + + // Because printing is easier right-to-left: format u into buf, ending at buf[i]. + // We could make things marginally faster by splitting the 32-bit case out + // into a separate block but it's not worth the duplication, so u has 64 bits. + i := len(buf) + // Use constants for the division and modulo for more efficient code. + // Switch cases ordered by popularity. + switch base { + case 10: + for u >= 10 { + i-- + next := u / 10 + buf[i] = byte('0' + u - next*10) + u = next + } + case 16: + for u >= 16 { + i-- + buf[i] = digits[u&0xF] + u >>= 4 + } + case 8: + for u >= 8 { + i-- + buf[i] = byte('0' + u&7) + u >>= 3 + } + case 2: + for u >= 2 { + i-- + buf[i] = byte('0' + u&1) + u >>= 1 + } + default: + panic("fmt: unknown base; can't happen") + } + i-- + buf[i] = digits[u] + for i > 0 && prec > len(buf)-i { + i-- + buf[i] = '0' + } + + // Various prefixes: 0x, -, etc. + if f.Sharp { + switch base { + case 8: + if buf[i] != '0' { + i-- + buf[i] = '0' + } + case 16: + // Add a leading 0x or 0X. + i-- + buf[i] = digits[16] + i-- + buf[i] = '0' + } + } + + if negative { + i-- + buf[i] = '-' + } else if f.Plus { + i-- + buf[i] = '+' + } else if f.Space { + i-- + buf[i] = ' ' + } + + // Left padding with zeros has already been handled like precision earlier + // or the f.zero flag is ignored due to an explicitly set precision. + oldZero := f.Zero + f.Zero = false + f.pad(buf[i:]) + f.Zero = oldZero +} + +// truncate truncates the string to the specified precision, if present. +func (f *formatInfo) truncate(s string) string { + if f.PrecPresent { + n := f.Prec + for i := range s { + n-- + if n < 0 { + return s[:i] + } + } + } + return s +} + +// fmt_s formats a string. +func (f *formatInfo) fmt_s(s string) { + s = f.truncate(s) + f.padString(s) +} + +// fmt_sbx formats a string or byte slice as a hexadecimal encoding of its bytes. +func (f *formatInfo) fmt_sbx(s string, b []byte, digits string) { + length := len(b) + if b == nil { + // No byte slice present. Assume string s should be encoded. + length = len(s) + } + // Set length to not process more bytes than the precision demands. + if f.PrecPresent && f.Prec < length { + length = f.Prec + } + // Compute width of the encoding taking into account the f.sharp and f.space flag. + width := 2 * length + if width > 0 { + if f.Space { + // Each element encoded by two hexadecimals will get a leading 0x or 0X. + if f.Sharp { + width *= 2 + } + // Elements will be separated by a space. + width += length - 1 + } else if f.Sharp { + // Only a leading 0x or 0X will be added for the whole string. + width += 2 + } + } else { // The byte slice or string that should be encoded is empty. + if f.WidthPresent { + f.writePadding(f.Width) + } + return + } + // Handle padding to the left. + if f.WidthPresent && f.Width > width && !f.Minus { + f.writePadding(f.Width - width) + } + // Write the encoding directly into the output buffer. + buf := f.buf + if f.Sharp { + // Add leading 0x or 0X. + buf.WriteByte('0') + buf.WriteByte(digits[16]) + } + var c byte + for i := 0; i < length; i++ { + if f.Space && i > 0 { + // Separate elements with a space. + buf.WriteByte(' ') + if f.Sharp { + // Add leading 0x or 0X for each element. + buf.WriteByte('0') + buf.WriteByte(digits[16]) + } + } + if b != nil { + c = b[i] // Take a byte from the input byte slice. + } else { + c = s[i] // Take a byte from the input string. + } + // Encode each byte as two hexadecimal digits. + buf.WriteByte(digits[c>>4]) + buf.WriteByte(digits[c&0xF]) + } + // Handle padding to the right. + if f.WidthPresent && f.Width > width && f.Minus { + f.writePadding(f.Width - width) + } +} + +// fmt_sx formats a string as a hexadecimal encoding of its bytes. +func (f *formatInfo) fmt_sx(s, digits string) { + f.fmt_sbx(s, nil, digits) +} + +// fmt_bx formats a byte slice as a hexadecimal encoding of its bytes. +func (f *formatInfo) fmt_bx(b []byte, digits string) { + f.fmt_sbx("", b, digits) +} + +// fmt_q formats a string as a double-quoted, escaped Go string constant. +// If f.sharp is set a raw (backquoted) string may be returned instead +// if the string does not contain any control characters other than tab. +func (f *formatInfo) fmt_q(s string) { + s = f.truncate(s) + if f.Sharp && strconv.CanBackquote(s) { + f.padString("`" + s + "`") + return + } + buf := f.intbuf[:0] + if f.Plus { + f.pad(strconv.AppendQuoteToASCII(buf, s)) + } else { + f.pad(strconv.AppendQuote(buf, s)) + } +} + +// fmt_c formats an integer as a Unicode character. +// If the character is not valid Unicode, it will print '\ufffd'. +func (f *formatInfo) fmt_c(c uint64) { + r := rune(c) + if c > utf8.MaxRune { + r = utf8.RuneError + } + buf := f.intbuf[:0] + w := utf8.EncodeRune(buf[:utf8.UTFMax], r) + f.pad(buf[:w]) +} + +// fmt_qc formats an integer as a single-quoted, escaped Go character constant. +// If the character is not valid Unicode, it will print '\ufffd'. +func (f *formatInfo) fmt_qc(c uint64) { + r := rune(c) + if c > utf8.MaxRune { + r = utf8.RuneError + } + buf := f.intbuf[:0] + if f.Plus { + f.pad(strconv.AppendQuoteRuneToASCII(buf, r)) + } else { + f.pad(strconv.AppendQuoteRune(buf, r)) + } +} + +// fmt_float formats a float64. It assumes that verb is a valid format specifier +// for strconv.AppendFloat and therefore fits into a byte. +func (f *formatInfo) fmt_float(v float64, size int, verb rune, prec int) { + // Explicit precision in format specifier overrules default precision. + if f.PrecPresent { + prec = f.Prec + } + // Format number, reserving space for leading + sign if needed. + num := strconv.AppendFloat(f.intbuf[:1], v, byte(verb), prec, size) + if num[1] == '-' || num[1] == '+' { + num = num[1:] + } else { + num[0] = '+' + } + // f.space means to add a leading space instead of a "+" sign unless + // the sign is explicitly asked for by f.plus. + if f.Space && num[0] == '+' && !f.Plus { + num[0] = ' ' + } + // Special handling for infinities and NaN, + // which don't look like a number so shouldn't be padded with zeros. + if num[1] == 'I' || num[1] == 'N' { + oldZero := f.Zero + f.Zero = false + // Remove sign before NaN if not asked for. + if num[1] == 'N' && !f.Space && !f.Plus { + num = num[1:] + } + f.pad(num) + f.Zero = oldZero + return + } + // The sharp flag forces printing a decimal point for non-binary formats + // and retains trailing zeros, which we may need to restore. + if f.Sharp && verb != 'b' { + digits := 0 + switch verb { + case 'v', 'g', 'G': + digits = prec + // If no precision is set explicitly use a precision of 6. + if digits == -1 { + digits = 6 + } + } + + // Buffer pre-allocated with enough room for + // exponent notations of the form "e+123". + var tailBuf [5]byte + tail := tailBuf[:0] + + hasDecimalPoint := false + // Starting from i = 1 to skip sign at num[0]. + for i := 1; i < len(num); i++ { + switch num[i] { + case '.': + hasDecimalPoint = true + case 'e', 'E': + tail = append(tail, num[i:]...) + num = num[:i] + default: + digits-- + } + } + if !hasDecimalPoint { + num = append(num, '.') + } + for digits > 0 { + num = append(num, '0') + digits-- + } + num = append(num, tail...) + } + // We want a sign if asked for and if the sign is not positive. + if f.Plus || num[0] != '+' { + // If we're zero padding to the left we want the sign before the leading zeros. + // Achieve this by writing the sign out and then padding the unsigned number. + if f.Zero && f.WidthPresent && f.Width > len(num) { + f.buf.WriteByte(num[0]) + f.writePadding(f.Width - len(num)) + f.buf.Write(num[1:]) + return + } + f.pad(num) + return + } + // No sign to show and the number is positive; just print the unsigned number. + f.pad(num[1:]) +} diff --git a/vendor/golang.org/x/text/message/message.go b/vendor/golang.org/x/text/message/message.go new file mode 100644 index 00000000..91a97264 --- /dev/null +++ b/vendor/golang.org/x/text/message/message.go @@ -0,0 +1,192 @@ +// Copyright 2015 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package message // import "golang.org/x/text/message" + +import ( + "io" + "os" + + // Include features to facilitate generated catalogs. + _ "golang.org/x/text/feature/plural" + + "golang.org/x/text/internal/number" + "golang.org/x/text/language" + "golang.org/x/text/message/catalog" +) + +// A Printer implements language-specific formatted I/O analogous to the fmt +// package. +type Printer struct { + // the language + tag language.Tag + + toDecimal number.Formatter + toScientific number.Formatter + + cat catalog.Catalog +} + +type options struct { + cat catalog.Catalog + // TODO: + // - allow %s to print integers in written form (tables are likely too large + // to enable this by default). + // - list behavior + // +} + +// An Option defines an option of a Printer. +type Option func(o *options) + +// Catalog defines the catalog to be used. +func Catalog(c catalog.Catalog) Option { + return func(o *options) { o.cat = c } +} + +// NewPrinter returns a Printer that formats messages tailored to language t. +func NewPrinter(t language.Tag, opts ...Option) *Printer { + options := &options{ + cat: DefaultCatalog, + } + for _, o := range opts { + o(options) + } + p := &Printer{ + tag: t, + cat: options.cat, + } + p.toDecimal.InitDecimal(t) + p.toScientific.InitScientific(t) + return p +} + +// Sprint is like fmt.Sprint, but using language-specific formatting. +func (p *Printer) Sprint(a ...interface{}) string { + pp := newPrinter(p) + pp.doPrint(a) + s := pp.String() + pp.free() + return s +} + +// Fprint is like fmt.Fprint, but using language-specific formatting. +func (p *Printer) Fprint(w io.Writer, a ...interface{}) (n int, err error) { + pp := newPrinter(p) + pp.doPrint(a) + n64, err := io.Copy(w, &pp.Buffer) + pp.free() + return int(n64), err +} + +// Print is like fmt.Print, but using language-specific formatting. +func (p *Printer) Print(a ...interface{}) (n int, err error) { + return p.Fprint(os.Stdout, a...) +} + +// Sprintln is like fmt.Sprintln, but using language-specific formatting. +func (p *Printer) Sprintln(a ...interface{}) string { + pp := newPrinter(p) + pp.doPrintln(a) + s := pp.String() + pp.free() + return s +} + +// Fprintln is like fmt.Fprintln, but using language-specific formatting. +func (p *Printer) Fprintln(w io.Writer, a ...interface{}) (n int, err error) { + pp := newPrinter(p) + pp.doPrintln(a) + n64, err := io.Copy(w, &pp.Buffer) + pp.free() + return int(n64), err +} + +// Println is like fmt.Println, but using language-specific formatting. +func (p *Printer) Println(a ...interface{}) (n int, err error) { + return p.Fprintln(os.Stdout, a...) +} + +// Sprintf is like fmt.Sprintf, but using language-specific formatting. +func (p *Printer) Sprintf(key Reference, a ...interface{}) string { + pp := newPrinter(p) + lookupAndFormat(pp, key, a) + s := pp.String() + pp.free() + return s +} + +// Fprintf is like fmt.Fprintf, but using language-specific formatting. +func (p *Printer) Fprintf(w io.Writer, key Reference, a ...interface{}) (n int, err error) { + pp := newPrinter(p) + lookupAndFormat(pp, key, a) + n, err = w.Write(pp.Bytes()) + pp.free() + return n, err + +} + +// Printf is like fmt.Printf, but using language-specific formatting. +func (p *Printer) Printf(key Reference, a ...interface{}) (n int, err error) { + pp := newPrinter(p) + lookupAndFormat(pp, key, a) + n, err = os.Stdout.Write(pp.Bytes()) + pp.free() + return n, err +} + +func lookupAndFormat(p *printer, r Reference, a []interface{}) { + p.fmt.Reset(a) + switch v := r.(type) { + case string: + if p.catContext.Execute(v) == catalog.ErrNotFound { + p.Render(v) + return + } + case key: + if p.catContext.Execute(v.id) == catalog.ErrNotFound && + p.catContext.Execute(v.fallback) == catalog.ErrNotFound { + p.Render(v.fallback) + return + } + default: + panic("key argument is not a Reference") + } +} + +type rawPrinter struct { + p *printer +} + +func (p rawPrinter) Render(msg string) { p.p.WriteString(msg) } +func (p rawPrinter) Arg(i int) interface{} { return nil } + +// Arg implements catmsg.Renderer. +func (p *printer) Arg(i int) interface{} { // TODO, also return "ok" bool + i-- + if uint(i) < uint(len(p.fmt.Args)) { + return p.fmt.Args[i] + } + return nil +} + +// Render implements catmsg.Renderer. +func (p *printer) Render(msg string) { + p.doPrintf(msg) +} + +// A Reference is a string or a message reference. +type Reference interface { + // TODO: also allow []string +} + +// Key creates a message Reference for a message where the given id is used for +// message lookup and the fallback is returned when no matches are found. +func Key(id string, fallback string) Reference { + return key{id, fallback} +} + +type key struct { + id, fallback string +} diff --git a/vendor/golang.org/x/text/message/print.go b/vendor/golang.org/x/text/message/print.go new file mode 100644 index 00000000..da304cc0 --- /dev/null +++ b/vendor/golang.org/x/text/message/print.go @@ -0,0 +1,984 @@ +// Copyright 2017 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package message + +import ( + "bytes" + "fmt" // TODO: consider copying interfaces from package fmt to avoid dependency. + "math" + "reflect" + "sync" + "unicode/utf8" + + "golang.org/x/text/internal/format" + "golang.org/x/text/internal/number" + "golang.org/x/text/language" + "golang.org/x/text/message/catalog" +) + +// Strings for use with buffer.WriteString. +// This is less overhead than using buffer.Write with byte arrays. +const ( + commaSpaceString = ", " + nilAngleString = "" + nilParenString = "(nil)" + nilString = "nil" + mapString = "map[" + percentBangString = "%!" + missingString = "(MISSING)" + badIndexString = "(BADINDEX)" + panicString = "(PANIC=" + extraString = "%!(EXTRA " + badWidthString = "%!(BADWIDTH)" + badPrecString = "%!(BADPREC)" + noVerbString = "%!(NOVERB)" + + invReflectString = "" +) + +var printerPool = sync.Pool{ + New: func() interface{} { return new(printer) }, +} + +// newPrinter allocates a new printer struct or grabs a cached one. +func newPrinter(pp *Printer) *printer { + p := printerPool.Get().(*printer) + p.Printer = *pp + // TODO: cache most of the following call. + p.catContext = pp.cat.Context(pp.tag, p) + + p.panicking = false + p.erroring = false + p.fmt.init(&p.Buffer) + return p +} + +// free saves used printer structs in printerFree; avoids an allocation per invocation. +func (p *printer) free() { + p.Buffer.Reset() + p.arg = nil + p.value = reflect.Value{} + printerPool.Put(p) +} + +// printer is used to store a printer's state. +// It implements "golang.org/x/text/internal/format".State. +type printer struct { + Printer + + // the context for looking up message translations + catContext *catalog.Context + + // buffer for accumulating output. + bytes.Buffer + + // arg holds the current item, as an interface{}. + arg interface{} + // value is used instead of arg for reflect values. + value reflect.Value + + // fmt is used to format basic items such as integers or strings. + fmt formatInfo + + // panicking is set by catchPanic to avoid infinite panic, recover, panic, ... recursion. + panicking bool + // erroring is set when printing an error string to guard against calling handleMethods. + erroring bool +} + +// Language implements "golang.org/x/text/internal/format".State. +func (p *printer) Language() language.Tag { return p.tag } + +func (p *printer) Width() (wid int, ok bool) { return p.fmt.Width, p.fmt.WidthPresent } + +func (p *printer) Precision() (prec int, ok bool) { return p.fmt.Prec, p.fmt.PrecPresent } + +func (p *printer) Flag(b int) bool { + switch b { + case '-': + return p.fmt.Minus + case '+': + return p.fmt.Plus || p.fmt.PlusV + case '#': + return p.fmt.Sharp || p.fmt.SharpV + case ' ': + return p.fmt.Space + case '0': + return p.fmt.Zero + } + return false +} + +// getField gets the i'th field of the struct value. +// If the field is itself is an interface, return a value for +// the thing inside the interface, not the interface itself. +func getField(v reflect.Value, i int) reflect.Value { + val := v.Field(i) + if val.Kind() == reflect.Interface && !val.IsNil() { + val = val.Elem() + } + return val +} + +func (p *printer) unknownType(v reflect.Value) { + if !v.IsValid() { + p.WriteString(nilAngleString) + return + } + p.WriteByte('?') + p.WriteString(v.Type().String()) + p.WriteByte('?') +} + +func (p *printer) badVerb(verb rune) { + p.erroring = true + p.WriteString(percentBangString) + p.WriteRune(verb) + p.WriteByte('(') + switch { + case p.arg != nil: + p.WriteString(reflect.TypeOf(p.arg).String()) + p.WriteByte('=') + p.printArg(p.arg, 'v') + case p.value.IsValid(): + p.WriteString(p.value.Type().String()) + p.WriteByte('=') + p.printValue(p.value, 'v', 0) + default: + p.WriteString(nilAngleString) + } + p.WriteByte(')') + p.erroring = false +} + +func (p *printer) fmtBool(v bool, verb rune) { + switch verb { + case 't', 'v': + p.fmt.fmt_boolean(v) + default: + p.badVerb(verb) + } +} + +// fmt0x64 formats a uint64 in hexadecimal and prefixes it with 0x or +// not, as requested, by temporarily setting the sharp flag. +func (p *printer) fmt0x64(v uint64, leading0x bool) { + sharp := p.fmt.Sharp + p.fmt.Sharp = leading0x + p.fmt.fmt_integer(v, 16, unsigned, ldigits) + p.fmt.Sharp = sharp +} + +// fmtInteger formats a signed or unsigned integer. +func (p *printer) fmtInteger(v uint64, isSigned bool, verb rune) { + switch verb { + case 'v': + if p.fmt.SharpV && !isSigned { + p.fmt0x64(v, true) + return + } + fallthrough + case 'd': + if p.fmt.Sharp || p.fmt.SharpV { + p.fmt.fmt_integer(v, 10, isSigned, ldigits) + } else { + p.fmtDecimalInt(v, isSigned) + } + case 'b': + p.fmt.fmt_integer(v, 2, isSigned, ldigits) + case 'o': + p.fmt.fmt_integer(v, 8, isSigned, ldigits) + case 'x': + p.fmt.fmt_integer(v, 16, isSigned, ldigits) + case 'X': + p.fmt.fmt_integer(v, 16, isSigned, udigits) + case 'c': + p.fmt.fmt_c(v) + case 'q': + if v <= utf8.MaxRune { + p.fmt.fmt_qc(v) + } else { + p.badVerb(verb) + } + case 'U': + p.fmt.fmt_unicode(v) + default: + p.badVerb(verb) + } +} + +// fmtFloat formats a float. The default precision for each verb +// is specified as last argument in the call to fmt_float. +func (p *printer) fmtFloat(v float64, size int, verb rune) { + switch verb { + case 'b': + p.fmt.fmt_float(v, size, verb, -1) + case 'v': + verb = 'g' + fallthrough + case 'g', 'G': + if p.fmt.Sharp || p.fmt.SharpV { + p.fmt.fmt_float(v, size, verb, -1) + } else { + p.fmtVariableFloat(v, size) + } + case 'e', 'E': + if p.fmt.Sharp || p.fmt.SharpV { + p.fmt.fmt_float(v, size, verb, 6) + } else { + p.fmtScientific(v, size, 6) + } + case 'f', 'F': + if p.fmt.Sharp || p.fmt.SharpV { + p.fmt.fmt_float(v, size, verb, 6) + } else { + p.fmtDecimalFloat(v, size, 6) + } + default: + p.badVerb(verb) + } +} + +func (p *printer) setFlags(f *number.Formatter) { + f.Flags &^= number.ElideSign + if p.fmt.Plus || p.fmt.Space { + f.Flags |= number.AlwaysSign + if !p.fmt.Plus { + f.Flags |= number.ElideSign + } + } else { + f.Flags &^= number.AlwaysSign + } +} + +func (p *printer) updatePadding(f *number.Formatter) { + f.Flags &^= number.PadMask + if p.fmt.Minus { + f.Flags |= number.PadAfterSuffix + } else { + f.Flags |= number.PadBeforePrefix + } + f.PadRune = ' ' + f.FormatWidth = uint16(p.fmt.Width) +} + +func (p *printer) initDecimal(minFrac, maxFrac int) { + f := &p.toDecimal + f.MinIntegerDigits = 1 + f.MaxIntegerDigits = 0 + f.MinFractionDigits = uint8(minFrac) + f.MaxFractionDigits = int16(maxFrac) + p.setFlags(f) + f.PadRune = 0 + if p.fmt.WidthPresent { + if p.fmt.Zero { + wid := p.fmt.Width + // Use significant integers for this. + // TODO: this is not the same as width, but so be it. + if f.MinFractionDigits > 0 { + wid -= 1 + int(f.MinFractionDigits) + } + if p.fmt.Plus || p.fmt.Space { + wid-- + } + if wid > 0 && wid > int(f.MinIntegerDigits) { + f.MinIntegerDigits = uint8(wid) + } + } + p.updatePadding(f) + } +} + +func (p *printer) initScientific(minFrac, maxFrac int) { + f := &p.toScientific + if maxFrac < 0 { + f.SetPrecision(maxFrac) + } else { + f.SetPrecision(maxFrac + 1) + f.MinFractionDigits = uint8(minFrac) + f.MaxFractionDigits = int16(maxFrac) + } + f.MinExponentDigits = 2 + p.setFlags(f) + f.PadRune = 0 + if p.fmt.WidthPresent { + f.Flags &^= number.PadMask + if p.fmt.Zero { + f.PadRune = f.Digit(0) + f.Flags |= number.PadAfterPrefix + } else { + f.PadRune = ' ' + f.Flags |= number.PadBeforePrefix + } + p.updatePadding(f) + } +} + +func (p *printer) fmtDecimalInt(v uint64, isSigned bool) { + var d number.Decimal + + f := &p.toDecimal + if p.fmt.PrecPresent { + p.setFlags(f) + f.MinIntegerDigits = uint8(p.fmt.Prec) + f.MaxIntegerDigits = 0 + f.MinFractionDigits = 0 + f.MaxFractionDigits = 0 + if p.fmt.WidthPresent { + p.updatePadding(f) + } + } else { + p.initDecimal(0, 0) + } + d.ConvertInt(p.toDecimal.RoundingContext, isSigned, v) + + out := p.toDecimal.Format([]byte(nil), &d) + p.Buffer.Write(out) +} + +func (p *printer) fmtDecimalFloat(v float64, size, prec int) { + var d number.Decimal + if p.fmt.PrecPresent { + prec = p.fmt.Prec + } + p.initDecimal(prec, prec) + d.ConvertFloat(p.toDecimal.RoundingContext, v, size) + + out := p.toDecimal.Format([]byte(nil), &d) + p.Buffer.Write(out) +} + +func (p *printer) fmtVariableFloat(v float64, size int) { + prec := -1 + if p.fmt.PrecPresent { + prec = p.fmt.Prec + } + var d number.Decimal + p.initScientific(0, prec) + d.ConvertFloat(p.toScientific.RoundingContext, v, size) + + // Copy logic of 'g' formatting from strconv. It is simplified a bit as + // we don't have to mind having prec > len(d.Digits). + shortest := prec < 0 + ePrec := prec + if shortest { + prec = len(d.Digits) + ePrec = 6 + } else if prec == 0 { + prec = 1 + ePrec = 1 + } + exp := int(d.Exp) - 1 + if exp < -4 || exp >= ePrec { + p.initScientific(0, prec) + + out := p.toScientific.Format([]byte(nil), &d) + p.Buffer.Write(out) + } else { + if prec > int(d.Exp) { + prec = len(d.Digits) + } + if prec -= int(d.Exp); prec < 0 { + prec = 0 + } + p.initDecimal(0, prec) + + out := p.toDecimal.Format([]byte(nil), &d) + p.Buffer.Write(out) + } +} + +func (p *printer) fmtScientific(v float64, size, prec int) { + var d number.Decimal + if p.fmt.PrecPresent { + prec = p.fmt.Prec + } + p.initScientific(prec, prec) + rc := p.toScientific.RoundingContext + d.ConvertFloat(rc, v, size) + + out := p.toScientific.Format([]byte(nil), &d) + p.Buffer.Write(out) + +} + +// fmtComplex formats a complex number v with +// r = real(v) and j = imag(v) as (r+ji) using +// fmtFloat for r and j formatting. +func (p *printer) fmtComplex(v complex128, size int, verb rune) { + // Make sure any unsupported verbs are found before the + // calls to fmtFloat to not generate an incorrect error string. + switch verb { + case 'v', 'b', 'g', 'G', 'f', 'F', 'e', 'E': + p.WriteByte('(') + p.fmtFloat(real(v), size/2, verb) + // Imaginary part always has a sign. + if math.IsNaN(imag(v)) { + // By CLDR's rules, NaNs do not use patterns or signs. As this code + // relies on AlwaysSign working for imaginary parts, we need to + // manually handle NaNs. + f := &p.toScientific + p.setFlags(f) + p.updatePadding(f) + p.setFlags(f) + nan := f.Symbol(number.SymNan) + extra := 0 + if w, ok := p.Width(); ok { + extra = w - utf8.RuneCountInString(nan) - 1 + } + if f.Flags&number.PadAfterNumber == 0 { + for ; extra > 0; extra-- { + p.WriteRune(f.PadRune) + } + } + p.WriteString(f.Symbol(number.SymPlusSign)) + p.WriteString(nan) + for ; extra > 0; extra-- { + p.WriteRune(f.PadRune) + } + p.WriteString("i)") + return + } + oldPlus := p.fmt.Plus + p.fmt.Plus = true + p.fmtFloat(imag(v), size/2, verb) + p.WriteString("i)") // TODO: use symbol? + p.fmt.Plus = oldPlus + default: + p.badVerb(verb) + } +} + +func (p *printer) fmtString(v string, verb rune) { + switch verb { + case 'v': + if p.fmt.SharpV { + p.fmt.fmt_q(v) + } else { + p.fmt.fmt_s(v) + } + case 's': + p.fmt.fmt_s(v) + case 'x': + p.fmt.fmt_sx(v, ldigits) + case 'X': + p.fmt.fmt_sx(v, udigits) + case 'q': + p.fmt.fmt_q(v) + case 'm': + ctx := p.cat.Context(p.tag, rawPrinter{p}) + if ctx.Execute(v) == catalog.ErrNotFound { + p.WriteString(v) + } + default: + p.badVerb(verb) + } +} + +func (p *printer) fmtBytes(v []byte, verb rune, typeString string) { + switch verb { + case 'v', 'd': + if p.fmt.SharpV { + p.WriteString(typeString) + if v == nil { + p.WriteString(nilParenString) + return + } + p.WriteByte('{') + for i, c := range v { + if i > 0 { + p.WriteString(commaSpaceString) + } + p.fmt0x64(uint64(c), true) + } + p.WriteByte('}') + } else { + p.WriteByte('[') + for i, c := range v { + if i > 0 { + p.WriteByte(' ') + } + p.fmt.fmt_integer(uint64(c), 10, unsigned, ldigits) + } + p.WriteByte(']') + } + case 's': + p.fmt.fmt_s(string(v)) + case 'x': + p.fmt.fmt_bx(v, ldigits) + case 'X': + p.fmt.fmt_bx(v, udigits) + case 'q': + p.fmt.fmt_q(string(v)) + default: + p.printValue(reflect.ValueOf(v), verb, 0) + } +} + +func (p *printer) fmtPointer(value reflect.Value, verb rune) { + var u uintptr + switch value.Kind() { + case reflect.Chan, reflect.Func, reflect.Map, reflect.Ptr, reflect.Slice, reflect.UnsafePointer: + u = value.Pointer() + default: + p.badVerb(verb) + return + } + + switch verb { + case 'v': + if p.fmt.SharpV { + p.WriteByte('(') + p.WriteString(value.Type().String()) + p.WriteString(")(") + if u == 0 { + p.WriteString(nilString) + } else { + p.fmt0x64(uint64(u), true) + } + p.WriteByte(')') + } else { + if u == 0 { + p.fmt.padString(nilAngleString) + } else { + p.fmt0x64(uint64(u), !p.fmt.Sharp) + } + } + case 'p': + p.fmt0x64(uint64(u), !p.fmt.Sharp) + case 'b', 'o', 'd', 'x', 'X': + if verb == 'd' { + p.fmt.Sharp = true // Print as standard go. TODO: does this make sense? + } + p.fmtInteger(uint64(u), unsigned, verb) + default: + p.badVerb(verb) + } +} + +func (p *printer) catchPanic(arg interface{}, verb rune) { + if err := recover(); err != nil { + // If it's a nil pointer, just say "". The likeliest causes are a + // Stringer that fails to guard against nil or a nil pointer for a + // value receiver, and in either case, "" is a nice result. + if v := reflect.ValueOf(arg); v.Kind() == reflect.Ptr && v.IsNil() { + p.WriteString(nilAngleString) + return + } + // Otherwise print a concise panic message. Most of the time the panic + // value will print itself nicely. + if p.panicking { + // Nested panics; the recursion in printArg cannot succeed. + panic(err) + } + + oldFlags := p.fmt.Parser + // For this output we want default behavior. + p.fmt.ClearFlags() + + p.WriteString(percentBangString) + p.WriteRune(verb) + p.WriteString(panicString) + p.panicking = true + p.printArg(err, 'v') + p.panicking = false + p.WriteByte(')') + + p.fmt.Parser = oldFlags + } +} + +func (p *printer) handleMethods(verb rune) (handled bool) { + if p.erroring { + return + } + // Is it a Formatter? + if formatter, ok := p.arg.(format.Formatter); ok { + handled = true + defer p.catchPanic(p.arg, verb) + formatter.Format(p, verb) + return + } + if formatter, ok := p.arg.(fmt.Formatter); ok { + handled = true + defer p.catchPanic(p.arg, verb) + formatter.Format(p, verb) + return + } + + // If we're doing Go syntax and the argument knows how to supply it, take care of it now. + if p.fmt.SharpV { + if stringer, ok := p.arg.(fmt.GoStringer); ok { + handled = true + defer p.catchPanic(p.arg, verb) + // Print the result of GoString unadorned. + p.fmt.fmt_s(stringer.GoString()) + return + } + } else { + // If a string is acceptable according to the format, see if + // the value satisfies one of the string-valued interfaces. + // Println etc. set verb to %v, which is "stringable". + switch verb { + case 'v', 's', 'x', 'X', 'q': + // Is it an error or Stringer? + // The duplication in the bodies is necessary: + // setting handled and deferring catchPanic + // must happen before calling the method. + switch v := p.arg.(type) { + case error: + handled = true + defer p.catchPanic(p.arg, verb) + p.fmtString(v.Error(), verb) + return + + case fmt.Stringer: + handled = true + defer p.catchPanic(p.arg, verb) + p.fmtString(v.String(), verb) + return + } + } + } + return false +} + +func (p *printer) printArg(arg interface{}, verb rune) { + p.arg = arg + p.value = reflect.Value{} + + if arg == nil { + switch verb { + case 'T', 'v': + p.fmt.padString(nilAngleString) + default: + p.badVerb(verb) + } + return + } + + // Special processing considerations. + // %T (the value's type) and %p (its address) are special; we always do them first. + switch verb { + case 'T': + p.fmt.fmt_s(reflect.TypeOf(arg).String()) + return + case 'p': + p.fmtPointer(reflect.ValueOf(arg), 'p') + return + } + + // Some types can be done without reflection. + switch f := arg.(type) { + case bool: + p.fmtBool(f, verb) + case float32: + p.fmtFloat(float64(f), 32, verb) + case float64: + p.fmtFloat(f, 64, verb) + case complex64: + p.fmtComplex(complex128(f), 64, verb) + case complex128: + p.fmtComplex(f, 128, verb) + case int: + p.fmtInteger(uint64(f), signed, verb) + case int8: + p.fmtInteger(uint64(f), signed, verb) + case int16: + p.fmtInteger(uint64(f), signed, verb) + case int32: + p.fmtInteger(uint64(f), signed, verb) + case int64: + p.fmtInteger(uint64(f), signed, verb) + case uint: + p.fmtInteger(uint64(f), unsigned, verb) + case uint8: + p.fmtInteger(uint64(f), unsigned, verb) + case uint16: + p.fmtInteger(uint64(f), unsigned, verb) + case uint32: + p.fmtInteger(uint64(f), unsigned, verb) + case uint64: + p.fmtInteger(f, unsigned, verb) + case uintptr: + p.fmtInteger(uint64(f), unsigned, verb) + case string: + p.fmtString(f, verb) + case []byte: + p.fmtBytes(f, verb, "[]byte") + case reflect.Value: + // Handle extractable values with special methods + // since printValue does not handle them at depth 0. + if f.IsValid() && f.CanInterface() { + p.arg = f.Interface() + if p.handleMethods(verb) { + return + } + } + p.printValue(f, verb, 0) + default: + // If the type is not simple, it might have methods. + if !p.handleMethods(verb) { + // Need to use reflection, since the type had no + // interface methods that could be used for formatting. + p.printValue(reflect.ValueOf(f), verb, 0) + } + } +} + +// printValue is similar to printArg but starts with a reflect value, not an interface{} value. +// It does not handle 'p' and 'T' verbs because these should have been already handled by printArg. +func (p *printer) printValue(value reflect.Value, verb rune, depth int) { + // Handle values with special methods if not already handled by printArg (depth == 0). + if depth > 0 && value.IsValid() && value.CanInterface() { + p.arg = value.Interface() + if p.handleMethods(verb) { + return + } + } + p.arg = nil + p.value = value + + switch f := value; value.Kind() { + case reflect.Invalid: + if depth == 0 { + p.WriteString(invReflectString) + } else { + switch verb { + case 'v': + p.WriteString(nilAngleString) + default: + p.badVerb(verb) + } + } + case reflect.Bool: + p.fmtBool(f.Bool(), verb) + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: + p.fmtInteger(uint64(f.Int()), signed, verb) + case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64, reflect.Uintptr: + p.fmtInteger(f.Uint(), unsigned, verb) + case reflect.Float32: + p.fmtFloat(f.Float(), 32, verb) + case reflect.Float64: + p.fmtFloat(f.Float(), 64, verb) + case reflect.Complex64: + p.fmtComplex(f.Complex(), 64, verb) + case reflect.Complex128: + p.fmtComplex(f.Complex(), 128, verb) + case reflect.String: + p.fmtString(f.String(), verb) + case reflect.Map: + if p.fmt.SharpV { + p.WriteString(f.Type().String()) + if f.IsNil() { + p.WriteString(nilParenString) + return + } + p.WriteByte('{') + } else { + p.WriteString(mapString) + } + keys := f.MapKeys() + for i, key := range keys { + if i > 0 { + if p.fmt.SharpV { + p.WriteString(commaSpaceString) + } else { + p.WriteByte(' ') + } + } + p.printValue(key, verb, depth+1) + p.WriteByte(':') + p.printValue(f.MapIndex(key), verb, depth+1) + } + if p.fmt.SharpV { + p.WriteByte('}') + } else { + p.WriteByte(']') + } + case reflect.Struct: + if p.fmt.SharpV { + p.WriteString(f.Type().String()) + } + p.WriteByte('{') + for i := 0; i < f.NumField(); i++ { + if i > 0 { + if p.fmt.SharpV { + p.WriteString(commaSpaceString) + } else { + p.WriteByte(' ') + } + } + if p.fmt.PlusV || p.fmt.SharpV { + if name := f.Type().Field(i).Name; name != "" { + p.WriteString(name) + p.WriteByte(':') + } + } + p.printValue(getField(f, i), verb, depth+1) + } + p.WriteByte('}') + case reflect.Interface: + value := f.Elem() + if !value.IsValid() { + if p.fmt.SharpV { + p.WriteString(f.Type().String()) + p.WriteString(nilParenString) + } else { + p.WriteString(nilAngleString) + } + } else { + p.printValue(value, verb, depth+1) + } + case reflect.Array, reflect.Slice: + switch verb { + case 's', 'q', 'x', 'X': + // Handle byte and uint8 slices and arrays special for the above verbs. + t := f.Type() + if t.Elem().Kind() == reflect.Uint8 { + var bytes []byte + if f.Kind() == reflect.Slice { + bytes = f.Bytes() + } else if f.CanAddr() { + bytes = f.Slice(0, f.Len()).Bytes() + } else { + // We have an array, but we cannot Slice() a non-addressable array, + // so we build a slice by hand. This is a rare case but it would be nice + // if reflection could help a little more. + bytes = make([]byte, f.Len()) + for i := range bytes { + bytes[i] = byte(f.Index(i).Uint()) + } + } + p.fmtBytes(bytes, verb, t.String()) + return + } + } + if p.fmt.SharpV { + p.WriteString(f.Type().String()) + if f.Kind() == reflect.Slice && f.IsNil() { + p.WriteString(nilParenString) + return + } + p.WriteByte('{') + for i := 0; i < f.Len(); i++ { + if i > 0 { + p.WriteString(commaSpaceString) + } + p.printValue(f.Index(i), verb, depth+1) + } + p.WriteByte('}') + } else { + p.WriteByte('[') + for i := 0; i < f.Len(); i++ { + if i > 0 { + p.WriteByte(' ') + } + p.printValue(f.Index(i), verb, depth+1) + } + p.WriteByte(']') + } + case reflect.Ptr: + // pointer to array or slice or struct? ok at top level + // but not embedded (avoid loops) + if depth == 0 && f.Pointer() != 0 { + switch a := f.Elem(); a.Kind() { + case reflect.Array, reflect.Slice, reflect.Struct, reflect.Map: + p.WriteByte('&') + p.printValue(a, verb, depth+1) + return + } + } + fallthrough + case reflect.Chan, reflect.Func, reflect.UnsafePointer: + p.fmtPointer(f, verb) + default: + p.unknownType(f) + } +} + +func (p *printer) badArgNum(verb rune) { + p.WriteString(percentBangString) + p.WriteRune(verb) + p.WriteString(badIndexString) +} + +func (p *printer) missingArg(verb rune) { + p.WriteString(percentBangString) + p.WriteRune(verb) + p.WriteString(missingString) +} + +func (p *printer) doPrintf(fmt string) { + for p.fmt.Parser.SetFormat(fmt); p.fmt.Scan(); { + switch p.fmt.Status { + case format.StatusText: + p.WriteString(p.fmt.Text()) + case format.StatusSubstitution: + p.printArg(p.Arg(p.fmt.ArgNum), p.fmt.Verb) + case format.StatusBadWidthSubstitution: + p.WriteString(badWidthString) + p.printArg(p.Arg(p.fmt.ArgNum), p.fmt.Verb) + case format.StatusBadPrecSubstitution: + p.WriteString(badPrecString) + p.printArg(p.Arg(p.fmt.ArgNum), p.fmt.Verb) + case format.StatusNoVerb: + p.WriteString(noVerbString) + case format.StatusBadArgNum: + p.badArgNum(p.fmt.Verb) + case format.StatusMissingArg: + p.missingArg(p.fmt.Verb) + default: + panic("unreachable") + } + } + + // Check for extra arguments, but only if there was at least one ordered + // argument. Note that this behavior is necessarily different from fmt: + // different variants of messages may opt to drop some or all of the + // arguments. + if !p.fmt.Reordered && p.fmt.ArgNum < len(p.fmt.Args) && p.fmt.ArgNum != 0 { + p.fmt.ClearFlags() + p.WriteString(extraString) + for i, arg := range p.fmt.Args[p.fmt.ArgNum:] { + if i > 0 { + p.WriteString(commaSpaceString) + } + if arg == nil { + p.WriteString(nilAngleString) + } else { + p.WriteString(reflect.TypeOf(arg).String()) + p.WriteString("=") + p.printArg(arg, 'v') + } + } + p.WriteByte(')') + } +} + +func (p *printer) doPrint(a []interface{}) { + prevString := false + for argNum, arg := range a { + isString := arg != nil && reflect.TypeOf(arg).Kind() == reflect.String + // Add a space between two non-string arguments. + if argNum > 0 && !isString && !prevString { + p.WriteByte(' ') + } + p.printArg(arg, 'v') + prevString = isString + } +} + +// doPrintln is like doPrint but always adds a space between arguments +// and a newline after the last argument. +func (p *printer) doPrintln(a []interface{}) { + for argNum, arg := range a { + if argNum > 0 { + p.WriteByte(' ') + } + p.printArg(arg, 'v') + } + p.WriteByte('\n') +} diff --git a/vendor/modules.txt b/vendor/modules.txt index a25200a9..a7b97898 100644 --- a/vendor/modules.txt +++ b/vendor/modules.txt @@ -655,6 +655,10 @@ github.com/sagikazarmark/slog-shim # github.com/saintfish/chardet v0.0.0-20230101081208-5e3ef4b5456d ## explicit github.com/saintfish/chardet +# github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 +## explicit; go 1.21 +github.com/santhosh-tekuri/jsonschema/v6 +github.com/santhosh-tekuri/jsonschema/v6/kind # github.com/segmentio/asm v1.2.0 ## explicit; go 1.18 github.com/segmentio/asm/base64 @@ -911,12 +915,19 @@ golang.org/x/text/encoding/charmap golang.org/x/text/encoding/internal golang.org/x/text/encoding/internal/identifier golang.org/x/text/encoding/unicode +golang.org/x/text/feature/plural golang.org/x/text/internal +golang.org/x/text/internal/catmsg +golang.org/x/text/internal/format golang.org/x/text/internal/language golang.org/x/text/internal/language/compact +golang.org/x/text/internal/number +golang.org/x/text/internal/stringset golang.org/x/text/internal/tag golang.org/x/text/internal/utf8internal golang.org/x/text/language +golang.org/x/text/message +golang.org/x/text/message/catalog golang.org/x/text/runes golang.org/x/text/secure/bidirule golang.org/x/text/secure/precis