This is an automated email from the ASF dual-hosted git repository.

zeroshade pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/iceberg-go.git


The following commit(s) were added to refs/heads/main by this push:
     new 9dd8d308f feat(table,rest): enforce the catalog's scan-planning-mode 
directive (#2162)
9dd8d308f is described below

commit 9dd8d308f6fa2119eccb4b7393b7dfc153a19b45
Author: Revanth <[email protected]>
AuthorDate: Wed Oct 21 12:26:55 2026 -0500

    feat(table,rest): enforce the catalog's scan-planning-mode directive (#2162)
    
    * fix(table): add ScanPlanningDirective() accessor on table
    
    Previously, the table returned by a LoadTable() on a REST catalog did
    not have any way of exposing what scan planning mode the catalog is
    trying to enforce on this table. In cases where the catalog is enforcing
    server-side scan planning, the caller would have to rely on errors
    and/or missing files through the Scan() interface, to realize that
    server-side scan planning is necessary.
    
    This change exposes a ScanPlanningDirective() function on a table.Table
    that exposes this value as returned from LoadTableResponse. If the catalog
    is not imposing any particular scan planning mode,
    ScanPlanningDirectiveUnknown is returned.
    
    * fix(table): carry scan-planning directive state explicitly
    
    Address review feedback on the ScanPlanningDirective accessor:
    
    - WithScanPlanningDirective now takes (value, ok) so absence is explicit
      and a comma-ok map read can be passed straight through.
    - Add WithScanPlanningDirectiveFrom to copy a table's directive verbatim;
      RefreshTableCredentials uses it instead of reconstructing the raw value
      from SavedConfig, which could report the wrong directive for externally
      built tables.
    - Drop the positional loadConfig parameter from tableFromResponse; load
      paths pass the directive as a caller option instead.
    - Rename ScanPlanningDirectiveUnknown to ScanPlanningDirectiveNone and
      document that it only means "no directive" with a nil error.
    - Use a switch over the valid directives.
    - Add coverage for Commit, StagedTable, Refresh, CreateTable,
      RegisterTable, and LoadTable followed by a credential refresh.
    
    Co-Authored-By: Claude Opus 5.5 <[email protected]>
    
    * feat(table,rest): enforce the catalog's scan-planning-mode directive
    
    * fix(table,rest): fail closed on UpdateTable and incremental scans under a 
scan-planning directive
    
    * fix(table,rest): tolerate a nil directive source and pin UpdateTable's 
directive
    
    ---------
    
    Co-authored-by: Bilal Akhtar <[email protected]>
    Co-authored-by: Claude Opus 5.5 <[email protected]>
---
 README.md                                      |  36 ++-
 catalog/rest/refresh_table_credentials_test.go | 111 ++++++++
 catalog/rest/rest.go                           |  53 +++-
 catalog/rest/rest_test.go                      | 370 +++++++++++++++++++++++++
 catalog/rest/scan_planning_directive_test.go   | 101 +++++++
 table/incremental_append_scan.go               |  13 +-
 table/incremental_changelog_scan.go            |  11 +-
 table/scan_planning.go                         | 188 ++++++++++++-
 table/scan_planning_directive_wiring_test.go   | 160 +++++++++++
 table/scan_planning_test.go                    | 223 +++++++++++++++
 table/scanner.go                               |  18 +-
 table/table.go                                 |  30 +-
 table/transaction.go                           |   9 +-
 13 files changed, 1267 insertions(+), 56 deletions(-)

diff --git a/README.md b/README.md
index 01ec83776..7e3a034e0 100644
--- a/README.md
+++ b/README.md
@@ -138,14 +138,34 @@ submission, polling, and task retrieval. Cancellation is 
best-effort and is not
 required for automatic remote planning. In `auto` mode, catalogs
 without a planner retain local planning.
 
-The scanner does not honor the REST `scan-planning-mode` configuration key
-(`client`/`server`). A catalog requiring `server` mode may rely on planning to
-vend plan-scoped storage credentials. Default local planning, explicit `local`,
-or an `auto` fallback can instead read manifests with the table's storage
-credentials, which may use a different identity or lack the required access.
-For these deployments, explicitly select `table.ScanPlanningRemote` on every
-scan; do not rely on the server configuration or `auto` to enforce remote
-planning. Remote mode returns an error if a required capability is missing.
+Scans follow the catalog's `scan-planning-mode` directive (`client`/`server`).
+The directive is resolved with the same precedence as the Java client: the
+table-load response's `config` block wins, and the catalog's own
+`scan-planning-mode` property (including `/v1/config` defaults and overrides)
+applies when the server sends none. A mismatch between the two is logged.
+Read it with `tbl.ScanPlanningDirective()`.
+
+| Directive | Scan option | Result |
+| :-------- | :---------- | :----- |
+| `server` | none, `auto`, or `remote` | Plan remotely. |
+| `server` | `local` | Error. |
+| `client` | none, `auto`, or `local` | Plan locally. |
+| `client` | `remote` | Error. |
+| none | any | As described above. |
+
+Some behaviors are specific to this client. The directive is checked when a
+scan is planned, not when the table is loaded, so a `server` table whose
+catalog does not advertise the plan endpoint can still be loaded and committed
+to; planning it returns an error. An unrecognized directive value likewise
+fails only at planning. An explicit scan option that conflicts with the
+directive is rejected rather than overridden. And incremental append and
+changelog scans, which only plan locally, return an error on a `server` table,
+where the Java client plans them locally.
+
+Scans inside a transaction plan staged metadata locally and do not apply the
+directive. Commit responses carry no table
+config, so a table returned by `UpdateTable` keeps only the catalog's own
+`scan-planning-mode` property; reload it to obtain a per-table directive.
 
 ```go
 // tbl is a table loaded from a rest.Catalog.
diff --git a/catalog/rest/refresh_table_credentials_test.go 
b/catalog/rest/refresh_table_credentials_test.go
index 5f46bc899..7dc37b9be 100644
--- a/catalog/rest/refresh_table_credentials_test.go
+++ b/catalog/rest/refresh_table_credentials_test.go
@@ -275,6 +275,117 @@ func 
TestRefreshTableCredentialsPreservesMetricsReporter(t *testing.T) {
        assert.Same(t, reporter, refreshed.MetricsReporter())
 }
 
+// TestRefreshTableCredentialsPreservesScanPlanningDirective checks that the
+// load-time scan-planning directive survives a credential refresh, whose
+// response carries no table config to re-derive it from. The directive is
+// copied from the table, never reconstructed from its saved config.
+func TestRefreshTableCredentialsPreservesScanPlanningDirective(t *testing.T) {
+       for _, tc := range []struct {
+               name    string
+               opts    []table.Option
+               want    table.ScanPlanningDirective
+               wantErr string
+       }{
+               {name: "absent", want: table.ScanPlanningDirectiveNone},
+               {
+                       name: "server",
+                       opts: 
[]table.Option{table.WithScanPlanningDirective("server", true)},
+                       want: table.ScanPlanningDirectiveServer,
+               },
+               {
+                       name:    "unrecognized",
+                       opts:    
[]table.Option{table.WithScanPlanningDirective("hybrid", true)},
+                       wantErr: `"hybrid"`,
+               },
+               {
+                       // A saved config built from metadata properties, as 
the method's doc
+                       // tells external callers to pass, must not leak into 
the directive.
+                       name: "unrecognized_with_saved_metadata_property",
+                       opts: []table.Option{
+                               table.WithScanPlanningDirective("hybrid", true),
+                               
table.WithSavedConfig(iceberg.Properties{table.ScanPlanningModeKey: "server"}),
+                       },
+                       wantErr: `"hybrid"`,
+               },
+               {
+                       name: "absent_with_saved_metadata_property",
+                       opts: []table.Option{
+                               
table.WithSavedConfig(iceberg.Properties{table.ScanPlanningModeKey: "server"}),
+                       },
+                       want: table.ScanPlanningDirectiveNone,
+               },
+       } {
+               t.Run(tc.name, func(t *testing.T) {
+                       scheme := "restcreds-directive-" + 
strings.ReplaceAll(tc.name, "_", "-")
+
+                       cat := newCredsTestCatalog(t, credsCatalogOpts{
+                               creds: func(w http.ResponseWriter, _ 
*http.Request) {
+                                       assert.NoError(t, 
json.NewEncoder(w).Encode(storageCredentialsBody(
+                                               
scheme+"://warehouse/database/table",
+                                               
map[string]string{"s3.access-key-id": "vended-key"},
+                                       )))
+                               },
+                       })
+
+                       tbl, _ := newExternalTable(t, cat, scheme, tc.opts...)
+
+                       refreshed, err := 
cat.RefreshTableCredentials(context.Background(), tbl)
+                       require.NoError(t, err)
+                       require.NotSame(t, tbl, refreshed)
+
+                       got, err := refreshed.ScanPlanningDirective()
+                       if tc.wantErr != "" {
+                               require.ErrorIs(t, err, 
iceberg.ErrInvalidArgument)
+                               assert.Contains(t, err.Error(), tc.wantErr)
+
+                               return
+                       }
+                       require.NoError(t, err)
+                       assert.Equal(t, tc.want, got)
+               })
+       }
+}
+
+// TestRefreshTableCredentialsAfterLoadTableKeepsScanPlanningDirective covers
+// the directive end to end: an unrecognized value from the load response's
+// config block must still be reported verbatim after a credential refresh.
+func TestRefreshTableCredentialsAfterLoadTableKeepsScanPlanningDirective(t 
*testing.T) {
+       const scheme = "restcreds-loaded-directive"
+
+       metadataLoc := scheme + 
"://warehouse/database/table/metadata/00000-a.metadata.json"
+       cat := newCredsTestCatalog(t, credsCatalogOpts{
+               loadTable: func(w http.ResponseWriter, req *http.Request) {
+                       assert.Equal(t, http.MethodGet, req.Method)
+                       assert.NoError(t, 
json.NewEncoder(w).Encode(map[string]any{
+                               "metadata-location": metadataLoc,
+                               "metadata": json.RawMessage(strings.ReplaceAll(
+                                       exampleTableMetadataNoSnapshotV1, 
"s3://", scheme+"://")),
+                               "config": 
map[string]string{table.ScanPlanningModeKey: "Hybrid"},
+                       }))
+               },
+               creds: func(w http.ResponseWriter, _ *http.Request) {
+                       assert.NoError(t, 
json.NewEncoder(w).Encode(storageCredentialsBody(
+                               scheme+"://warehouse/database/table",
+                               map[string]string{iceio.S3AccessKeyID: 
"vended-key"},
+                       )))
+               },
+       })
+
+       tbl, err := cat.LoadTable(context.Background(), 
catalog.ToIdentifier("db", "tbl"))
+       require.NoError(t, err)
+       _, err = tbl.ScanPlanningDirective()
+       require.ErrorIs(t, err, iceberg.ErrInvalidArgument)
+       require.Contains(t, err.Error(), `"Hybrid"`)
+
+       refreshed, err := cat.RefreshTableCredentials(context.Background(), tbl)
+       require.NoError(t, err)
+       require.NotSame(t, tbl, refreshed)
+
+       _, err = refreshed.ScanPlanningDirective()
+       require.ErrorIs(t, err, iceberg.ErrInvalidArgument)
+       assert.Contains(t, err.Error(), `"Hybrid"`)
+}
+
 // TestRefreshTableCredentialsAfterLoadTable covers a table loaded through
 // LoadTable: the per-table config block of the load response is in neither the
 // catalog's props nor the table's metadata properties, so only the table's
diff --git a/catalog/rest/rest.go b/catalog/rest/rest.go
index 2d6db178b..fcd5d8aa7 100644
--- a/catalog/rest/rest.go
+++ b/catalog/rest/rest.go
@@ -34,6 +34,7 @@ import (
        "strconv"
        "strings"
        "sync"
+       "sync/atomic"
        "time"
 
        "github.com/apache/iceberg-go"
@@ -935,6 +936,9 @@ type Catalog struct {
        // from reporterProps) and the server advertises the endpoint. Owned 
here and
        // closed by Close.
        metricsDispatcher *metricsDispatcher
+       // scanPlanningMismatchWarned limits the scan-planning-mode mismatch 
warning
+       // to once per catalog; see scanPlanningDirective.
+       scanPlanningMismatchWarned atomic.Bool
 }
 
 func newCatalogFromProps(ctx context.Context, name string, uri string, p 
iceberg.Properties) (*Catalog, error) {
@@ -1394,6 +1398,33 @@ func (r *Catalog) tableFromResponse(
        ), nil
 }
 
+// scanPlanningDirective resolves a table's raw scan-planning-mode directive 
and
+// whether one is present, in the shape table.WithScanPlanningDirective takes.
+// As in Java's RESTSessionCatalog, the server's table config takes precedence
+// and the catalog's own scan-planning-mode property applies when the server
+// sends none; table metadata is never consulted. r.props already folds in the
+// /v1/config defaults and overrides. A mismatch between the two is logged as a
+// warning once per catalog and at debug level after that, since Refresh
+// reloads the table. A nil loadConfig yields the catalog property alone.
+func (r *Catalog) scanPlanningDirective(ctx context.Context, identifier 
[]string, loadConfig iceberg.Properties) (string, bool) {
+       server, hasServer := loadConfig[table.ScanPlanningModeKey]
+       client, hasClient := r.props[table.ScanPlanningModeKey]
+       if !hasServer {
+               return client, hasClient
+       }
+
+       if hasClient && !strings.EqualFold(client, server) {
+               level := slog.LevelDebug
+               if r.scanPlanningMismatchWarned.CompareAndSwap(false, true) {
+                       level = slog.LevelWarn
+               }
+               slog.Log(ctx, level, "iceberg: scan-planning-mode mismatch 
between catalog and server; using the server value",
+                       "table", strings.Join(identifier, "."), "client", 
client, "server", server)
+       }
+
+       return server, true
+}
+
 func (r *Catalog) fetchTableCreds(ctx context.Context, ident []string, 
location string) (iceberg.Properties, error) {
        if err := r.endpoints.check(endpointTableCredentials); err != nil {
                return nil, err
@@ -1445,9 +1476,14 @@ func (r *Catalog) RefreshTableCredentials(ctx 
context.Context, tbl *table.Table)
        scanCfg := maps.Clone(config)
        mergeVendedCredentials(config, resp, metadataLoc)
 
+       opts := []table.Option{
+               table.WithSavedConfig(tbl.SavedConfig()),
+               // Credential refresh does not return table config, so carry the
+               // existing scan-planning directive over verbatim.
+               table.WithScanPlanningDirectiveFrom(tbl),
+       }
        // Keep a reporter the caller set on the table rather than reverting it 
to
        // the catalog default, as Refresh does.
-       opts := []table.Option{table.WithSavedConfig(tbl.SavedConfig())}
        if reporter := tbl.MetricsReporter(); !metrics.IsNop(reporter) {
                opts = append(opts, table.WithMetricsReporter(reporter))
        }
@@ -1725,7 +1761,7 @@ func (r *Catalog) CreateTable(ctx context.Context, 
identifier table.Identifier,
        vended := resolveStorageCredentials(ret.StorageCredentials, 
ret.MetadataLoc)
        mergeVendedCredentials(config, vended, ret.MetadataLoc)
 
-       return r.tableFromResponse(ctx, identifier, ret.Metadata, 
ret.MetadataLoc, config, scanPlanningConfig, credsVended, vended, ret.Labels, 
table.WithSavedConfig(saved))
+       return r.tableFromResponse(ctx, identifier, ret.Metadata, 
ret.MetadataLoc, config, scanPlanningConfig, credsVended, vended, ret.Labels, 
table.WithSavedConfig(saved), 
table.WithScanPlanningDirective(r.scanPlanningDirective(ctx, identifier, 
ret.Config)))
 }
 
 // commitStagedCreate performs the second phase of a staged table
@@ -1954,7 +1990,7 @@ func (r *Catalog) RegisterTable(ctx context.Context, 
identifier table.Identifier
        vended := resolveStorageCredentials(ret.StorageCredentials, 
ret.MetadataLoc)
        mergeVendedCredentials(config, vended, ret.MetadataLoc)
 
-       return r.tableFromResponse(ctx, identifier, ret.Metadata, 
ret.MetadataLoc, config, scanPlanningConfig, credsVended, vended, ret.Labels, 
table.WithSavedConfig(saved))
+       return r.tableFromResponse(ctx, identifier, ret.Metadata, 
ret.MetadataLoc, config, scanPlanningConfig, credsVended, vended, ret.Labels, 
table.WithSavedConfig(saved), 
table.WithScanPlanningDirective(r.scanPlanningDirective(ctx, identifier, 
ret.Config)))
 }
 
 // LoadTable loads a table from the catalog. It implements [catalog.Catalog].
@@ -2017,7 +2053,7 @@ func (r *Catalog) loadTableWithMode(ctx context.Context, 
identifier table.Identi
        vended := resolveStorageCredentials(ret.StorageCredentials, 
ret.MetadataLoc)
        mergeVendedCredentials(config, vended, ret.MetadataLoc)
 
-       return r.tableFromResponse(ctx, identifier, ret.Metadata, 
ret.MetadataLoc, config, scanPlanningConfig, credsVended, vended, ret.Labels, 
table.WithSavedConfig(saved))
+       return r.tableFromResponse(ctx, identifier, ret.Metadata, 
ret.MetadataLoc, config, scanPlanningConfig, credsVended, vended, ret.Labels, 
table.WithSavedConfig(saved), 
table.WithScanPlanningDirective(r.scanPlanningDirective(ctx, identifier, 
ret.Config)))
 }
 
 func (r *Catalog) UpdateTable(ctx context.Context, ident table.Identifier, 
requirements []table.Requirement, updates []table.Update) (*table.Table, error) 
{
@@ -2065,8 +2101,13 @@ func (r *Catalog) UpdateTable(ctx context.Context, ident 
table.Identifier, requi
        config := maps.Clone(r.props)
        maps.Copy(config, metadata.Properties())
 
-       // A commit response carries no labels (they are load-time enrichment).
-       return r.tableFromResponse(ctx, ident, metadata, ret.MetadataLoc, 
config, config, false, nil, nil, table.WithSavedConfig(metadata.Properties()))
+       // A commit response carries no labels or table config (they are 
load-time
+       // enrichment). The catalog's own scan-planning-mode property still 
applies,
+       // so the returned table enforces a catalog-wide directive; a per-table
+       // directive from the server's load config needs a reload.
+       return r.tableFromResponse(ctx, ident, metadata, ret.MetadataLoc, 
config, config, false, nil, nil,
+               table.WithSavedConfig(metadata.Properties()),
+               table.WithScanPlanningDirective(r.scanPlanningDirective(ctx, 
ident, nil)))
 }
 
 func (r *Catalog) DropTable(ctx context.Context, identifier table.Identifier) 
error {
diff --git a/catalog/rest/rest_test.go b/catalog/rest/rest_test.go
index d04c958de..ee38c1a7c 100644
--- a/catalog/rest/rest_test.go
+++ b/catalog/rest/rest_test.go
@@ -1773,6 +1773,184 @@ func (r *RestCatalogSuite) TestLoadTableLabels() {
        r.Nil(labels.Field(1))                                                  
   // field present in schema but unlabeled
 }
 
+func (r *RestCatalogSuite) TestLoadTableScanPlanningDirective() {
+       for _, tc := range []struct {
+               name       string
+               config     string
+               properties string
+               catOpts    []rest.Option
+               want       table.ScanPlanningDirective
+               wantErr    bool
+       }{
+               {name: "absent", config: `{}`, want: 
table.ScanPlanningDirectiveNone},
+               {name: "client", config: `{"scan-planning-mode": "client"}`, 
want: table.ScanPlanningDirectiveClient},
+               {name: "server", config: `{"scan-planning-mode": "server"}`, 
want: table.ScanPlanningDirectiveServer},
+               {name: "unrecognized", config: `{"scan-planning-mode": 
"bogus"}`, want: table.ScanPlanningDirectiveNone, wantErr: true},
+               // Table metadata properties never carry the directive.
+               {name: "metadata_property_only", config: `{}`, properties: 
`{"scan-planning-mode": "server"}`, want: table.ScanPlanningDirectiveNone},
+               // As in Java, the catalog's own property applies when the 
server sends
+               // none, and the server's value wins when both are present.
+               {
+                       name:    "catalog_property_only",
+                       config:  `{}`,
+                       catOpts: 
[]rest.Option{rest.WithAdditionalProps(iceberg.Properties{"scan-planning-mode": 
"client"})},
+                       want:    table.ScanPlanningDirectiveClient,
+               },
+               {
+                       name:    "config_wins_over_catalog_property",
+                       config:  `{"scan-planning-mode": "server"}`,
+                       catOpts: 
[]rest.Option{rest.WithAdditionalProps(iceberg.Properties{"scan-planning-mode": 
"client"})},
+                       want:    table.ScanPlanningDirectiveServer,
+               },
+               {
+                       name:       "config_wins_over_metadata_property",
+                       config:     `{"scan-planning-mode": "client"}`,
+                       properties: `{"scan-planning-mode": "server"}`,
+                       want:       table.ScanPlanningDirectiveClient,
+               },
+       } {
+               r.Run(tc.name, func() {
+                       tableName := "table_" + tc.name
+                       properties := tc.properties
+                       if properties == "" {
+                               properties = `{}`
+                       }
+                       
r.mux.HandleFunc("/v1/namespaces/fokko/tables/"+tableName, func(w 
http.ResponseWriter, req *http.Request) {
+                               r.Require().Equal(http.MethodGet, req.Method)
+                               w.Write([]byte(`{
+                                       "metadata-location": 
"s3://warehouse/database/table/metadata/00001.metadata.json",
+                                       "metadata": {
+                                               "format-version": 1,
+                                               "table-uuid": 
"b55d9dda-6561-423a-8bfc-787980ce421f",
+                                               "location": 
"s3://warehouse/database/table",
+                                               "last-updated-ms": 
1646787054459,
+                                               "last-column-id": 2,
+                                               "schema": 
{"type":"struct","schema-id":0,"fields":[{"id":1,"name":"id","required":false,"type":"int"},{"id":2,"name":"data","required":false,"type":"string"}]},
+                                               "current-schema-id": 0,
+                                               "schemas": 
[{"type":"struct","schema-id":0,"fields":[{"id":1,"name":"id","required":false,"type":"int"},{"id":2,"name":"data","required":false,"type":"string"}]}],
+                                               "partition-spec": [],
+                                               "default-spec-id": 0,
+                                               "partition-specs": 
[{"spec-id":0,"fields":[]}],
+                                               "last-partition-id": 999,
+                                               "default-sort-order-id": 0,
+                                               "sort-orders": 
[{"order-id":0,"fields":[]}],
+                                               "properties": ` + properties + `
+                                       },
+                                       "config": ` + tc.config + `
+                               }`))
+                       })
+
+                       cat, err := rest.NewCatalog(context.Background(), 
"rest", r.srv.URL,
+                               
append([]rest.Option{rest.WithOAuthToken(TestToken)}, tc.catOpts...)...)
+                       r.Require().NoError(err)
+
+                       tbl, err := cat.LoadTable(context.Background(), 
catalog.ToIdentifier("fokko", tableName))
+                       r.Require().NoError(err)
+                       got, err := tbl.ScanPlanningDirective()
+                       if tc.wantErr {
+                               r.ErrorIs(err, iceberg.ErrInvalidArgument)
+                               r.Equal(tc.want, got)
+
+                               return
+                       }
+                       r.Require().NoError(err)
+                       r.Equal(tc.want, got)
+               })
+       }
+}
+
+// TestCreateAndRegisterTableScanPlanningDirective pins that the other load
+// response paths read the directive from the config block too.
+func (r *RestCatalogSuite) TestCreateAndRegisterTableScanPlanningDirective() {
+       const metadataLoc = 
"s3://warehouse/database/table/metadata/00001.metadata.json"
+
+       for _, op := range []struct {
+               name string
+               path string
+               call func(cat *rest.Catalog, ident table.Identifier) 
(*table.Table, error)
+       }{
+               {
+                       name: "create",
+                       path: "/v1/namespaces/fokko/tables",
+                       call: func(cat *rest.Catalog, ident table.Identifier) 
(*table.Table, error) {
+                               return cat.CreateTable(context.Background(), 
ident, tableSchemaSimple)
+                       },
+               },
+               {
+                       name: "register",
+                       path: "/v1/namespaces/fokko/register",
+                       call: func(cat *rest.Catalog, ident table.Identifier) 
(*table.Table, error) {
+                               return cat.RegisterTable(context.Background(), 
ident, metadataLoc)
+                       },
+               },
+       } {
+               for _, tc := range []struct {
+                       name       string
+                       config     string
+                       properties string
+                       want       table.ScanPlanningDirective
+                       wantErr    bool
+               }{
+                       {name: "absent", config: `{}`, properties: `{}`, want: 
table.ScanPlanningDirectiveNone},
+                       {name: "server", config: `{"scan-planning-mode": 
"server"}`, properties: `{}`, want: table.ScanPlanningDirectiveServer},
+                       {name: "unrecognized", config: `{"scan-planning-mode": 
"bogus"}`, properties: `{}`, wantErr: true},
+                       {name: "metadata_property_only", config: `{}`, 
properties: `{"scan-planning-mode": "server"}`, want: 
table.ScanPlanningDirectiveNone},
+               } {
+                       r.Run(op.name+"_"+tc.name, func() {
+                               mux := http.NewServeMux()
+                               srv := httptest.NewServer(mux)
+                               defer srv.Close()
+
+                               mux.HandleFunc("/v1/config", func(w 
http.ResponseWriter, _ *http.Request) {
+                                       
json.NewEncoder(w).Encode(map[string]any{
+                                               "defaults":  map[string]any{},
+                                               "overrides": map[string]any{},
+                                               "endpoints": 
rest.AllEndpointStrings,
+                                       })
+                               })
+                               mux.HandleFunc(op.path, func(w 
http.ResponseWriter, req *http.Request) {
+                                       r.Require().Equal(http.MethodPost, 
req.Method)
+                                       w.Write([]byte(`{
+                                               "metadata-location": "` + 
metadataLoc + `",
+                                               "metadata": {
+                                                       "format-version": 1,
+                                                       "table-uuid": 
"b55d9dda-6561-423a-8bfc-787980ce421f",
+                                                       "location": 
"s3://warehouse/database/table",
+                                                       "last-updated-ms": 
1646787054459,
+                                                       "last-column-id": 2,
+                                                       "schema": 
{"type":"struct","schema-id":0,"fields":[{"id":1,"name":"id","required":false,"type":"int"},{"id":2,"name":"data","required":false,"type":"string"}]},
+                                                       "current-schema-id": 0,
+                                                       "schemas": 
[{"type":"struct","schema-id":0,"fields":[{"id":1,"name":"id","required":false,"type":"int"},{"id":2,"name":"data","required":false,"type":"string"}]}],
+                                                       "partition-spec": [],
+                                                       "default-spec-id": 0,
+                                                       "partition-specs": 
[{"spec-id":0,"fields":[]}],
+                                                       "last-partition-id": 
999,
+                                                       
"default-sort-order-id": 0,
+                                                       "sort-orders": 
[{"order-id":0,"fields":[]}],
+                                                       "properties": ` + 
tc.properties + `
+                                               },
+                                               "config": ` + tc.config + `
+                                       }`))
+                               })
+
+                               cat, err := 
rest.NewCatalog(context.Background(), "rest", srv.URL, 
rest.WithOAuthToken(TestToken))
+                               r.Require().NoError(err)
+
+                               tbl, err := op.call(cat, 
catalog.ToIdentifier("fokko", "fokko2"))
+                               r.Require().NoError(err)
+                               got, err := tbl.ScanPlanningDirective()
+                               if tc.wantErr {
+                                       r.ErrorIs(err, 
iceberg.ErrInvalidArgument)
+
+                                       return
+                               }
+                               r.Require().NoError(err)
+                               r.Equal(tc.want, got)
+                       })
+               }
+       }
+}
+
 func (r *RestCatalogSuite) TestCreateTableLabels() {
        r.mux.HandleFunc("/v1/namespaces/fokko/tables", func(w 
http.ResponseWriter, req *http.Request) {
                if !r.Equal(http.MethodPost, req.Method) {
@@ -4489,3 +4667,195 @@ func TestEndpointNegotiation(t *testing.T) {
        }
        assert.False(t, tablesHit)
 }
+
+// directiveTestMetadata is minimal table metadata for the scan-planning
+// directive tests.
+const directiveTestMetadata = `{
+       "format-version": 1,
+       "table-uuid": "b55d9dda-6561-423a-8bfc-787980ce421f",
+       "location": "s3://warehouse/database/table",
+       "last-updated-ms": 1646787054459,
+       "last-column-id": 2,
+       "schema": 
{"type":"struct","schema-id":0,"fields":[{"id":1,"name":"id","required":false,"type":"int"},{"id":2,"name":"data","required":false,"type":"string"}]},
+       "current-schema-id": 0,
+       "schemas": 
[{"type":"struct","schema-id":0,"fields":[{"id":1,"name":"id","required":false,"type":"int"},{"id":2,"name":"data","required":false,"type":"string"}]}],
+       "partition-spec": [],
+       "default-spec-id": 0,
+       "partition-specs": [{"spec-id":0,"fields":[]}],
+       "last-partition-id": 999,
+       "default-sort-order-id": 0,
+       "sort-orders": [{"order-id":0,"fields":[]}],
+       "properties": {}
+}`
+
+// A server directive without the plan endpoint must not block loading or
+// metadata-only use of the table; only planning a scan fails (#2102).
+func TestLoadTableServerDirectiveWithoutPlanEndpoint(t *testing.T) {
+       planning := map[string]bool{
+               "POST /v1/{prefix}/namespaces/{namespace}/tables/{table}/plan": 
            true,
+               "GET 
/v1/{prefix}/namespaces/{namespace}/tables/{table}/plan/{plan-id}":    true,
+               "DELETE 
/v1/{prefix}/namespaces/{namespace}/tables/{table}/plan/{plan-id}": true,
+               "POST 
/v1/{prefix}/namespaces/{namespace}/tables/{table}/tasks":            true,
+       }
+       var endpoints []string
+       for _, e := range rest.AllEndpointStrings {
+               if !planning[e] {
+                       endpoints = append(endpoints, e)
+               }
+       }
+       require.Len(t, endpoints, len(rest.AllEndpointStrings)-len(planning), 
"planning endpoint strings changed")
+
+       mux := http.NewServeMux()
+       mux.HandleFunc("/v1/config", func(w http.ResponseWriter, _ 
*http.Request) {
+               require.NoError(t, json.NewEncoder(w).Encode(map[string]any{
+                       "defaults": map[string]any{}, "overrides": 
map[string]any{}, "endpoints": endpoints,
+               }))
+       })
+       mux.HandleFunc("/v1/namespaces/fokko/tables/tbl", func(w 
http.ResponseWriter, _ *http.Request) {
+               _, err := w.Write([]byte(`{
+                       "metadata-location": 
"s3://warehouse/database/table/metadata/00001.metadata.json",
+                       "metadata": ` + directiveTestMetadata + `,
+                       "config": {"scan-planning-mode": "server"}
+               }`))
+               require.NoError(t, err)
+       })
+       srv := httptest.NewServer(mux)
+       defer srv.Close()
+
+       cat, err := rest.NewCatalog(context.Background(), "rest", srv.URL, 
rest.WithOAuthToken(TestToken))
+       require.NoError(t, err)
+       require.False(t, cat.SupportsPlanTableScan())
+
+       tbl, err := cat.LoadTable(context.Background(), 
catalog.ToIdentifier("fokko", "tbl"))
+       require.NoError(t, err)
+       assert.Len(t, tbl.Schema().Fields(), 2)
+       directive, err := tbl.ScanPlanningDirective()
+       require.NoError(t, err)
+       assert.Equal(t, table.ScanPlanningDirectiveServer, directive)
+
+       _, err = tbl.Scan().PlanFiles(context.Background())
+       require.ErrorIs(t, err, table.ErrInvalidOperation)
+       assert.ErrorContains(t, err, "requires server-side scan planning")
+}
+
+// A catalog commonly advertises scan-planning-mode through the /v1/config
+// defaults block rather than client options. Tables from both LoadTable and
+// UpdateTable, whose commit response carries no table config, must enforce it.
+func TestCatalogConfigScanPlanningDirective(t *testing.T) {
+       const metadataLoc = 
"s3://warehouse/database/table/metadata/00001.metadata.json"
+
+       mux := http.NewServeMux()
+       mux.HandleFunc("/v1/config", func(w http.ResponseWriter, _ 
*http.Request) {
+               require.NoError(t, json.NewEncoder(w).Encode(map[string]any{
+                       "defaults":  map[string]any{"scan-planning-mode": 
"server"},
+                       "overrides": map[string]any{},
+               }))
+       })
+       mux.HandleFunc("/v1/namespaces/fokko/tables/tbl", func(w 
http.ResponseWriter, req *http.Request) {
+               body := `{"metadata-location": "` + metadataLoc + `", 
"metadata": ` + directiveTestMetadata
+               if req.Method == http.MethodGet {
+                       body += `, "config": {}`
+               }
+               _, err := w.Write([]byte(body + `}`))
+               require.NoError(t, err)
+       })
+       srv := httptest.NewServer(mux)
+       defer srv.Close()
+
+       cat, err := rest.NewCatalog(context.Background(), "rest", srv.URL, 
rest.WithOAuthToken(TestToken))
+       require.NoError(t, err)
+       ident := catalog.ToIdentifier("fokko", "tbl")
+
+       loaded, err := cat.LoadTable(context.Background(), ident)
+       require.NoError(t, err)
+       updated, err := cat.UpdateTable(context.Background(), ident, nil, nil)
+       require.NoError(t, err)
+
+       for name, tbl := range map[string]*table.Table{"load": loaded, 
"update": updated} {
+               t.Run(name, func(t *testing.T) {
+                       directive, err := tbl.ScanPlanningDirective()
+                       require.NoError(t, err)
+                       assert.Equal(t, table.ScanPlanningDirectiveServer, 
directive)
+
+                       _, err = tbl.Scan().PlanFiles(context.Background())
+                       require.ErrorIs(t, err, table.ErrInvalidOperation)
+                       assert.ErrorContains(t, err, "requires server-side scan 
planning")
+               })
+       }
+}
+
+// A commit response carries no table config, so UpdateTable keeps only the
+// catalog's own scan-planning-mode property and drops the per-table directive
+// from the load config. This pins both directions: a per-table server 
directive
+// is dropped, and a per-table client directive under a catalog-wide server
+// directive comes back as server.
+func TestUpdateTableKeepsOnlyCatalogScanPlanningDirective(t *testing.T) {
+       const metadataLoc = 
"s3://warehouse/database/table/metadata/00001.metadata.json"
+
+       for _, tc := range []struct {
+               name        string
+               catalogMode string
+               tableConfig string
+               wantLoaded  table.ScanPlanningDirective
+               wantUpdated table.ScanPlanningDirective
+       }{
+               {
+                       name:        "per-table server is dropped",
+                       tableConfig: `{"scan-planning-mode": "server"}`,
+                       wantLoaded:  table.ScanPlanningDirectiveServer,
+                       wantUpdated: table.ScanPlanningDirectiveNone,
+               },
+               {
+                       name:        "per-table client under catalog server 
becomes server",
+                       catalogMode: "server",
+                       tableConfig: `{"scan-planning-mode": "client"}`,
+                       wantLoaded:  table.ScanPlanningDirectiveClient,
+                       wantUpdated: table.ScanPlanningDirectiveServer,
+               },
+       } {
+               t.Run(tc.name, func(t *testing.T) {
+                       mux := http.NewServeMux()
+                       mux.HandleFunc("/v1/config", func(w 
http.ResponseWriter, _ *http.Request) {
+                               require.NoError(t, 
json.NewEncoder(w).Encode(map[string]any{
+                                       "defaults": map[string]any{}, 
"overrides": map[string]any{},
+                               }))
+                       })
+                       mux.HandleFunc("/v1/namespaces/fokko/tables/tbl", 
func(w http.ResponseWriter, req *http.Request) {
+                               body := `{"metadata-location": "` + metadataLoc 
+ `", "metadata": ` + directiveTestMetadata
+                               if req.Method == http.MethodGet {
+                                       body += `, "config": ` + tc.tableConfig
+                               }
+                               _, err := w.Write([]byte(body + `}`))
+                               require.NoError(t, err)
+                       })
+                       srv := httptest.NewServer(mux)
+                       defer srv.Close()
+
+                       opts := []rest.Option{rest.WithOAuthToken(TestToken)}
+                       if tc.catalogMode != "" {
+                               opts = append(opts, 
rest.WithAdditionalProps(iceberg.Properties{"scan-planning-mode": 
tc.catalogMode}))
+                       }
+                       cat, err := rest.NewCatalog(context.Background(), 
"rest", srv.URL, opts...)
+                       require.NoError(t, err)
+                       ident := catalog.ToIdentifier("fokko", "tbl")
+
+                       loaded, err := cat.LoadTable(context.Background(), 
ident)
+                       require.NoError(t, err)
+                       got, err := loaded.ScanPlanningDirective()
+                       require.NoError(t, err)
+                       assert.Equal(t, tc.wantLoaded, got)
+
+                       updated, err := cat.UpdateTable(context.Background(), 
ident, nil, nil)
+                       require.NoError(t, err)
+                       got, err = updated.ScanPlanningDirective()
+                       require.NoError(t, err)
+                       assert.Equal(t, tc.wantUpdated, got)
+
+                       if tc.wantUpdated == table.ScanPlanningDirectiveServer {
+                               _, err = 
updated.Scan(table.WithScanPlanningMode(table.ScanPlanningLocal)).PlanFiles(context.Background())
+                               require.ErrorIs(t, err, 
table.ErrInvalidOperation)
+                               assert.ErrorContains(t, err, "requires 
server-side scan planning")
+                       }
+               })
+       }
+}
diff --git a/catalog/rest/scan_planning_directive_test.go 
b/catalog/rest/scan_planning_directive_test.go
new file mode 100644
index 000000000..6ac174c27
--- /dev/null
+++ b/catalog/rest/scan_planning_directive_test.go
@@ -0,0 +1,101 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+package rest
+
+import (
+       "bytes"
+       "context"
+       "log/slog"
+       "strings"
+       "testing"
+
+       "github.com/apache/iceberg-go"
+       "github.com/apache/iceberg-go/table"
+       "github.com/stretchr/testify/assert"
+)
+
+// captureSlog replaces the default slog logger for the rest of the test. Tests
+// that use it must not be parallel.
+func captureSlog(t *testing.T) *bytes.Buffer {
+       t.Helper()
+
+       var logs bytes.Buffer
+       prev := slog.Default()
+       slog.SetDefault(slog.New(slog.NewTextHandler(&logs, 
&slog.HandlerOptions{Level: slog.LevelDebug})))
+       t.Cleanup(func() { slog.SetDefault(prev) })
+
+       return &logs
+}
+
+func TestScanPlanningDirective(t *testing.T) {
+       logs := captureSlog(t)
+
+       key := table.ScanPlanningModeKey
+       for _, tc := range []struct {
+               name        string
+               catalog     iceberg.Properties
+               server      iceberg.Properties
+               want        string
+               wantPresent bool
+               wantWarn    bool
+       }{
+               {name: "neither"},
+               {name: "server only", server: iceberg.Properties{key: 
"server"}, want: "server", wantPresent: true},
+               {name: "catalog only", catalog: iceberg.Properties{key: 
"client"}, want: "client", wantPresent: true},
+               {name: "empty server value", server: iceberg.Properties{key: 
""}, want: "", wantPresent: true},
+               {name: "agree", catalog: iceberg.Properties{key: "SERVER"}, 
server: iceberg.Properties{key: "server"}, want: "server", wantPresent: true},
+               {name: "mismatch", catalog: iceberg.Properties{key: "client"}, 
server: iceberg.Properties{key: "server"}, want: "server", wantPresent: true, 
wantWarn: true},
+       } {
+               t.Run(tc.name, func(t *testing.T) {
+                       logs.Reset()
+                       r := &Catalog{props: tc.catalog}
+
+                       got, present := 
r.scanPlanningDirective(context.Background(), []string{"db", "tbl"}, tc.server)
+                       assert.Equal(t, tc.want, got)
+                       assert.Equal(t, tc.wantPresent, present)
+                       if tc.wantWarn {
+                               assert.Contains(t, logs.String(), "level=WARN")
+                               assert.Contains(t, logs.String(), 
"scan-planning-mode mismatch")
+                               assert.Contains(t, logs.String(), 
"table=db.tbl")
+                       } else {
+                               assert.Empty(t, logs.String())
+                       }
+               })
+       }
+}
+
+// Refresh reloads the table, so a stable mismatch must not warn on every load.
+func TestScanPlanningDirectiveMismatchWarnsOncePerCatalog(t *testing.T) {
+       logs := captureSlog(t)
+
+       key := table.ScanPlanningModeKey
+       r := &Catalog{props: iceberg.Properties{key: "client"}}
+       for range 3 {
+               r.scanPlanningDirective(context.Background(), []string{"db", 
"tbl"}, iceberg.Properties{key: "server"})
+       }
+
+       out := logs.String()
+       assert.Equal(t, 1, strings.Count(out, "level=WARN"), out)
+       assert.Equal(t, 2, strings.Count(out, "level=DEBUG"), out)
+
+       // A separate catalog warns again.
+       logs.Reset()
+       other := &Catalog{props: iceberg.Properties{key: "client"}}
+       other.scanPlanningDirective(context.Background(), []string{"db", 
"tbl"}, iceberg.Properties{key: "server"})
+       assert.Contains(t, logs.String(), "level=WARN")
+}
diff --git a/table/incremental_append_scan.go b/table/incremental_append_scan.go
index ed77dbe1b..d682d4ca8 100644
--- a/table/incremental_append_scan.go
+++ b/table/incremental_append_scan.go
@@ -47,8 +47,9 @@ type IncrementalAppendScan struct {
 // sensitivity, concurrency, planning mode, and reporting. Projection and row
 // limits are not applied to the returned file tasks; callers can read planned
 // tasks with a separately configured [Scan.ReadTasks]. Auto planning falls 
back
-// to local planning. Remote planning returns ErrInvalidOperation until
-// incremental remote planning is implemented.
+// to local planning. Remote planning, including a catalog `server`
+// scan-planning-mode directive, returns ErrInvalidOperation until incremental
+// remote planning is implemented.
 func (t Table) NewIncrementalAppendScan(opts ...ScanOption) 
*IncrementalAppendScan {
        return &IncrementalAppendScan{scan: t.Scan(opts...)}
 }
@@ -86,12 +87,8 @@ func (s *IncrementalAppendScan) ToSnapshot(snapshotID int64) 
*IncrementalAppendS
 // through the configured reporter on successful planning. Delete files are not
 // applied because appended files are not present before the append snapshot.
 func (s *IncrementalAppendScan) PlanFiles(ctx context.Context) 
([]FileScanTask, error) {
-       switch s.scan.planningMode {
-       case ScanPlanningLocal, ScanPlanningAuto:
-       case ScanPlanningRemote:
-               return nil, fmt.Errorf("%w: incremental append scans do not 
support remote planning", ErrInvalidOperation)
-       default:
-               return nil, fmt.Errorf("%w: unknown scan planning mode %q", 
iceberg.ErrInvalidArgument, s.scan.planningMode)
+       if err := s.scan.checkLocalOnlyPlanning("incremental append"); err != 
nil {
+               return nil, err
        }
        start := time.Now()
 
diff --git a/table/incremental_changelog_scan.go 
b/table/incremental_changelog_scan.go
index 8b8006adc..f3b418102 100644
--- a/table/incremental_changelog_scan.go
+++ b/table/incremental_changelog_scan.go
@@ -48,7 +48,8 @@ type plannedChangelogTask struct {
 
 // NewIncrementalChangelogScan creates an incremental changelog planner.
 // Projection and row limits are not applied to returned tasks. Auto planning
-// falls back to local planning, while remote planning is not supported. Use
+// falls back to local planning, while remote planning, including a catalog
+// `server` scan-planning-mode directive, is not supported. Use
 // ChangelogScanTask.ScanTask with Scan.ReadTasks to read the returned files.
 // Row filters are attached to each task as residuals without 
partition-specific
 // simplification, matching the existing incremental append scan behavior.
@@ -102,12 +103,8 @@ func (s *IncrementalChangelogScan) PlanFiles(ctx 
context.Context) ([]ChangelogSc
                return nil, err
        }
 
-       switch s.scan.planningMode {
-       case ScanPlanningLocal, ScanPlanningAuto:
-       case ScanPlanningRemote:
-               return nil, fmt.Errorf("%w: incremental changelog scans do not 
support remote planning", ErrInvalidOperation)
-       default:
-               return nil, fmt.Errorf("%w: unknown scan planning mode %q", 
iceberg.ErrInvalidArgument, s.scan.planningMode)
+       if err := s.scan.checkLocalOnlyPlanning("incremental changelog"); err 
!= nil {
+               return nil, err
        }
        start := time.Now()
 
diff --git a/table/scan_planning.go b/table/scan_planning.go
index ddf31dfb2..f27c29195 100644
--- a/table/scan_planning.go
+++ b/table/scan_planning.go
@@ -23,27 +23,32 @@ package table
 
 import (
        "context"
+       "fmt"
        "io"
+       "strings"
 
        "github.com/apache/iceberg-go"
        icebergio "github.com/apache/iceberg-go/io"
 )
 
 // ScanPlanningMode is the user-facing scan option: three values
-// (local/remote/auto) selecting how (*Scan).PlanFiles plans a scan. Local
-// planning remains the default; remote is opt-in via WithScanPlanningMode.
+// (local/remote/auto) selecting how (*Scan).PlanFiles plans a scan. Without a
+// catalog directive, local planning is the default and remote is opt-in via
+// WithScanPlanningMode.
 //
-// This is deliberately distinct from the REST table-config key
-// `scan-planning-mode` (values `client`/`server`), which is a server directive
-// resolved separately (OQ4): a `client` table forces local planning, a 
`server`
-// table forces remote planning, and explicit conflicting scan options fail
-// fast. There is intentionally no fourth `server` value here; the directive
-// lives in the table config, not the user option.
+// This is deliberately distinct from the catalog's `scan-planning-mode`
+// directive (values `client`/`server`, see ScanPlanningDirective), which
+// (*Scan).PlanFiles enforces: a `server` table plans remotely and a `client`
+// table plans locally, whatever the default or ScanPlanningAuto would choose.
+// A scan that explicitly selects the conflicting mode fails. Incremental scans
+// only plan locally, so they fail under a `server` directive. Scans inside a
+// transaction plan staged metadata locally and do not apply the directive.
 type ScanPlanningMode string
 
 const (
        // ScanPlanningLocal always plans locally by reading manifests through 
the
-       // table's FileIO. This is the default and current behavior.
+       // table's FileIO. This is the default unless the catalog requires
+       // server-side planning.
        ScanPlanningLocal ScanPlanningMode = "local"
        // ScanPlanningRemote requires a planner that advertises remote 
capability
        // and fails loudly if remote planning is unavailable.
@@ -53,10 +58,169 @@ const (
        ScanPlanningAuto ScanPlanningMode = "auto"
 )
 
+// ScanPlanningModeKey is the REST table-config key a catalog uses to tell
+// clients which planning mode a table supports. Valid values are `client` and
+// `server`.
+const ScanPlanningModeKey = "scan-planning-mode"
+
+// ScanPlanningDirective is the catalog's `scan-planning-mode` table-config
+// value. It is distinct from ScanPlanningMode, the user-facing scan option.
+type ScanPlanningDirective string
+
+const (
+       // ScanPlanningDirectiveNone is the zero value. When returned with a nil
+       // error from Table.ScanPlanningDirective, it means the catalog 
supplied no
+       // directive and does not constrain where scans are planned. It is also
+       // returned alongside a non-nil error for an unrecognized directive, in
+       // which case it carries no meaning.
+       ScanPlanningDirectiveNone ScanPlanningDirective = ""
+       // ScanPlanningDirectiveClient indicates the catalog requires the 
client to
+       // plan scans locally.
+       ScanPlanningDirectiveClient ScanPlanningDirective = "client"
+       // ScanPlanningDirectiveServer indicates the catalog requires scans to 
be
+       // planned remotely by the catalog server.
+       ScanPlanningDirectiveServer ScanPlanningDirective = "server"
+)
+
+// ScanPlanningDirective returns the catalog's `scan-planning-mode` directive
+// recorded when the table was loaded. For a REST catalog this is the value in
+// the `config` block of the table-load response, or, when the server sends
+// none, the catalog's own `scan-planning-mode` property. Table metadata
+// properties are not consulted. Values are matched case-insensitively.
+//
+// It returns ScanPlanningDirectiveNone and a nil error when the catalog
+// supplied no directive. Any value other than `client` or `server`, including
+// an empty value or a mode introduced by a future version of the REST spec,
+// returns an error wrapping iceberg.ErrInvalidArgument; callers should treat
+// that as a directive they cannot honor rather than as the absence of one.
+//
+// The directive is only known for tables built from a load response
+// (LoadTable, CreateTable, RegisterTable, and their refreshes); tables
+// returned by Transaction.Commit or StagedTable keep the directive of the
+// table they were derived from. Commit responses do not carry table config, so
+// a table returned by a REST catalog's UpdateTable reports only the catalog's
+// own `scan-planning-mode` property; reload the table to obtain a per-table
+// directive.
+//
+// (*Scan).PlanFiles enforces the directive; see ScanPlanningMode.
+func (t Table) ScanPlanningDirective() (ScanPlanningDirective, error) {
+       if !t.hasScanPlanningDirective {
+               return ScanPlanningDirectiveNone, nil
+       }
+
+       switch d := 
ScanPlanningDirective(strings.ToLower(t.scanPlanningDirective)); d {
+       case ScanPlanningDirectiveClient, ScanPlanningDirectiveServer:
+               return d, nil
+       default:
+               return ScanPlanningDirectiveNone, fmt.Errorf("%w: unrecognized 
%s %q, expected %q or %q",
+                       iceberg.ErrInvalidArgument, ScanPlanningModeKey, 
t.scanPlanningDirective,
+                       ScanPlanningDirectiveClient, 
ScanPlanningDirectiveServer)
+       }
+}
+
+// WithScanPlanningDirective records the raw `scan-planning-mode` value from a
+// catalog's table-load response config. ok reports whether the key was
+// present, so callers can pass a comma-ok map read straight through:
+//
+//     v, ok := cfg[table.ScanPlanningModeKey]
+//     table.WithScanPlanningDirective(v, ok)
+//
+// When ok is false the table reports no directive. A present but empty value
+// is reported as unrecognized.
+func WithScanPlanningDirective(value string, ok bool) Option {
+       return func(t *Table) {
+               t.scanPlanningDirective = value
+               t.hasScanPlanningDirective = ok
+       }
+}
+
+// WithScanPlanningDirectiveFrom copies src's scan-planning directive verbatim,
+// including whether one was present and any unrecognized raw value. Catalogs
+// use it when rebuilding a table from a response that carries no table config,
+// such as a credential refresh. A nil src leaves the directive unchanged.
+func WithScanPlanningDirectiveFrom(src *Table) Option {
+       if src == nil {
+               return func(*Table) {}
+       }
+
+       return WithScanPlanningDirective(src.scanPlanningDirective, 
src.hasScanPlanningDirective)
+}
+
 // WithScanPlanningMode sets the scan-planning mode for a scan. The default is
-// ScanPlanningLocal.
+// ScanPlanningLocal, or the mode the catalog's directive requires. Explicitly
+// selecting a mode that conflicts with the directive makes planning fail.
 func WithScanPlanningMode(mode ScanPlanningMode) ScanOption {
-       return func(scan *Scan) { scan.planningMode = mode }
+       return func(scan *Scan) {
+               scan.planningMode = mode
+               scan.planningModeSet = true
+       }
+}
+
+// effectivePlanningMode applies the catalog's scan-planning-mode directive to
+// the scan's planning mode. A `server` directive selects remote planning and a
+// `client` directive selects local planning, unless the scan explicitly asked
+// for the other one, which is an error. The directive is checked here rather
+// than when the table is loaded, so metadata-only use of a table keeps working
+// whatever the catalog advertises.
+func (scan *Scan) effectivePlanningMode() (ScanPlanningMode, error) {
+       switch scan.planningMode {
+       case ScanPlanningLocal, ScanPlanningRemote, ScanPlanningAuto:
+       default:
+               return "", fmt.Errorf("%w: unknown scan planning mode %q", 
iceberg.ErrInvalidArgument, scan.planningMode)
+       }
+
+       if scan.directiveErr != nil {
+               return "", scan.directiveErr
+       }
+
+       switch scan.directive {
+       case ScanPlanningDirectiveServer:
+               if scan.planningModeSet && scan.planningMode == 
ScanPlanningLocal {
+                       return "", fmt.Errorf("%w: the catalog requires 
server-side scan planning (%s=%s) but the scan selected %s planning",
+                               ErrInvalidOperation, ScanPlanningModeKey, 
ScanPlanningDirectiveServer, ScanPlanningLocal)
+               }
+               if scan.planner == nil || 
!scan.planner.SupportsRemoteScanPlanning() {
+                       return "", fmt.Errorf("%w: the catalog requires 
server-side scan planning (%s=%s) but remote scan planning is unavailable",
+                               ErrInvalidOperation, ScanPlanningModeKey, 
ScanPlanningDirectiveServer)
+               }
+
+               return ScanPlanningRemote, nil
+       case ScanPlanningDirectiveClient:
+               if scan.planningModeSet && scan.planningMode == 
ScanPlanningRemote {
+                       return "", fmt.Errorf("%w: the catalog requires 
client-side scan planning (%s=%s) but the scan selected %s planning",
+                               ErrInvalidOperation, ScanPlanningModeKey, 
ScanPlanningDirectiveClient, ScanPlanningRemote)
+               }
+
+               return ScanPlanningLocal, nil
+       default:
+               return scan.planningMode, nil
+       }
+}
+
+// checkLocalOnlyPlanning applies the catalog's scan-planning-mode directive to
+// a scan that can only plan locally, such as an incremental scan. A `server`
+// directive fails rather than reading manifests with the table's own
+// credentials, which the directive exists to prevent. kind names the scan in
+// errors.
+func (scan *Scan) checkLocalOnlyPlanning(kind string) error {
+       mode, err := scan.effectivePlanningMode()
+       if err != nil {
+               return err
+       }
+
+       switch mode {
+       case ScanPlanningLocal, ScanPlanningAuto:
+               return nil
+       case ScanPlanningRemote:
+               if scan.directive == ScanPlanningDirectiveServer {
+                       return fmt.Errorf("%w: the catalog requires server-side 
scan planning (%s=%s) but %s scans do not support remote planning",
+                               ErrInvalidOperation, ScanPlanningModeKey, 
ScanPlanningDirectiveServer, kind)
+               }
+
+               return fmt.Errorf("%w: %s scans do not support remote 
planning", ErrInvalidOperation, kind)
+       default:
+               return fmt.Errorf("%w: unknown scan planning mode %q", 
iceberg.ErrInvalidArgument, mode)
+       }
 }
 
 // ScanPlanningMetadata is the subset of table.Metadata a ScanPlanner needs:
@@ -171,7 +335,7 @@ type FullRemoteScanPlanner interface {
 //
 //     type Scan struct {
 //             // ...existing fields...
-//             planningMode ScanPlanningMode // set by WithScanPlanningMode; 
default ScanPlanningLocal
+//             planningMode ScanPlanningMode // set by WithScanPlanningMode; 
default ScanPlanningLocal, overridden by the catalog directive
 //             planner      ScanPlanner      // non-nil only when the catalog 
supplies one
 //     }
 //
diff --git a/table/scan_planning_directive_wiring_test.go 
b/table/scan_planning_directive_wiring_test.go
new file mode 100644
index 000000000..38319fa34
--- /dev/null
+++ b/table/scan_planning_directive_wiring_test.go
@@ -0,0 +1,160 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+package table
+
+import (
+       "context"
+       "path/filepath"
+       "testing"
+
+       "github.com/apache/iceberg-go"
+       iceio "github.com/apache/iceberg-go/io"
+       "github.com/stretchr/testify/assert"
+       "github.com/stretchr/testify/require"
+)
+
+// directiveStubCatalog hands every LoadTable a table carrying the configured
+// scan-planning directive, so Refresh can be checked to re-hydrate it.
+type directiveStubCatalog struct {
+       directive string
+       present   bool
+}
+
+func (c *directiveStubCatalog) LoadTable(_ context.Context, ident Identifier) 
(*Table, error) {
+       return New(ident, nil, "",
+               func(context.Context) (iceio.IO, error) { return 
iceio.LocalFS{}, nil }, c,
+               WithScanPlanningDirective(c.directive, c.present)), nil
+}
+
+func (c *directiveStubCatalog) CommitTable(context.Context, Identifier, 
[]Requirement, []Update) (Metadata, string, error) {
+       return nil, "", nil
+}
+
+// TestRefreshRehydratesScanPlanningDirective pins that Refresh adopts the
+// directive from the reloaded table, including dropping one the reload no
+// longer carries.
+func TestRefreshRehydratesScanPlanningDirective(t *testing.T) {
+       cat := &directiveStubCatalog{directive: "server", present: true}
+       tbl := New(Identifier{"db", "directive_refresh"}, nil, "",
+               func(context.Context) (iceio.IO, error) { return 
iceio.LocalFS{}, nil }, cat)
+
+       got, err := tbl.ScanPlanningDirective()
+       require.NoError(t, err)
+       require.Equal(t, ScanPlanningDirectiveNone, got)
+
+       require.NoError(t, tbl.Refresh(context.Background()))
+       got, err = tbl.ScanPlanningDirective()
+       require.NoError(t, err)
+       assert.Equal(t, ScanPlanningDirectiveServer, got)
+
+       cat.directive, cat.present = "", false
+       require.NoError(t, tbl.Refresh(context.Background()))
+       got, err = tbl.ScanPlanningDirective()
+       require.NoError(t, err)
+       assert.Equal(t, ScanPlanningDirectiveNone, got)
+}
+
+// TestCommitPreservesScanPlanningDirective pins the commit path: doCommit
+// rebuilds the table via New(...), so the returned table must keep the source
+// table's directive, including an unrecognized raw value.
+func TestCommitPreservesScanPlanningDirective(t *testing.T) {
+       for _, tc := range []struct {
+               name    string
+               raw     string
+               present bool
+               want    ScanPlanningDirective
+               wantErr bool
+       }{
+               {name: "absent", want: ScanPlanningDirectiveNone},
+               {name: "server", raw: "server", present: true, want: 
ScanPlanningDirectiveServer},
+               {name: "unrecognized", raw: "hybrid", present: true, wantErr: 
true},
+       } {
+               t.Run(tc.name, func(t *testing.T) {
+                       schema := iceberg.NewSchema(0,
+                               iceberg.NestedField{ID: 1, Name: "id", Type: 
iceberg.PrimitiveTypes.Int64, Required: true},
+                       )
+                       loc := filepath.ToSlash(t.TempDir())
+                       meta, err := NewMetadata(schema, 
iceberg.UnpartitionedSpec, UnsortedSortOrder, loc,
+                               iceberg.Properties{PropertyFormatVersion: "2"})
+                       require.NoError(t, err)
+
+                       cat := &committingLabelCatalog{metadata: meta}
+                       tbl := New(Identifier{"db", "commit_directive"}, meta, 
loc+"/metadata/v1.metadata.json",
+                               func(context.Context) (iceio.IO, error) { 
return iceio.LocalFS{}, nil }, cat,
+                               WithScanPlanningDirective(tc.raw, tc.present))
+
+                       txn := tbl.NewTransaction()
+                       require.NoError(t, 
txn.SetProperties(iceberg.Properties{"k": "v"}))
+                       committed, err := txn.Commit(context.Background())
+                       require.NoError(t, err)
+
+                       got, err := committed.ScanPlanningDirective()
+                       if tc.wantErr {
+                               require.ErrorIs(t, err, 
iceberg.ErrInvalidArgument)
+                               assert.Contains(t, err.Error(), `"`+tc.raw+`"`)
+
+                               return
+                       }
+                       require.NoError(t, err)
+                       assert.Equal(t, tc.want, got)
+               })
+       }
+}
+
+// TestStagedTablePreservesScanPlanningDirective pins that
+// Transaction.StagedTable() forwards the transaction table's directive; it
+// rebuilds the table via New(...) too.
+func TestStagedTablePreservesScanPlanningDirective(t *testing.T) {
+       for _, tc := range []struct {
+               name    string
+               raw     string
+               present bool
+               want    ScanPlanningDirective
+               wantErr bool
+       }{
+               {name: "absent", want: ScanPlanningDirectiveNone},
+               {name: "client", raw: "client", present: true, want: 
ScanPlanningDirectiveClient},
+               {name: "unrecognized", raw: "hybrid", present: true, wantErr: 
true},
+       } {
+               t.Run(tc.name, func(t *testing.T) {
+                       schema := iceberg.NewSchema(1, iceberg.NestedField{
+                               ID: 1, Name: "id", Type: 
iceberg.PrimitiveTypes.Int64, Required: true,
+                       })
+                       meta, err := NewMetadata(schema, 
iceberg.UnpartitionedSpec, UnsortedSortOrder,
+                               "mem://default/staged", 
iceberg.Properties{PropertyFormatVersion: "2"})
+                       require.NoError(t, err)
+
+                       tbl := New(Identifier{"default", "staged"}, meta, "",
+                               func(context.Context) (iceio.IO, error) { 
return iceio.NewMemFS(), nil }, nil,
+                               WithScanPlanningDirective(tc.raw, tc.present))
+
+                       staged, err := tbl.NewTransaction().StagedTable()
+                       require.NoError(t, err)
+
+                       got, err := staged.ScanPlanningDirective()
+                       if tc.wantErr {
+                               require.ErrorIs(t, err, 
iceberg.ErrInvalidArgument)
+                               assert.Contains(t, err.Error(), `"`+tc.raw+`"`)
+
+                               return
+                       }
+                       require.NoError(t, err)
+                       assert.Equal(t, tc.want, got)
+               })
+       }
+}
diff --git a/table/scan_planning_test.go b/table/scan_planning_test.go
index c0a7cbc9e..f88343539 100644
--- a/table/scan_planning_test.go
+++ b/table/scan_planning_test.go
@@ -741,3 +741,226 @@ func mustPlanIOState(t *testing.T, planIO PlanIO) 
*planIOState {
 
        return state
 }
+
+func TestTableScanPlanningDirective(t *testing.T) {
+       t.Parallel()
+
+       tests := []struct {
+               name    string
+               opts    []Option
+               want    ScanPlanningDirective
+               wantErr bool
+       }{
+               {name: "absent", want: ScanPlanningDirectiveNone},
+               {
+                       name: "io props ignored",
+                       opts: 
[]Option{WithScanPlanningIOProperties(iceberg.Properties{ScanPlanningModeKey: 
"server"})},
+                       want: ScanPlanningDirectiveNone,
+               },
+               {name: "not present", opts: 
[]Option{WithScanPlanningDirective("server", false)}, want: 
ScanPlanningDirectiveNone},
+               {name: "client", opts: 
[]Option{WithScanPlanningDirective("client", true)}, want: 
ScanPlanningDirectiveClient},
+               {name: "server", opts: 
[]Option{WithScanPlanningDirective("server", true)}, want: 
ScanPlanningDirectiveServer},
+               {name: "case insensitive", opts: 
[]Option{WithScanPlanningDirective("SERVER", true)}, want: 
ScanPlanningDirectiveServer},
+               {name: "unrecognized", opts: 
[]Option{WithScanPlanningDirective("remote", true)}, want: 
ScanPlanningDirectiveNone, wantErr: true},
+               {name: "empty value", opts: 
[]Option{WithScanPlanningDirective("", true)}, want: ScanPlanningDirectiveNone, 
wantErr: true},
+               {
+                       name: "later option overrides",
+                       opts: []Option{WithScanPlanningDirective("server", 
true), WithScanPlanningDirective("", false)},
+                       want: ScanPlanningDirectiveNone,
+               },
+       }
+
+       for _, tt := range tests {
+               t.Run(tt.name, func(t *testing.T) {
+                       t.Parallel()
+
+                       tbl := New(Identifier{"db", "tbl"}, nil, "", nil, nil, 
tt.opts...)
+                       got, err := tbl.ScanPlanningDirective()
+                       if tt.wantErr {
+                               require.ErrorIs(t, err, 
iceberg.ErrInvalidArgument)
+                               assert.Equal(t, tt.want, got)
+
+                               return
+                       }
+                       require.NoError(t, err)
+                       assert.Equal(t, tt.want, got)
+               })
+       }
+}
+
+// TestWithScanPlanningDirectiveFrom pins that the directive is copied
+// verbatim, including its presence bit and an unrecognized raw value.
+func TestWithScanPlanningDirectiveFrom(t *testing.T) {
+       t.Parallel()
+
+       for _, tc := range []struct {
+               name string
+               opts []Option
+       }{
+               {name: "absent"},
+               {name: "server", opts: 
[]Option{WithScanPlanningDirective("server", true)}},
+               {name: "unrecognized", opts: 
[]Option{WithScanPlanningDirective("hybrid", true)}},
+               {name: "empty value", opts: 
[]Option{WithScanPlanningDirective("", true)}},
+       } {
+               t.Run(tc.name, func(t *testing.T) {
+                       t.Parallel()
+
+                       src := New(Identifier{"db", "tbl"}, nil, "", nil, nil, 
tc.opts...)
+                       dst := New(Identifier{"db", "tbl"}, nil, "", nil, nil,
+                               WithScanPlanningDirective("client", true), 
WithScanPlanningDirectiveFrom(src))
+
+                       assert.Equal(t, src.scanPlanningDirective, 
dst.scanPlanningDirective)
+                       assert.Equal(t, src.hasScanPlanningDirective, 
dst.hasScanPlanningDirective)
+               })
+       }
+
+       t.Run("nil source", func(t *testing.T) {
+               t.Parallel()
+
+               var dst *Table
+               require.NotPanics(t, func() {
+                       dst = New(Identifier{"db", "tbl"}, nil, "", nil, nil,
+                               WithScanPlanningDirective("client", true), 
WithScanPlanningDirectiveFrom(nil))
+               })
+               assert.Equal(t, "client", dst.scanPlanningDirective)
+               assert.True(t, dst.hasScanPlanningDirective)
+       })
+}
+
+func TestScanEnforcesPlanningDirective(t *testing.T) {
+       t.Parallel()
+
+       remoteTasks := ScanPlanningResult{Tasks: []FileScanTask{{}}}
+       tests := []struct {
+               name      string
+               directive *string
+               opts      []ScanOption
+               supports  bool
+               wantErr   error
+               // wantRemote reports whether the planner was asked to plan.
+               wantRemote bool
+       }{
+               {name: "server default", directive: new("server"), supports: 
true, wantRemote: true},
+               {name: "server auto", directive: new("server"), opts: 
[]ScanOption{WithScanPlanningMode(ScanPlanningAuto)}, supports: true, 
wantRemote: true},
+               {name: "server remote", directive: new("server"), opts: 
[]ScanOption{WithScanPlanningMode(ScanPlanningRemote)}, supports: true, 
wantRemote: true},
+               {name: "server case insensitive", directive: new("SERVER"), 
supports: true, wantRemote: true},
+               {name: "server explicit local", directive: new("server"), opts: 
[]ScanOption{WithScanPlanningMode(ScanPlanningLocal)}, supports: true, wantErr: 
ErrInvalidOperation},
+               {name: "server without capable planner", directive: 
new("server"), supports: false, wantErr: ErrInvalidOperation},
+               {name: "server unknown mode", directive: new("server"), opts: 
[]ScanOption{WithScanPlanningMode("bogus")}, supports: true, wantErr: 
iceberg.ErrInvalidArgument},
+               {name: "client default", directive: new("client"), supports: 
true},
+               {name: "client auto with capable planner", directive: 
new("client"), opts: []ScanOption{WithScanPlanningMode(ScanPlanningAuto)}, 
supports: true},
+               {name: "client explicit remote", directive: new("client"), 
opts: []ScanOption{WithScanPlanningMode(ScanPlanningRemote)}, supports: true, 
wantErr: ErrInvalidOperation},
+               {name: "unrecognized directive", directive: new("bogus"), 
supports: true, wantErr: iceberg.ErrInvalidArgument},
+               {name: "no directive default", supports: true},
+               {name: "no directive auto", opts: 
[]ScanOption{WithScanPlanningMode(ScanPlanningAuto)}, supports: true, 
wantRemote: true},
+       }
+
+       for _, tt := range tests {
+               t.Run(tt.name, func(t *testing.T) {
+                       t.Parallel()
+
+                       txn, _ := createTestTransactionWithMemIO(t, 
*iceberg.UnpartitionedSpec)
+                       tbl := txn.tbl
+                       planner := &fakeScanPlanner{result: remoteTasks, 
supports: tt.supports}
+                       tbl.planner = planner
+                       if tt.directive != nil {
+                               WithScanPlanningDirective(*tt.directive, 
true)(tbl)
+                       }
+
+                       // The directive never blocks building a scan, only 
planning it.
+                       scan := tbl.Scan(tt.opts...)
+                       require.NotNil(t, scan)
+
+                       tasks, err := scan.PlanFiles(context.Background())
+                       if tt.wantErr != nil {
+                               require.ErrorIs(t, err, tt.wantErr)
+                               assert.False(t, planner.called, "planner must 
not be called when planning fails")
+
+                               return
+                       }
+                       require.NoError(t, err)
+                       assert.Equal(t, tt.wantRemote, planner.called)
+                       if tt.wantRemote {
+                               assert.Len(t, tasks, 1)
+                       }
+               })
+       }
+}
+
+func TestTransactionScanIgnoresPlanningDirective(t *testing.T) {
+       t.Parallel()
+
+       txn, _ := createTestTransactionWithMemIO(t, *iceberg.UnpartitionedSpec)
+       planner := &fakeScanPlanner{supports: true}
+       txn.tbl.planner = planner
+       WithScanPlanningDirective("server", true)(txn.tbl)
+
+       scan, err := txn.Scan()
+       require.NoError(t, err)
+       _, err = scan.PlanFiles(context.Background())
+       require.NoError(t, err)
+       assert.False(t, planner.called, "transaction scans plan staged metadata 
locally")
+}
+
+// Incremental scans only plan locally, so a `server` directive must fail them
+// rather than let them read manifests with the table's own credentials.
+func TestIncrementalScansEnforcePlanningDirective(t *testing.T) {
+       t.Parallel()
+
+       scans := []struct {
+               name string
+               plan func(Table, ...ScanOption) error
+       }{
+               {name: "append", plan: func(tbl Table, opts ...ScanOption) 
error {
+                       _, err := 
tbl.NewIncrementalAppendScan(opts...).PlanFiles(context.Background())
+
+                       return err
+               }},
+               {name: "changelog", plan: func(tbl Table, opts ...ScanOption) 
error {
+                       _, err := 
tbl.NewIncrementalChangelogScan(opts...).PlanFiles(context.Background())
+
+                       return err
+               }},
+       }
+       tests := []struct {
+               name      string
+               directive *string
+               opts      []ScanOption
+               wantErr   error
+               wantMsg   string
+       }{
+               {name: "server default", directive: new("server"), wantErr: 
ErrInvalidOperation, wantMsg: "requires server-side scan planning"},
+               {name: "server auto", directive: new("server"), opts: 
[]ScanOption{WithScanPlanningMode(ScanPlanningAuto)}, wantErr: 
ErrInvalidOperation, wantMsg: "requires server-side scan planning"},
+               {name: "server explicit local", directive: new("server"), opts: 
[]ScanOption{WithScanPlanningMode(ScanPlanningLocal)}, wantErr: 
ErrInvalidOperation, wantMsg: "selected local planning"},
+               {name: "client default", directive: new("client")},
+               {name: "client explicit remote", directive: new("client"), 
opts: []ScanOption{WithScanPlanningMode(ScanPlanningRemote)}, wantErr: 
ErrInvalidOperation, wantMsg: "requires client-side scan planning"},
+               {name: "unrecognized directive", directive: new("bogus"), 
wantErr: iceberg.ErrInvalidArgument},
+               {name: "no directive auto", opts: 
[]ScanOption{WithScanPlanningMode(ScanPlanningAuto)}},
+       }
+
+       for _, sc := range scans {
+               for _, tt := range tests {
+                       t.Run(sc.name+"/"+tt.name, func(t *testing.T) {
+                               t.Parallel()
+
+                               txn, _ := createTestTransactionWithMemIO(t, 
*iceberg.UnpartitionedSpec)
+                               tbl := txn.tbl
+                               planner := &fakeScanPlanner{supports: true}
+                               tbl.planner = planner
+                               if tt.directive != nil {
+                                       
WithScanPlanningDirective(*tt.directive, true)(tbl)
+                               }
+
+                               err := sc.plan(*tbl, tt.opts...)
+                               assert.False(t, planner.called, "incremental 
scans never call the remote planner")
+                               if tt.wantErr != nil {
+                                       require.ErrorIs(t, err, tt.wantErr)
+                                       assert.ErrorContains(t, err, tt.wantMsg)
+
+                                       return
+                               }
+                               require.NoError(t, err)
+                       })
+               }
+       }
+}
diff --git a/table/scanner.go b/table/scanner.go
index 2d053e1e8..b3c98e041 100644
--- a/table/scanner.go
+++ b/table/scanner.go
@@ -552,6 +552,13 @@ type Scan struct {
        planner             ScanPlanner
        scanPlanningIOProps iceberg.Properties
        planningMode        ScanPlanningMode
+       // planningModeSet records an explicit WithScanPlanningMode, so a mode 
that
+       // conflicts with the catalog directive fails instead of being 
overridden.
+       planningModeSet bool
+       // directive and directiveErr are the table's scan-planning-mode 
directive
+       // (see ScanPlanningDirective). An unrecognized directive fails 
planning only.
+       directive    ScanPlanningDirective
+       directiveErr error
        // planIO, when non-nil, is a plan-scoped FileIO loader set by remote 
scan
        // planning. ReadTasks leases it instead of falling back to ioF, and 
replacing
        // the plan retires it after all active readers finish. See PlanIO.
@@ -1637,7 +1644,12 @@ func (scan *Scan) planFiles(ctx context.Context, 
projectScanColumns bool) ([]Fil
                scan.asOfTimestamp = nil
        }
 
-       switch scan.planningMode {
+       mode, err := scan.effectivePlanningMode()
+       if err != nil {
+               return nil, err
+       }
+
+       switch mode {
        case ScanPlanningRemote:
                return scan.planFilesRemote(ctx)
        case ScanPlanningAuto:
@@ -1650,7 +1662,9 @@ func (scan *Scan) planFiles(ctx context.Context, 
projectScanColumns bool) ([]Fil
                }
        case ScanPlanningLocal:
        default:
-               return nil, fmt.Errorf("%w: unknown scan planning mode %q", 
iceberg.ErrInvalidArgument, scan.planningMode)
+               // effectivePlanningMode validates the mode first; this keeps a 
mode it
+               // accepts but this switch does not handle from planning 
locally.
+               return nil, fmt.Errorf("%w: unknown scan planning mode %q", 
iceberg.ErrInvalidArgument, mode)
        }
 
        start := time.Now()
diff --git a/table/table.go b/table/table.go
index ab06c9c35..b51424a7d 100644
--- a/table/table.go
+++ b/table/table.go
@@ -111,8 +111,13 @@ type Table struct {
        // REST catalogs can return FileIO configuration in the response's 
config
        // block.
        scanPlanningIOProps iceberg.Properties
-       savedConfig         iceberg.Properties
-       reporter            metrics.Reporter
+       // scanPlanningDirective is the raw `scan-planning-mode` value from the
+       // catalog load response's config block; hasScanPlanningDirective 
records
+       // whether the key was present.
+       scanPlanningDirective    string
+       hasScanPlanningDirective bool
+       savedConfig              iceberg.Properties
+       reporter                 metrics.Reporter
        // reporterSet records whether a caller injected a reporter via
        // WithMetricsReporter. It distinguishes an explicit reporter 
(including an
        // explicit NopReporter opt-out) from the construction-time default, so
@@ -250,6 +255,8 @@ func (t *Table) Refresh(ctx context.Context) error {
        t.manifestCache = newSnapshotManifestCacheForMetadata(fresh.metadata)
        t.planner = fresh.planner
        t.scanPlanningIOProps = maps.Clone(fresh.scanPlanningIOProps)
+       t.scanPlanningDirective = fresh.scanPlanningDirective
+       t.hasScanPlanningDirective = fresh.hasScanPlanningDirective
        t.labels = fresh.labels
        t.savedConfig = maps.Clone(fresh.savedConfig)
        // Only inherit the catalog-derived reporter when the caller hasn't set 
one
@@ -857,6 +864,7 @@ func (t Table) doCommit(ctx context.Context, updates 
[]Update, reqs []Requiremen
                t.cat,
                withReporterState(t.reporter, t.reporterSet),
                WithScanPlanningIOProperties(t.scanPlanningIOProps),
+               WithScanPlanningDirective(t.scanPlanningDirective, 
t.hasScanPlanningDirective),
                WithLabels(t.labels),
                WithSavedConfig(t.savedConfig),
        ), nil
@@ -1364,6 +1372,7 @@ func WithArrowBatchSize(n int) ScanOption {
 }
 
 func (t Table) Scan(opts ...ScanOption) *Scan {
+       directive, directiveErr := t.ScanPlanningDirective()
        s := &Scan{
                identifier:          slices.Clone(t.identifier),
                metadata:            t.metadata,
@@ -1372,14 +1381,15 @@ func (t Table) Scan(opts ...ScanOption) *Scan {
                manifestCache:       t.manifestCache,
                planner:             t.planner,
                scanPlanningIOProps: maps.Clone(t.scanPlanningIOProps),
-               // TODO(#1178 Phase 6): resolve scan-planning-mode table 
properties here.
-               planningMode:   ScanPlanningLocal,
-               rowFilter:      iceberg.AlwaysTrue{},
-               selectedFields: []string{"*"},
-               caseSensitive:  true,
-               limit:          ScanNoLimit,
-               concurrency:    runtime.GOMAXPROCS(0),
-               reporter:       t.MetricsReporter(),
+               directive:           directive,
+               directiveErr:        directiveErr,
+               planningMode:        ScanPlanningLocal,
+               rowFilter:           iceberg.AlwaysTrue{},
+               selectedFields:      []string{"*"},
+               caseSensitive:       true,
+               limit:               ScanNoLimit,
+               concurrency:         runtime.GOMAXPROCS(0),
+               reporter:            t.MetricsReporter(),
        }
 
        for _, opt := range opts {
diff --git a/table/transaction.go b/table/transaction.go
index ae1615d04..9b51b322d 100644
--- a/table/transaction.go
+++ b/table/transaction.go
@@ -3523,9 +3523,11 @@ func (t *Transaction) Scan(opts ...ScanOption) (*Scan, 
error) {
                manifestCache:    
newSnapshotManifestCacheForMetadata(updatedMeta),
                // Catalog planners can only see committed table state, not 
metadata
                // staged inside this transaction. Keep transaction scans local 
so auto
-               // mode cannot silently return stale tasks.
-               planner: nil,
-               // TODO(#1178 Phase 6): resolve scan-planning-mode table 
properties here.
+               // mode cannot silently return stale tasks. The catalog's
+               // scan-planning-mode directive is not applied either: the 
server cannot
+               // plan staged state, and committing a transaction reads the 
table's
+               // manifests locally whatever the directive says.
+               planner:        nil,
                planningMode:   ScanPlanningLocal,
                rowFilter:      iceberg.AlwaysTrue{},
                selectedFields: []string{"*"},
@@ -3564,6 +3566,7 @@ func (t *Transaction) StagedTable() (*StagedTable, error) 
{
                        t.tbl.cat,
                        withReporterState(t.tbl.reporter, t.tbl.reporterSet),
                        WithScanPlanningIOProperties(t.tbl.scanPlanningIOProps),
+                       WithScanPlanningDirective(t.tbl.scanPlanningDirective, 
t.tbl.hasScanPlanningDirective),
                        WithLabels(t.tbl.labels),
                        WithSavedConfig(t.tbl.savedConfig),
                ),

Reply via email to