You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@iceberg.apache.org by ze...@apache.org on 2026/10/21 17:27:01 UTC

(iceberg-go) branch main updated: feat(table,rest): enforce the catalog's scan-planning-mode directive (#2162)

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 <re...@gmail.com>
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 <no...@anthropic.com>
    
    * 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 <bi...@clickhouse.com>
    Co-authored-by: Claude Opus 5.5 <no...@anthropic.com>
---
 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),
 		),