From cd15506732cc7db9668cdd55eafa06b11734a890 Mon Sep 17 00:00:00 2001 From: Miley Chandonnet Date: Sat, 1 Aug 2026 11:04:46 -0500 Subject: [PATCH] AMPR-263 #664: Add TableWriteIntent core primitives and PlugManifest capability gating Written by Claude, following the AMPR-263 write-back verdict (append-row and update-cell as provider-delegated intents, no whole-table replace): adds TableWriteIntent/RowRef/Capability/Failure and a TableWriteSink guard base that enforces the capability gate before any dispatch, plus a matching PlugManifest.tableWriteCapabilities declaration and validator rules. Co-Authored-By: Claude Sonnet 5 --- .../socket/ampere/canon/table/TableRowRef.kt | 27 ++++ .../canon/table/TableWriteCapability.kt | 25 ++++ .../ampere/canon/table/TableWriteFailure.kt | 84 +++++++++++++ .../ampere/canon/table/TableWriteIntent.kt | 50 ++++++++ .../ampere/canon/table/TableWriteSink.kt | 58 +++++++++ .../link/socket/ampere/plug/PlugManifest.kt | 12 ++ .../ampere/plug/PlugManifestValidator.kt | 49 ++++++++ .../ampere/canon/table/TableWriteSinkTest.kt | 117 ++++++++++++++++++ .../ampere/plug/PlugManifestValidationTest.kt | 72 +++++++++++ 9 files changed, 494 insertions(+) create mode 100644 ampere-core/src/commonMain/kotlin/link/socket/ampere/canon/table/TableRowRef.kt create mode 100644 ampere-core/src/commonMain/kotlin/link/socket/ampere/canon/table/TableWriteCapability.kt create mode 100644 ampere-core/src/commonMain/kotlin/link/socket/ampere/canon/table/TableWriteFailure.kt create mode 100644 ampere-core/src/commonMain/kotlin/link/socket/ampere/canon/table/TableWriteIntent.kt create mode 100644 ampere-core/src/commonMain/kotlin/link/socket/ampere/canon/table/TableWriteSink.kt create mode 100644 ampere-core/src/commonTest/kotlin/link/socket/ampere/canon/table/TableWriteSinkTest.kt diff --git a/ampere-core/src/commonMain/kotlin/link/socket/ampere/canon/table/TableRowRef.kt b/ampere-core/src/commonMain/kotlin/link/socket/ampere/canon/table/TableRowRef.kt new file mode 100644 index 00000000..a4ff6079 --- /dev/null +++ b/ampere-core/src/commonMain/kotlin/link/socket/ampere/canon/table/TableRowRef.kt @@ -0,0 +1,27 @@ +package link.socket.ampere.canon.table + +/** + * Identifies the target row of a [TableWriteIntent.UpdateCell]. + * + * `TABLE` has no addressable row identity today — [link.socket.ampere.canon.CanonTablePreview] + * is a truncated, positional window, not a keyed row set (AMPR-263 recon). The + * two cases here are the two identity primitives a provider can actually + * offer, per the AMPR-263 provider survey: + * + * - [Position] — a row's index in the table, the only identity Google Sheets + * and a folder-mounted CSV can offer. It is fragile: inserting or removing + * a row above it shifts every index below, so it is only trustworthy + * immediately after the read that produced it. + * - [NativeRowId] — a stable, provider-native identifier that survives + * reordering, e.g. a Notion database row's `page_id`. Sinks that can + * accept this case should prefer it. + * + * Which case a given [link.socket.ampere.plug.spi.ExecuteSink] accepts is a + * per-provider capability, not something this type enforces structurally. + */ +sealed interface TableRowRef { + + data class Position(val index: Int) : TableRowRef + + data class NativeRowId(val id: String) : TableRowRef +} diff --git a/ampere-core/src/commonMain/kotlin/link/socket/ampere/canon/table/TableWriteCapability.kt b/ampere-core/src/commonMain/kotlin/link/socket/ampere/canon/table/TableWriteCapability.kt new file mode 100644 index 00000000..a5704e03 --- /dev/null +++ b/ampere-core/src/commonMain/kotlin/link/socket/ampere/canon/table/TableWriteCapability.kt @@ -0,0 +1,25 @@ +package link.socket.ampere.canon.table + +import kotlinx.serialization.Serializable + +/** + * A [TableWriteIntent] subtype a Plug positively declares it can honor + * losslessly, per [link.socket.ampere.plug.PlugManifest.tableWriteCapabilities]. + * + * There is no `REPLACE_TABLE` member and there never will be — the AMPR-263 + * verdict forbids offering whole-table replace on any provider, so the + * closed membership of this enum *is* that constraint, not just a + * documentation note about it. + * + * Per the AMPR-263 provider survey, [APPEND_ROW] is honorable losslessly by + * every surveyed provider (Sheets `values.append`, Notion `pages.create`, a + * CSV append), while [UPDATE_CELL] is not — a Plug that cannot honor + * preserve-and-merge for a given provider's existing cells must omit + * [UPDATE_CELL] from its declared capabilities rather than accept the intent + * and weaken the guarantee. + */ +@Serializable +enum class TableWriteCapability { + APPEND_ROW, + UPDATE_CELL, +} diff --git a/ampere-core/src/commonMain/kotlin/link/socket/ampere/canon/table/TableWriteFailure.kt b/ampere-core/src/commonMain/kotlin/link/socket/ampere/canon/table/TableWriteFailure.kt new file mode 100644 index 00000000..8b28c569 --- /dev/null +++ b/ampere-core/src/commonMain/kotlin/link/socket/ampere/canon/table/TableWriteFailure.kt @@ -0,0 +1,84 @@ +package link.socket.ampere.canon.table + +import link.socket.ampere.canon.CanonId + +/** + * Why a [TableWriteIntent] could not be executed. + * + * The list is closed; callers can rely on `when` being exhaustive. Mirrors + * [link.socket.ampere.canon.adapter.CanonConversionFailure]'s shape for the + * same reason: a typed, closed failure set is what lets a caller (or a + * future Arc retry policy) branch on *why*, not just that a write failed. + */ +sealed interface TableWriteFailure { + + /** + * The intent named a [TableWriteCapability] the receiving sink did not + * declare. This is the guard that keeps the AMPR-263 non-negotiable + * honest: a sink that cannot honor preserve-and-merge for a capability + * simply never declares it, and every intent of that shape fails here + * before any native write is attempted. + */ + data class CapabilityNotSupported( + val capability: TableWriteCapability, + val tableId: CanonId, + ) : TableWriteFailure + + /** + * The target cell holds a provider-native formula, and canon carries + * values, not formulas (AMPR-263 §2, the formula-cell hazard). Writing + * through would silently replace the formula with a literal. + */ + data class FormulaCellWrite( + val tableId: CanonId, + val row: TableRowRef, + val column: String, + ) : TableWriteFailure + + /** + * The target column is a provider-computed property (e.g. a Notion + * `formula`/`rollup` property) rather than a stored value. Distinct from + * [FormulaCellWrite]: this is a schema-level fact about the column, not + * a per-cell one. + */ + data class ComputedColumnWrite( + val tableId: CanonId, + val column: String, + ) : TableWriteFailure + + /** + * The write was rejected because the native table changed between read + * and write. Whether this is detected precisely (a per-row etag) or + * coarsely (a whole-document revision) is a provider fact, not something + * this failure encodes — see the AMPR-263 provider survey. + */ + data class ConcurrentModification( + val tableId: CanonId, + val reason: String, + ) : TableWriteFailure + + /** [TableRowRef] did not resolve to a row in the native table. */ + data class RowNotFound( + val tableId: CanonId, + val row: TableRowRef, + ) : TableWriteFailure + + /** The transport rejected the write for a reason none of the above name. */ + data class WriteRejected( + val tableId: CanonId, + val reason: String, + ) : TableWriteFailure +} + +/** + * Carries a [TableWriteFailure] through [Result.failure], the same role + * [link.socket.ampere.canon.adapter.CanonConversionException] plays for + * [link.socket.ampere.canon.adapter.CanonConversionFailure]. + */ +class TableWriteException( + val failure: TableWriteFailure, +) : Exception("Table write failed: $failure") + +/** Shorthand for the Result-typed failure path. */ +fun tableWriteFailure(failure: TableWriteFailure): Result = + Result.failure(TableWriteException(failure)) diff --git a/ampere-core/src/commonMain/kotlin/link/socket/ampere/canon/table/TableWriteIntent.kt b/ampere-core/src/commonMain/kotlin/link/socket/ampere/canon/table/TableWriteIntent.kt new file mode 100644 index 00000000..d5fde145 --- /dev/null +++ b/ampere-core/src/commonMain/kotlin/link/socket/ampere/canon/table/TableWriteIntent.kt @@ -0,0 +1,50 @@ +package link.socket.ampere.canon.table + +import link.socket.ampere.canon.CanonId + +/** + * A write Arc can emit against a `TABLE`, translated natively by the + * receiving Plug rather than merged by a canon-generic algorithm. + * + * This is the AMPR-263 verdict's shape: two intents, never a whole-table + * replace. The closed membership below is that constraint enforced by the + * type system — there is no `ReplaceTable` case to add, and a Plug that + * cannot honor either case losslessly for a given provider must refuse it + * (see [TableWriteSink]), not accept it and clobber. + * + * `AppendRow` never touches an existing cell, so it carries no formula or + * concurrency hazard by construction (AMPR-263 §2, Model B). `UpdateCell` + * does, and which providers can accept it — and under what guard — is a + * per-Plug capability declared in + * [link.socket.ampere.plug.PlugManifest.tableWriteCapabilities], not + * something this type can decide. + */ +sealed interface TableWriteIntent { + + /** The `TABLE` canon entity this intent targets. */ + val tableId: CanonId + + /** + * Add a new row. Values are positional, matching + * [link.socket.ampere.canon.CanonTable.columnNames] order. + */ + data class AppendRow( + override val tableId: CanonId, + val values: List, + ) : TableWriteIntent + + /** Overwrite one existing cell. Never a document- or row-level replace. */ + data class UpdateCell( + override val tableId: CanonId, + val row: TableRowRef, + val column: String, + val value: String, + ) : TableWriteIntent +} + +/** The [TableWriteCapability] a Plug must declare to accept this intent. */ +val TableWriteIntent.requiredCapability: TableWriteCapability + get() = when (this) { + is TableWriteIntent.AppendRow -> TableWriteCapability.APPEND_ROW + is TableWriteIntent.UpdateCell -> TableWriteCapability.UPDATE_CELL + } diff --git a/ampere-core/src/commonMain/kotlin/link/socket/ampere/canon/table/TableWriteSink.kt b/ampere-core/src/commonMain/kotlin/link/socket/ampere/canon/table/TableWriteSink.kt new file mode 100644 index 00000000..71ce5e3a --- /dev/null +++ b/ampere-core/src/commonMain/kotlin/link/socket/ampere/canon/table/TableWriteSink.kt @@ -0,0 +1,58 @@ +package link.socket.ampere.canon.table + +import link.socket.ampere.canon.CanonType +import link.socket.ampere.plug.spi.ExecuteReceipt +import link.socket.ampere.plug.spi.ExecuteSink + +/** + * Guarded [ExecuteSink] for [TableWriteIntent]. + * + * ## The capability gate is structural, not remembered + * + * Mirrors [link.socket.ampere.canon.adapter.WritableCanonAdapter]'s shape: + * subclasses do not implement [ExecuteSink.execute] directly. Instead + * [execute] is `final`, checks the intent's [TableWriteIntent.requiredCapability] + * against [capabilities] before doing anything else, and only then routes to + * [appendRow] or [updateCell]. A subclass cannot accept an intent it never + * declared support for without deleting a member of this class — the same + * guarantee [link.socket.ampere.canon.adapter.WritableCanonAdapter.writeBack] + * makes for scalar field write-back, applied to the AMPR-263 verdict's + * per-provider capability gating instead of a per-field `ownedFields` set. + * + * [capabilities] is where a provider's AMPR-263 verdict is expressed in code: + * a CSV sink passes `setOf(APPEND_ROW)` and [updateCell] is never reached: [execute] + * fails every [TableWriteIntent.UpdateCell] with + * [TableWriteFailure.CapabilityNotSupported] before dispatch. [updateCell] + * must still be implemented — Kotlin requires it — but a sink whose + * [capabilities] omit [TableWriteCapability.UPDATE_CELL] can implement it as + * an unreachable defensive failure rather than real logic. + */ +abstract class TableWriteSink( + protected val capabilities: Set, +) : ExecuteSink { + + final override val consumes: Set = setOf(CanonType.TABLE) + + final override suspend fun execute(command: TableWriteIntent): Result { + val capability = command.requiredCapability + if (capability !in capabilities) { + return tableWriteFailure( + TableWriteFailure.CapabilityNotSupported( + capability = capability, + tableId = command.tableId, + ), + ) + } + + return when (command) { + is TableWriteIntent.AppendRow -> appendRow(command) + is TableWriteIntent.UpdateCell -> updateCell(command) + } + } + + /** Runs once [capabilities] confirms [TableWriteCapability.APPEND_ROW] is declared. */ + protected abstract suspend fun appendRow(intent: TableWriteIntent.AppendRow): Result + + /** Runs once [capabilities] confirms [TableWriteCapability.UPDATE_CELL] is declared. */ + protected abstract suspend fun updateCell(intent: TableWriteIntent.UpdateCell): Result +} diff --git a/ampere-core/src/commonMain/kotlin/link/socket/ampere/plug/PlugManifest.kt b/ampere-core/src/commonMain/kotlin/link/socket/ampere/plug/PlugManifest.kt index b1bfe8db..1c857d6c 100644 --- a/ampere-core/src/commonMain/kotlin/link/socket/ampere/plug/PlugManifest.kt +++ b/ampere-core/src/commonMain/kotlin/link/socket/ampere/plug/PlugManifest.kt @@ -2,6 +2,7 @@ package link.socket.ampere.plug import kotlinx.serialization.Serializable import link.socket.ampere.canon.CanonType +import link.socket.ampere.canon.table.TableWriteCapability import link.socket.ampere.link.LinkRequirement import link.socket.ampere.plug.permission.PlugPermission @@ -42,6 +43,16 @@ import link.socket.ampere.plug.permission.PlugPermission * than a gap to fill in later. See [PlugManifestValidator] for how this flag * changes Link requirement validation. * + * [tableWriteCapabilities] is the AMPR-263 verdict expressed as a manifest + * declaration: which [TableWriteCapability] this Plug can honor losslessly + * for `TABLE`, never more than it can actually guarantee. A Plug that cannot + * honor preserve-and-merge for [TableWriteCapability.UPDATE_CELL] on its + * provider simply omits it — the AMPR-263 non-negotiable's "degrade to + * read-only" clause is this field being empty or partial, not a runtime + * override. See [link.socket.ampere.canon.table.TableWriteSink] for the + * corresponding execute-side guard, and [PlugManifestValidator] for how a + * declaration here is cross-checked against [emits]/[consumes]. + * * Every collection field defaults to empty so manifests written before each * schema addition continue to decode unchanged. */ @@ -59,4 +70,5 @@ data class PlugManifest( val optionalConsumes: Set = emptySet(), val resolvesAssets: Boolean = false, val isCanonExternal: Boolean = false, + val tableWriteCapabilities: Set = emptySet(), ) diff --git a/ampere-core/src/commonMain/kotlin/link/socket/ampere/plug/PlugManifestValidator.kt b/ampere-core/src/commonMain/kotlin/link/socket/ampere/plug/PlugManifestValidator.kt index a80d2046..781b9059 100644 --- a/ampere-core/src/commonMain/kotlin/link/socket/ampere/plug/PlugManifestValidator.kt +++ b/ampere-core/src/commonMain/kotlin/link/socket/ampere/plug/PlugManifestValidator.kt @@ -1,6 +1,7 @@ package link.socket.ampere.plug import link.socket.ampere.canon.CanonType +import link.socket.ampere.canon.table.TableWriteCapability import link.socket.ampere.plug.permission.PlugPermission /** @@ -55,6 +56,7 @@ object PlugManifestValidator { reasons += validateLinkRequirements(manifest) reasons += validateDeviceCapabilities(manifest) reasons += validateCanonConsumption(manifest) + reasons += validateTableWriteCapabilities(manifest) return if (reasons.isEmpty()) { ManifestValidationResult.Valid @@ -152,6 +154,33 @@ object PlugManifestValidator { return (manifest.consumes intersect manifest.optionalConsumes) .map { ManifestValidationReason.RedundantOptionalConsumes(it) } } + + /** + * A [PlugManifest.tableWriteCapabilities] declaration only makes sense + * for a Plug that actually has `TABLE` in its canon-level data contract + * — same asymmetry [validateLinkRequirements] enforces for + * [link.socket.ampere.link.LinkRequirement.minimumScope], applied to + * write capabilities instead of read scope. + */ + private fun validateTableWriteCapabilities( + manifest: PlugManifest, + ): List { + if (manifest.tableWriteCapabilities.isEmpty()) return emptyList() + + val reasons = mutableListOf() + + if (manifest.isCanonExternal) { + reasons += ManifestValidationReason.CanonExternalWithTableWriteCapabilities( + capabilities = manifest.tableWriteCapabilities, + ) + } else if (CanonType.TABLE !in manifest.emits + manifest.consumes) { + reasons += ManifestValidationReason.UndeclaredTableWriteCapability( + capabilities = manifest.tableWriteCapabilities, + ) + } + + return reasons + } } sealed interface ManifestValidationResult { @@ -234,4 +263,24 @@ sealed interface ManifestValidationReason { data class CanonExternalWithDeclaredCanon( val canonTypes: Set, ) : ManifestValidationReason + + /** + * [PlugManifest.tableWriteCapabilities] is non-empty but the manifest + * names neither [CanonType.TABLE] in [PlugManifest.emits] nor + * [PlugManifest.consumes] — a Plug asking to write a canon type it never + * declared handling. + */ + data class UndeclaredTableWriteCapability( + val capabilities: Set, + ) : ManifestValidationReason + + /** + * [PlugManifest.isCanonExternal] declares no canon-level data contract, + * but [PlugManifest.tableWriteCapabilities] is non-empty — the same + * contradiction [CanonExternalWithDeclaredCanon] catches for + * [PlugManifest.emits]/[PlugManifest.consumes], for write capabilities. + */ + data class CanonExternalWithTableWriteCapabilities( + val capabilities: Set, + ) : ManifestValidationReason } diff --git a/ampere-core/src/commonTest/kotlin/link/socket/ampere/canon/table/TableWriteSinkTest.kt b/ampere-core/src/commonTest/kotlin/link/socket/ampere/canon/table/TableWriteSinkTest.kt new file mode 100644 index 00000000..043e7737 --- /dev/null +++ b/ampere-core/src/commonTest/kotlin/link/socket/ampere/canon/table/TableWriteSinkTest.kt @@ -0,0 +1,117 @@ +package link.socket.ampere.canon.table + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertIs +import kotlin.test.assertTrue +import kotlinx.coroutines.test.runTest +import kotlinx.datetime.Instant +import link.socket.ampere.canon.CanonId +import link.socket.ampere.canon.CanonType +import link.socket.ampere.link.LinkId +import link.socket.ampere.plug.spi.ExecuteReceipt + +class TableWriteSinkTest { + + private val linkId = LinkId("csv-mount-1") + private val executedAt = Instant.fromEpochMilliseconds(1_700_000_000_000) + private val tableId = CanonId("table-1") + + private class RecordingSink( + capabilities: Set, + private val linkId: LinkId, + private val executedAt: Instant, + ) : TableWriteSink(capabilities) { + var appendCalls = 0 + var updateCalls = 0 + + override suspend fun appendRow(intent: TableWriteIntent.AppendRow): Result { + appendCalls++ + return Result.success(ExecuteReceipt(linkId = linkId, executedAt = executedAt)) + } + + override suspend fun updateCell(intent: TableWriteIntent.UpdateCell): Result { + updateCalls++ + return Result.success(ExecuteReceipt(linkId = linkId, executedAt = executedAt)) + } + } + + @Test + fun `a sink always declares TABLE as its consumed canon type`() { + val sink = RecordingSink(setOf(TableWriteCapability.APPEND_ROW), linkId, executedAt) + + assertEquals(setOf(CanonType.TABLE), sink.consumes) + } + + @Test + fun `append-row runs when declared`() = runTest { + val sink = RecordingSink(setOf(TableWriteCapability.APPEND_ROW), linkId, executedAt) + + val result = sink.execute(TableWriteIntent.AppendRow(tableId, listOf("a", "b"))) + + assertTrue(result.isSuccess) + assertEquals(1, sink.appendCalls) + assertEquals(0, sink.updateCalls) + } + + @Test + fun `update-cell is refused before dispatch when not declared`() = runTest { + // This is the CSV leg of the AMPR-263 verdict: a sink that never + // declares UPDATE_CELL must never reach updateCell(), because that + // is the only way preserve-and-merge stays honest for a provider + // that can't honor it. + val sink = RecordingSink(setOf(TableWriteCapability.APPEND_ROW), linkId, executedAt) + + val result = sink.execute( + TableWriteIntent.UpdateCell(tableId, TableRowRef.Position(0), "status", "Done"), + ) + + assertTrue(result.isFailure) + assertEquals(0, sink.updateCalls) + + val failure = assertIs(result.exceptionOrNull()).failure + val capabilityFailure = assertIs(failure) + assertEquals(TableWriteCapability.UPDATE_CELL, capabilityFailure.capability) + assertEquals(tableId, capabilityFailure.tableId) + } + + @Test + fun `update-cell runs when declared`() = runTest { + val sink = RecordingSink( + setOf(TableWriteCapability.APPEND_ROW, TableWriteCapability.UPDATE_CELL), + linkId, + executedAt, + ) + + val result = sink.execute( + TableWriteIntent.UpdateCell(tableId, TableRowRef.NativeRowId("page-1"), "status", "Done"), + ) + + assertTrue(result.isSuccess) + assertEquals(1, sink.updateCalls) + } + + @Test + fun `append-row is refused before dispatch when the sink only supports update-cell`() = runTest { + val sink = RecordingSink(setOf(TableWriteCapability.UPDATE_CELL), linkId, executedAt) + + val result = sink.execute(TableWriteIntent.AppendRow(tableId, listOf("a"))) + + assertTrue(result.isFailure) + assertFalse(result.isSuccess) + assertEquals(0, sink.appendCalls) + } + + @Test + fun `requiredCapability maps each intent to the capability that must be declared`() { + assertEquals( + TableWriteCapability.APPEND_ROW, + TableWriteIntent.AppendRow(tableId, emptyList()).requiredCapability, + ) + assertEquals( + TableWriteCapability.UPDATE_CELL, + TableWriteIntent.UpdateCell(tableId, TableRowRef.Position(0), "col", "v").requiredCapability, + ) + } +} diff --git a/ampere-core/src/commonTest/kotlin/link/socket/ampere/plug/PlugManifestValidationTest.kt b/ampere-core/src/commonTest/kotlin/link/socket/ampere/plug/PlugManifestValidationTest.kt index 08b4221a..abefcbc9 100644 --- a/ampere-core/src/commonTest/kotlin/link/socket/ampere/plug/PlugManifestValidationTest.kt +++ b/ampere-core/src/commonTest/kotlin/link/socket/ampere/plug/PlugManifestValidationTest.kt @@ -5,6 +5,7 @@ import kotlin.test.assertEquals import kotlin.test.assertIs import kotlin.test.assertTrue import link.socket.ampere.canon.CanonType +import link.socket.ampere.canon.table.TableWriteCapability import link.socket.ampere.link.LinkDirection import link.socket.ampere.link.LinkRequirement import link.socket.ampere.link.Transport @@ -457,4 +458,75 @@ class PlugManifestValidationTest { assertTrue(manifest.optionalConsumes.isEmpty()) assertEquals(ManifestValidationResult.Valid, PlugManifestValidator.validate(manifest)) } + + // ----------------------------------------------------------------- + // Table write capabilities (AMPR-263) + // ----------------------------------------------------------------- + + @Test + fun `a manifest written before table write capabilities existed still decodes`() { + val manifest = PlugManifest(id = PlugId("legacy"), name = "Legacy", version = "0.1.0") + + assertTrue(manifest.tableWriteCapabilities.isEmpty()) + assertEquals(ManifestValidationResult.Valid, PlugManifestValidator.validate(manifest)) + } + + @Test + fun `a manifest declaring table write capabilities alongside TABLE in its canon contract validates`() { + val manifest = PlugManifest( + id = PlugId("csv-plug"), + name = "CSV Plug", + version = "1.0.0", + emits = setOf(CanonType.TABLE), + tableWriteCapabilities = setOf(TableWriteCapability.APPEND_ROW), + ) + + assertEquals(ManifestValidationResult.Valid, PlugManifestValidator.validate(manifest)) + } + + @Test + fun `table write capabilities declared without TABLE in emits or consumes is rejected`() { + val manifest = PlugManifest( + id = PlugId("csv-plug"), + name = "CSV Plug", + version = "1.0.0", + tableWriteCapabilities = setOf(TableWriteCapability.APPEND_ROW), + ) + + val invalid = assertIs( + PlugManifestValidator.validate(manifest), + ) + + val undeclared = invalid.reasons + .filterIsInstance() + assertEquals(1, undeclared.size) + assertEquals(setOf(TableWriteCapability.APPEND_ROW), undeclared.single().capabilities) + } + + @Test + fun `table write capabilities declared on a canon-external manifest is rejected`() { + val manifest = PlugManifest( + id = PlugId("mislabelled-plug"), + name = "Mislabelled Plug", + version = "1.0.0", + isCanonExternal = true, + tableWriteCapabilities = setOf(TableWriteCapability.UPDATE_CELL), + ) + + val invalid = assertIs( + PlugManifestValidator.validate(manifest), + ) + + val contradiction = invalid.reasons + .filterIsInstance() + assertEquals(1, contradiction.size) + assertEquals(setOf(TableWriteCapability.UPDATE_CELL), contradiction.single().capabilities) + } + + @Test + fun `a manifest declaring no table write capabilities validates without naming TABLE`() { + val manifest = PlugManifest(id = PlugId("plain-plug"), name = "Plain Plug", version = "1.0.0") + + assertEquals(ManifestValidationResult.Valid, PlugManifestValidator.validate(manifest)) + } }