Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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
}
Original file line number Diff line number Diff line change
@@ -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,
}
Original file line number Diff line number Diff line change
@@ -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 <T> tableWriteFailure(failure: TableWriteFailure): Result<T> =
Result.failure(TableWriteException(failure))
Original file line number Diff line number Diff line change
@@ -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<String>,
) : 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
}
Original file line number Diff line number Diff line change
@@ -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<TableWriteCapability>,
) : ExecuteSink<TableWriteIntent> {

final override val consumes: Set<CanonType> = setOf(CanonType.TABLE)

final override suspend fun execute(command: TableWriteIntent): Result<ExecuteReceipt> {
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<ExecuteReceipt>

/** Runs once [capabilities] confirms [TableWriteCapability.UPDATE_CELL] is declared. */
protected abstract suspend fun updateCell(intent: TableWriteIntent.UpdateCell): Result<ExecuteReceipt>
}
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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.
*/
Expand All @@ -59,4 +70,5 @@ data class PlugManifest(
val optionalConsumes: Set<CanonType> = emptySet(),
val resolvesAssets: Boolean = false,
val isCanonExternal: Boolean = false,
val tableWriteCapabilities: Set<TableWriteCapability> = emptySet(),
)
Original file line number Diff line number Diff line change
@@ -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

/**
Expand Down Expand Up @@ -55,6 +56,7 @@ object PlugManifestValidator {
reasons += validateLinkRequirements(manifest)
reasons += validateDeviceCapabilities(manifest)
reasons += validateCanonConsumption(manifest)
reasons += validateTableWriteCapabilities(manifest)

return if (reasons.isEmpty()) {
ManifestValidationResult.Valid
Expand Down Expand Up @@ -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<ManifestValidationReason> {
if (manifest.tableWriteCapabilities.isEmpty()) return emptyList()

val reasons = mutableListOf<ManifestValidationReason>()

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 {
Expand Down Expand Up @@ -234,4 +263,24 @@ sealed interface ManifestValidationReason {
data class CanonExternalWithDeclaredCanon(
val canonTypes: Set<CanonType>,
) : 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<TableWriteCapability>,
) : 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<TableWriteCapability>,
) : ManifestValidationReason
}
Loading
Loading