You are viewing a plain text version of this content. The canonical link for it is here.
Posted to notifications@asterixdb.apache.org by AsterixDB Code Review <do...@asterix-gerrit.ics.uci.edu> on 2026/08/23 22:44:03 UTC

[XL] Change in asterixdb[master]: [ASTERIXDB-3815][SQL++][COMP] Accept an expression for time travel

From Hussain Towaileb <hu...@gmail.com>:

Hussain Towaileb has uploaded this change for review. ( https://asterix-gerrit.ics.uci.edu/c/asterixdb/+/21606?usp=email )


Change subject: [ASTERIXDB-3815][SQL++][COMP] Accept an expression for time travel
......................................................................

[ASTERIXDB-3815][SQL++][COMP] Accept an expression for time travel

Details:

AT SNAPSHOT and AT TIMESTAMP accepted only a string literal, so a
snapshot could never be derived -- every value had to be computed
outside the query and pasted in as text. The value is now an ordinary
expression, reduced by constant folding before the scan is built. It
still has to be constant, because it selects which Iceberg files the
scan reads. Accepted forms include integer and string literals, query
parameters, temporal constructors and conversions, date arithmetic,
and values bound earlier in the statement, on both FROM terms and
JOIN clauses. A value that does not reduce to a usable constant is
reported rather than ignored.

Also fixes two defects that silently dropped a JOIN's time travel, so
the latest snapshot was read instead of the one that was asked for:
one when the clause was cloned, and one when a JOIN preceding a RIGHT
OUTER JOIN was rebuilt by that rewrite.

Added test cases covering the accepted forms, the interaction with
joins, subqueries, function inlining and the right-join rewrite, the
errors raised for a value that is not constant or not usable, and the
grammar.

Ext-ref: MB-73487
Co-Authored-By: Claude Opus 5 <no...@anthropic.com>
Change-Id: Ibe50c85b5defd86bed540d4c8fd14320f17cdad9
---
A asterixdb/asterix-algebra/src/main/java/org/apache/asterix/optimizer/rules/util/TimeTravelUtil.java
M asterixdb/asterix-algebra/src/main/java/org/apache/asterix/translator/SqlppExpressionToPlanTranslator.java
M asterixdb/asterix-app/src/main/java/org/apache/asterix/app/function/DatasetRewriter.java
A asterixdb/asterix-app/src/test/java/org/apache/asterix/optimizer/rules/util/TimeTravelUtilTest.java
A asterixdb/asterix-app/src/test/resources/optimizerts/queries/time-travel/time-travel-expression.sqlpp
A asterixdb/asterix-app/src/test/resources/optimizerts/results/time-travel/time-travel-expression.plan
A asterixdb/asterix-app/src/test/resources/runtimets/queries_sqlpp/iceberg/snapshots/test.085.query.sqlpp
A asterixdb/asterix-app/src/test/resources/runtimets/queries_sqlpp/iceberg/snapshots/test.090.query.sqlpp
A asterixdb/asterix-app/src/test/resources/runtimets/queries_sqlpp/iceberg/snapshots/test.100.query.sqlpp
A asterixdb/asterix-app/src/test/resources/runtimets/queries_sqlpp/iceberg/snapshots/test.110.query.sqlpp
A asterixdb/asterix-app/src/test/resources/runtimets/queries_sqlpp/iceberg/snapshots/test.120.query.sqlpp
A asterixdb/asterix-app/src/test/resources/runtimets/queries_sqlpp/iceberg/snapshots/test.140.query.sqlpp
A asterixdb/asterix-app/src/test/resources/runtimets/queries_sqlpp/iceberg/snapshots/test.150.query.sqlpp
A asterixdb/asterix-app/src/test/resources/runtimets/queries_sqlpp/iceberg/snapshots/test.160.query.sqlpp
A asterixdb/asterix-app/src/test/resources/runtimets/queries_sqlpp/iceberg/snapshots/test.170.query.sqlpp
A asterixdb/asterix-app/src/test/resources/runtimets/queries_sqlpp/iceberg/snapshots/test.180.query.sqlpp
A asterixdb/asterix-app/src/test/resources/runtimets/queries_sqlpp/iceberg/snapshots/test.190.query.sqlpp
A asterixdb/asterix-app/src/test/resources/runtimets/queries_sqlpp/iceberg/snapshots/test.200.query.sqlpp
A asterixdb/asterix-app/src/test/resources/runtimets/queries_sqlpp/iceberg/snapshots/test.210.ddl.sqlpp
A asterixdb/asterix-app/src/test/resources/runtimets/queries_sqlpp/iceberg/snapshots/test.220.query.sqlpp
A asterixdb/asterix-app/src/test/resources/runtimets/queries_sqlpp/iceberg/snapshots/test.230.ddl.sqlpp
A asterixdb/asterix-app/src/test/resources/runtimets/queries_sqlpp/iceberg/snapshots/test.240.query.sqlpp
A asterixdb/asterix-app/src/test/resources/runtimets/queries_sqlpp/iceberg/snapshots/test.250.ddl.sqlpp
A asterixdb/asterix-app/src/test/resources/runtimets/queries_sqlpp/iceberg/snapshots/test.260.query.sqlpp
A asterixdb/asterix-app/src/test/resources/runtimets/queries_sqlpp/iceberg/snapshots/test.270.ddl.sqlpp
A asterixdb/asterix-app/src/test/resources/runtimets/queries_sqlpp/iceberg/snapshots/test.280.ddl.sqlpp
A asterixdb/asterix-app/src/test/resources/runtimets/queries_sqlpp/iceberg/snapshots/test.290.query.sqlpp
A asterixdb/asterix-app/src/test/resources/runtimets/queries_sqlpp/iceberg/snapshots/test.300.ddl.sqlpp
A asterixdb/asterix-app/src/test/resources/runtimets/queries_sqlpp/iceberg/snapshots/test.310.query.sqlpp
A asterixdb/asterix-app/src/test/resources/runtimets/queries_sqlpp/iceberg/snapshots/test.320.query.sqlpp
A asterixdb/asterix-app/src/test/resources/runtimets/queries_sqlpp/iceberg/snapshots/test.330.query.sqlpp
A asterixdb/asterix-app/src/test/resources/runtimets/queries_sqlpp/iceberg/snapshots/test.340.query.sqlpp
A asterixdb/asterix-app/src/test/resources/runtimets/queries_sqlpp/iceberg/snapshots/test.360.query.sqlpp
A asterixdb/asterix-app/src/test/resources/runtimets/queries_sqlpp/misc/time-travel-negative/time-travel-negative.1.ddl.sqlpp
A asterixdb/asterix-app/src/test/resources/runtimets/queries_sqlpp/misc/time-travel-negative/time-travel-negative.10.query.sqlpp
A asterixdb/asterix-app/src/test/resources/runtimets/queries_sqlpp/misc/time-travel-negative/time-travel-negative.11.query.sqlpp
A asterixdb/asterix-app/src/test/resources/runtimets/queries_sqlpp/misc/time-travel-negative/time-travel-negative.12.query.sqlpp
A asterixdb/asterix-app/src/test/resources/runtimets/queries_sqlpp/misc/time-travel-negative/time-travel-negative.13.query.sqlpp
A asterixdb/asterix-app/src/test/resources/runtimets/queries_sqlpp/misc/time-travel-negative/time-travel-negative.14.query.sqlpp
A asterixdb/asterix-app/src/test/resources/runtimets/queries_sqlpp/misc/time-travel-negative/time-travel-negative.15.ddl.sqlpp
A asterixdb/asterix-app/src/test/resources/runtimets/queries_sqlpp/misc/time-travel-negative/time-travel-negative.2.query.sqlpp
A asterixdb/asterix-app/src/test/resources/runtimets/queries_sqlpp/misc/time-travel-negative/time-travel-negative.3.query.sqlpp
A asterixdb/asterix-app/src/test/resources/runtimets/queries_sqlpp/misc/time-travel-negative/time-travel-negative.4.query.sqlpp
A asterixdb/asterix-app/src/test/resources/runtimets/queries_sqlpp/misc/time-travel-negative/time-travel-negative.5.query.sqlpp
A asterixdb/asterix-app/src/test/resources/runtimets/queries_sqlpp/misc/time-travel-negative/time-travel-negative.6.query.sqlpp
A asterixdb/asterix-app/src/test/resources/runtimets/queries_sqlpp/misc/time-travel-negative/time-travel-negative.7.query.sqlpp
A asterixdb/asterix-app/src/test/resources/runtimets/queries_sqlpp/misc/time-travel-negative/time-travel-negative.8.query.sqlpp
A asterixdb/asterix-app/src/test/resources/runtimets/queries_sqlpp/misc/time-travel-negative/time-travel-negative.9.query.sqlpp
A asterixdb/asterix-app/src/test/resources/runtimets/results/iceberg/snapshots/result.085.adm
A asterixdb/asterix-app/src/test/resources/runtimets/results/iceberg/snapshots/result.090.adm
A asterixdb/asterix-app/src/test/resources/runtimets/results/iceberg/snapshots/result.100.adm
A asterixdb/asterix-app/src/test/resources/runtimets/results/iceberg/snapshots/result.110.adm
A asterixdb/asterix-app/src/test/resources/runtimets/results/iceberg/snapshots/result.120.adm
A asterixdb/asterix-app/src/test/resources/runtimets/results/iceberg/snapshots/result.140.adm
A asterixdb/asterix-app/src/test/resources/runtimets/results/iceberg/snapshots/result.150.adm
A asterixdb/asterix-app/src/test/resources/runtimets/results/iceberg/snapshots/result.160.adm
A asterixdb/asterix-app/src/test/resources/runtimets/results/iceberg/snapshots/result.170.adm
A asterixdb/asterix-app/src/test/resources/runtimets/results/iceberg/snapshots/result.180.adm
A asterixdb/asterix-app/src/test/resources/runtimets/results/iceberg/snapshots/result.190.adm
A asterixdb/asterix-app/src/test/resources/runtimets/results/iceberg/snapshots/result.200.adm
A asterixdb/asterix-app/src/test/resources/runtimets/results/iceberg/snapshots/result.220.adm
A asterixdb/asterix-app/src/test/resources/runtimets/results/iceberg/snapshots/result.240.adm
A asterixdb/asterix-app/src/test/resources/runtimets/results/iceberg/snapshots/result.260.adm
A asterixdb/asterix-app/src/test/resources/runtimets/results/iceberg/snapshots/result.290.adm
A asterixdb/asterix-app/src/test/resources/runtimets/results/iceberg/snapshots/result.310.adm
A asterixdb/asterix-app/src/test/resources/runtimets/results/iceberg/snapshots/result.320.adm
A asterixdb/asterix-app/src/test/resources/runtimets/results/iceberg/snapshots/result.330.adm
A asterixdb/asterix-app/src/test/resources/runtimets/results/iceberg/snapshots/result.340.adm
A asterixdb/asterix-app/src/test/resources/runtimets/results/iceberg/snapshots/result.360.adm
M asterixdb/asterix-app/src/test/resources/runtimets/sqlpp_queries.xml
M asterixdb/asterix-common/src/main/java/org/apache/asterix/common/exceptions/ErrorCode.java
M asterixdb/asterix-common/src/main/resources/asx_errormsg/en.properties
M asterixdb/asterix-lang-sqlpp/src/main/java/org/apache/asterix/lang/sqlpp/JoinClauseRightInput.java
M asterixdb/asterix-lang-sqlpp/src/main/java/org/apache/asterix/lang/sqlpp/clause/AbstractBinaryCorrelateClause.java
M asterixdb/asterix-lang-sqlpp/src/main/java/org/apache/asterix/lang/sqlpp/clause/AbstractBinaryCorrelateWithConditionClause.java
M asterixdb/asterix-lang-sqlpp/src/main/java/org/apache/asterix/lang/sqlpp/clause/FromTerm.java
M asterixdb/asterix-lang-sqlpp/src/main/java/org/apache/asterix/lang/sqlpp/clause/JoinClause.java
M asterixdb/asterix-lang-sqlpp/src/main/java/org/apache/asterix/lang/sqlpp/rewrites/visitor/SqlppGatherFunctionCallsVisitor.java
M asterixdb/asterix-lang-sqlpp/src/main/java/org/apache/asterix/lang/sqlpp/rewrites/visitor/SqlppInlineUdfsVisitor.java
M asterixdb/asterix-lang-sqlpp/src/main/java/org/apache/asterix/lang/sqlpp/rewrites/visitor/SqlppRightJoinRewriteVisitor.java
A asterixdb/asterix-lang-sqlpp/src/main/java/org/apache/asterix/lang/sqlpp/struct/TimeTravelSpec.java
M asterixdb/asterix-lang-sqlpp/src/main/java/org/apache/asterix/lang/sqlpp/visitor/DeepCopyVisitor.java
M asterixdb/asterix-lang-sqlpp/src/main/java/org/apache/asterix/lang/sqlpp/visitor/FreeVariableVisitor.java
M asterixdb/asterix-lang-sqlpp/src/main/java/org/apache/asterix/lang/sqlpp/visitor/SqlppAstPrintVisitor.java
M asterixdb/asterix-lang-sqlpp/src/main/java/org/apache/asterix/lang/sqlpp/visitor/SqlppCloneAndSubstituteVariablesVisitor.java
M asterixdb/asterix-lang-sqlpp/src/main/java/org/apache/asterix/lang/sqlpp/visitor/SqlppFormatPrintVisitor.java
M asterixdb/asterix-lang-sqlpp/src/main/java/org/apache/asterix/lang/sqlpp/visitor/base/AbstractSqlppContainsExpressionVisitor.java
M asterixdb/asterix-lang-sqlpp/src/main/java/org/apache/asterix/lang/sqlpp/visitor/base/AbstractSqlppExpressionScopingVisitor.java
M asterixdb/asterix-lang-sqlpp/src/main/java/org/apache/asterix/lang/sqlpp/visitor/base/AbstractSqlppSimpleExpressionVisitor.java
M asterixdb/asterix-lang-sqlpp/src/main/javacc/SQLPP.jj
A asterixdb/asterix-lang-sqlpp/src/test/java/org/apache/asterix/lang/sqlpp/parser/TimeTravelParserTest.java
M hyracks-fullstack/algebricks/algebricks-core/src/main/java/org/apache/hyracks/algebricks/core/algebra/operators/logical/AbstractUnnestNonMapOperator.java
M hyracks-fullstack/algebricks/algebricks-core/src/main/java/org/apache/hyracks/algebricks/core/algebra/operators/logical/TimeTravel.java
M hyracks-fullstack/algebricks/algebricks-core/src/main/java/org/apache/hyracks/algebricks/core/algebra/operators/logical/visitors/LogicalOperatorDeepCopyWithNewVariablesVisitor.java
M hyracks-fullstack/algebricks/algebricks-core/src/main/java/org/apache/hyracks/algebricks/core/algebra/operators/logical/visitors/OperatorDeepCopyVisitor.java
M hyracks-fullstack/algebricks/algebricks-core/src/main/java/org/apache/hyracks/algebricks/core/algebra/prettyprint/LogicalOperatorPrettyPrintVisitor.java
M hyracks-fullstack/algebricks/algebricks-core/src/main/java/org/apache/hyracks/algebricks/core/utils/LogicalOperatorDotVisitor.java
97 files changed, 2,296 insertions(+), 119 deletions(-)



  git pull ssh://asterix-gerrit.ics.uci.edu:29418/asterixdb refs/changes/06/21606/1

diff --git a/asterixdb/asterix-algebra/src/main/java/org/apache/asterix/optimizer/rules/util/TimeTravelUtil.java b/asterixdb/asterix-algebra/src/main/java/org/apache/asterix/optimizer/rules/util/TimeTravelUtil.java
new file mode 100644
index 0000000..5f69ad0
--- /dev/null
+++ b/asterixdb/asterix-algebra/src/main/java/org/apache/asterix/optimizer/rules/util/TimeTravelUtil.java
@@ -0,0 +1,154 @@
+/*
+ * 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 org.apache.asterix.optimizer.rules.util;
+
+import static java.time.format.DateTimeFormatter.ISO_LOCAL_DATE;
+import static java.time.temporal.ChronoField.HOUR_OF_DAY;
+import static java.time.temporal.ChronoField.MINUTE_OF_HOUR;
+import static java.time.temporal.ChronoField.NANO_OF_SECOND;
+import static java.time.temporal.ChronoField.SECOND_OF_MINUTE;
+import static org.apache.hyracks.util.annotations.AiProvenance.Agent.CLAUDE_OPUS_5;
+import static org.apache.hyracks.util.annotations.AiProvenance.Tool.CLAUDE_CODE_UI;
+
+import java.time.Instant;
+import java.time.LocalDate;
+import java.time.LocalDateTime;
+import java.time.ZoneOffset;
+import java.time.format.DateTimeFormatter;
+import java.time.format.DateTimeFormatterBuilder;
+
+import org.apache.asterix.common.exceptions.CompilationException;
+import org.apache.asterix.common.exceptions.ErrorCode;
+import org.apache.asterix.om.base.ADate;
+import org.apache.asterix.om.base.ADateTime;
+import org.apache.asterix.om.base.AInt16;
+import org.apache.asterix.om.base.AInt32;
+import org.apache.asterix.om.base.AInt64;
+import org.apache.asterix.om.base.AInt8;
+import org.apache.asterix.om.base.AString;
+import org.apache.asterix.om.base.IAObject;
+import org.apache.asterix.om.constants.AsterixConstantValue;
+import org.apache.asterix.om.types.ATypeTag;
+import org.apache.hyracks.algebricks.core.algebra.base.ILogicalExpression;
+import org.apache.hyracks.algebricks.core.algebra.base.LogicalExpressionTag;
+import org.apache.hyracks.algebricks.core.algebra.expressions.ConstantExpression;
+import org.apache.hyracks.algebricks.core.algebra.expressions.IAlgebricksConstantValue;
+import org.apache.hyracks.algebricks.core.algebra.operators.logical.TimeTravel;
+import org.apache.hyracks.api.exceptions.SourceLocation;
+import org.apache.hyracks.util.annotations.AiProvenance;
+
+/**
+ * Reads the constant an {@code AT SNAPSHOT} / {@code AT TIMESTAMP} value folded down to, and renders it in one
+ * of the forms the Iceberg reader parses.
+ * <p>
+ * The value rides the ordinary expression machinery: it is an expression reference on the unnest operator, so
+ * constant folding reduces it during normalization -- before {@code UnnestToDataScanRule} turns the unnest into
+ * the scan that consumes it. What is left here is only reading the result and rejecting what could not be used:
+ * the snapshot selects which manifest and data files the scan reads, so there is no runtime evaluation path for
+ * it and it has to be a constant by the time the scan is built.
+ *
+ * @see org.apache.asterix.external.util.iceberg.IcebergSnapshotUtils
+ */
+@AiProvenance(agent = CLAUDE_OPUS_5, tool = CLAUDE_CODE_UI)
+public final class TimeTravelUtil {
+
+    private static final String EXPECTED_FOR_SNAPSHOT_ID = "an integer or a string";
+
+    private static final String EXPECTED_FOR_SNAPSHOT_TIMESTAMP = "an integer, a string, a date or a datetime";
+
+    /**
+     * One of the forms {@code IcebergSnapshotUtils} accepts: always seconds, fractional part only when there is
+     * one. {@link DateTimeFormatter#ISO_LOCAL_DATE_TIME} would drop {@code :00} seconds, which still parses but
+     * makes the value look truncated.
+     */
+    private static final DateTimeFormatter SNAPSHOT_TIMESTAMP_FORMAT =
+            new DateTimeFormatterBuilder().append(ISO_LOCAL_DATE).appendLiteral('T').appendValue(HOUR_OF_DAY, 2)
+                    .appendLiteral(':').appendValue(MINUTE_OF_HOUR, 2).appendLiteral(':')
+                    .appendValue(SECOND_OF_MINUTE, 2).appendFraction(NANO_OF_SECOND, 0, 3, true).toFormatter();
+
+    private TimeTravelUtil() {
+    }
+
+    /**
+     * @param timeTravel the specification carried by the unnest operator
+     * @param sourceLoc where to report a failure against
+     * @return the value in the string form the Iceberg reader parses
+     * @throws CompilationException if the value did not fold to a constant, or folded to something unusable
+     */
+    public static String resolve(TimeTravel timeTravel, SourceLocation sourceLoc) throws CompilationException {
+        ILogicalExpression valueExpr = timeTravel.getValueExpression();
+        if (valueExpr.getExpressionTag() != LogicalExpressionTag.CONSTANT) {
+            throw new CompilationException(ErrorCode.EXPECTED_CONSTANT_VALUE, sourceLoc);
+        }
+        IAlgebricksConstantValue constantValue = ((ConstantExpression) valueExpr).getValue();
+        if (constantValue.isNull() || constantValue.isMissing()) {
+            // e.g. 'date("2026-08-20") - 1': date arithmetic only accepts durations, and a type mismatch
+            // inside an arithmetic function produces null rather than an error.
+            throw invalidValue(timeTravel.getType(), sourceLoc, constantValue.isNull() ? "null" : "missing");
+        }
+        if (!(constantValue instanceof AsterixConstantValue)) {
+            throw new CompilationException(ErrorCode.EXPECTED_CONSTANT_VALUE, sourceLoc);
+        }
+        return stringify(((AsterixConstantValue) constantValue).getObject(), timeTravel.getType(), sourceLoc);
+    }
+
+    public static String stringify(IAObject value, TimeTravel.Type type, SourceLocation sourceLoc)
+            throws CompilationException {
+        ATypeTag typeTag = value.getType().getTypeTag();
+        switch (typeTag) {
+            case TINYINT:
+                return String.valueOf(((AInt8) value).getByteValue());
+            case SMALLINT:
+                return String.valueOf(((AInt16) value).getShortValue());
+            case INTEGER:
+                return String.valueOf(((AInt32) value).getIntegerValue());
+            case BIGINT:
+                return String.valueOf(((AInt64) value).getLongValue());
+            case STRING:
+                return ((AString) value).getStringValue();
+            case DATE:
+                requireTimestamp(type, sourceLoc, typeTag);
+                return LocalDate.ofEpochDay(((ADate) value).getChrononTimeInDays()).format(ISO_LOCAL_DATE);
+            case DATETIME:
+                requireTimestamp(type, sourceLoc, typeTag);
+                return LocalDateTime
+                        .ofInstant(Instant.ofEpochMilli(((ADateTime) value).getChrononTime()), ZoneOffset.UTC)
+                        .format(SNAPSHOT_TIMESTAMP_FORMAT);
+            default:
+                throw invalidValue(type, sourceLoc, typeTag.toString().toLowerCase());
+        }
+    }
+
+    /**
+     * A date or datetime only makes sense for {@code AT TIMESTAMP}; a snapshot id is an integer.
+     */
+    private static void requireTimestamp(TimeTravel.Type type, SourceLocation sourceLoc, ATypeTag typeTag)
+            throws CompilationException {
+        if (type != TimeTravel.Type.SNAPSHOT_TIMESTAMP) {
+            throw invalidValue(type, sourceLoc, typeTag.toString().toLowerCase());
+        }
+    }
+
+    private static CompilationException invalidValue(TimeTravel.Type type, SourceLocation sourceLoc, String actual) {
+        String expected =
+                type == TimeTravel.Type.SNAPSHOT_TIMESTAMP ? EXPECTED_FOR_SNAPSHOT_TIMESTAMP : EXPECTED_FOR_SNAPSHOT_ID;
+        return new CompilationException(ErrorCode.INVALID_TIME_TRAVEL_VALUE, sourceLoc, type.getKeyword().toUpperCase(),
+                expected, actual);
+    }
+}
diff --git a/asterixdb/asterix-algebra/src/main/java/org/apache/asterix/translator/SqlppExpressionToPlanTranslator.java b/asterixdb/asterix-algebra/src/main/java/org/apache/asterix/translator/SqlppExpressionToPlanTranslator.java
index 8bae37c..388ea39 100644
--- a/asterixdb/asterix-algebra/src/main/java/org/apache/asterix/translator/SqlppExpressionToPlanTranslator.java
+++ b/asterixdb/asterix-algebra/src/main/java/org/apache/asterix/translator/SqlppExpressionToPlanTranslator.java
@@ -19,6 +19,8 @@
 package org.apache.asterix.translator;
 
 import static org.apache.asterix.external.util.ExternalDataConstants.SUBPATH;
+import static org.apache.hyracks.util.annotations.AiProvenance.Agent.CLAUDE_OPUS_5;
+import static org.apache.hyracks.util.annotations.AiProvenance.Tool.CLAUDE_CODE_UI;
 
 import java.util.ArrayList;
 import java.util.Collections;
@@ -83,6 +85,7 @@
 import org.apache.asterix.lang.sqlpp.optype.UnnestType;
 import org.apache.asterix.lang.sqlpp.struct.SetOperationInput;
 import org.apache.asterix.lang.sqlpp.struct.SetOperationRight;
+import org.apache.asterix.lang.sqlpp.struct.TimeTravelSpec;
 import org.apache.asterix.lang.sqlpp.util.SqlppRewriteUtil;
 import org.apache.asterix.lang.sqlpp.util.SqlppVariableUtil;
 import org.apache.asterix.lang.sqlpp.visitor.base.ISqlppVisitor;
@@ -135,12 +138,14 @@
 import org.apache.hyracks.algebricks.core.algebra.operators.logical.ProjectOperator;
 import org.apache.hyracks.algebricks.core.algebra.operators.logical.SelectOperator;
 import org.apache.hyracks.algebricks.core.algebra.operators.logical.SubplanOperator;
+import org.apache.hyracks.algebricks.core.algebra.operators.logical.TimeTravel;
 import org.apache.hyracks.algebricks.core.algebra.operators.logical.UnnestOperator;
 import org.apache.hyracks.algebricks.core.algebra.operators.logical.WindowOperator;
 import org.apache.hyracks.algebricks.core.algebra.plan.ALogicalPlanImpl;
 import org.apache.hyracks.algebricks.core.algebra.util.OperatorManipulationUtil;
 import org.apache.hyracks.api.exceptions.SourceLocation;
 import org.apache.hyracks.util.LogRedactionUtil;
+import org.apache.hyracks.util.annotations.AiProvenance;
 
 /**
  * Each visit returns a pair of an operator and a variable. The variable
@@ -328,9 +333,10 @@
             LogicalVariable pVar = context.newVarFromExpression(fromTerm.getPositionalVariable());
             // We set the positional variable type as BIGINT type.
             unnestOp = new UnnestOperator(fromVar, new MutableObject<>(pUnnestExpr.first), pVar, BuiltinType.AINT64,
-                    fromTerm.getTimeTravel());
+                    toTimeTravel(fromTerm.getTimeTravel()));
         } else {
-            unnestOp = new UnnestOperator(fromVar, new MutableObject<>(pUnnestExpr.first), fromTerm.getTimeTravel());
+            unnestOp = new UnnestOperator(fromVar, new MutableObject<>(pUnnestExpr.first),
+                    toTimeTravel(fromTerm.getTimeTravel()));
         }
         unnestOp.getAnnotations().put(ARRAY_ACCESS, fromExpr.getKind() == Kind.FIELD_ACCESSOR_EXPRESSION);
         ExternalSubpathAnnotation hint = ((AbstractExpression) fromExpr).findHint(ExternalSubpathAnnotation.class);
@@ -350,6 +356,27 @@
         return new Pair<>(topOpRef.getValue(), fromVar);
     }
 
+    /**
+     * Translates the value of an {@code AT SNAPSHOT} / {@code AT TIMESTAMP} specification into the expression
+     * reference the operator carries. It is translated against a throwaway input, so a value that needs
+     * operators of its own -- a subquery, or a non-deterministic call that cannot be inlined -- is rejected
+     * here rather than leaking an operator into the plan. Reducing it to a constant is left to constant
+     * folding, and reading that constant to {@code DatasetRewriter}.
+     */
+    @AiProvenance(agent = CLAUDE_OPUS_5, tool = CLAUDE_CODE_UI)
+    private TimeTravel toTimeTravel(TimeTravelSpec timeTravelSpec) throws CompilationException {
+        if (timeTravelSpec == null) {
+            return null;
+        }
+        Mutable<ILogicalOperator> valueInputRef = new MutableObject<>(new EmptyTupleSourceOperator());
+        Pair<ILogicalExpression, Mutable<ILogicalOperator>> eo =
+                langExprToAlgExpression(timeTravelSpec.getValueExpression(), valueInputRef);
+        if (eo.second != valueInputRef) {
+            throw new CompilationException(ErrorCode.EXPECTED_CONSTANT_VALUE, timeTravelSpec.getSourceLocation());
+        }
+        return new TimeTravel(new MutableObject<>(eo.first), timeTravelSpec.getType());
+    }
+
     @Override
     public Pair<ILogicalOperator, LogicalVariable> visit(JoinClause joinClause, Mutable<ILogicalOperator> leftInputRef)
             throws CompilationException {
@@ -574,21 +601,21 @@
         Expression rightExpr = binaryCorrelate.getRightExpression();
         Pair<ILogicalExpression, Mutable<ILogicalOperator>> eo = langExprToAlgExpression(rightExpr, inputOpRef);
         Pair<ILogicalExpression, Mutable<ILogicalOperator>> pUnnestExpr = makeUnnestExpression(eo.first, eo.second);
+        TimeTravel timeTravel = toTimeTravel(binaryCorrelate.getTimeTravel());
         AbstractUnnestOperator unnestOp;
         if (binaryCorrelate.hasPositionalVariable()) {
             LogicalVariable pVar = context.newVarFromExpression(binaryCorrelate.getPositionalVariable());
             // We set the positional variable type as BIGINT type.
             unnestOp = outerUnnest
                     ? new LeftOuterUnnestOperator(rightVar, new MutableObject<>(pUnnestExpr.first), pVar,
-                            BuiltinType.AINT64, outerUnnestMissingValue, binaryCorrelate.getTimeTravel())
+                            BuiltinType.AINT64, outerUnnestMissingValue, timeTravel)
                     : new UnnestOperator(rightVar, new MutableObject<>(pUnnestExpr.first), pVar, BuiltinType.AINT64,
-                            binaryCorrelate.getTimeTravel());
+                            timeTravel);
         } else {
             unnestOp = outerUnnest
                     ? new LeftOuterUnnestOperator(rightVar, new MutableObject<>(pUnnestExpr.first),
-                            outerUnnestMissingValue, binaryCorrelate.getTimeTravel())
-                    : new UnnestOperator(rightVar, new MutableObject<>(pUnnestExpr.first),
-                            binaryCorrelate.getTimeTravel());
+                            outerUnnestMissingValue, timeTravel)
+                    : new UnnestOperator(rightVar, new MutableObject<>(pUnnestExpr.first), timeTravel);
         }
         unnestOp.getAnnotations().put(ARRAY_ACCESS, rightExpr.getKind() == Kind.FIELD_ACCESSOR_EXPRESSION);
         ExternalSubpathAnnotation hint = ((AbstractExpression) rightExpr).findHint(ExternalSubpathAnnotation.class);
diff --git a/asterixdb/asterix-app/src/main/java/org/apache/asterix/app/function/DatasetRewriter.java b/asterixdb/asterix-app/src/main/java/org/apache/asterix/app/function/DatasetRewriter.java
index 6543601..0ccb371 100644
--- a/asterixdb/asterix-app/src/main/java/org/apache/asterix/app/function/DatasetRewriter.java
+++ b/asterixdb/asterix-app/src/main/java/org/apache/asterix/app/function/DatasetRewriter.java
@@ -49,6 +49,7 @@
 import org.apache.asterix.om.utils.ConstantExpressionUtil;
 import org.apache.asterix.optimizer.rules.UnnestToDataScanRule;
 import org.apache.asterix.optimizer.rules.util.EquivalenceClassUtils;
+import org.apache.asterix.optimizer.rules.util.TimeTravelUtil;
 import org.apache.commons.lang3.mutable.Mutable;
 import org.apache.hyracks.algebricks.common.exceptions.AlgebricksException;
 import org.apache.hyracks.algebricks.core.algebra.base.ILogicalExpression;
@@ -118,6 +119,14 @@
         } else {
             dataSource = metadataProvider.findDataSource(dsid);
         }
+        // The time travel value must have folded to a constant by now: ConstantFoldingRule sits earlier in the
+        // same normalization rule collection as UnnestToDataScanRule, which is what got us here. This reads it
+        // unconditionally, including for dataset kinds that have no snapshots, so that a value which did not
+        // fold -- or folded to something unusable -- is reported rather than silently dropped.
+        TimeTravel timeTravel = unnest.getTimeTravel();
+        String timeTravelValue =
+                timeTravel == null ? null : TimeTravelUtil.resolve(timeTravel, unnest.getSourceLocation());
+
         boolean hasMeta = dataSource.hasMeta();
         if (hasMeta) {
             variables.add(context.newVar());
@@ -132,12 +141,11 @@
             if (externalSubpath instanceof String) {
                 dataSourceProperties.put(SUBPATH, (String) externalSubpath);
             }
-            Object timeTravelObj = unnest.getTimeTravel();
-            if (timeTravelObj instanceof TimeTravel timeTravel) {
-                if (timeTravel.getType().equals(TimeTravel.Type.SNAPSHOT_ID)) {
-                    dataSourceProperties.put(ICEBERG_SNAPSHOT_ID_PROPERTY_KEY, timeTravel.getSnapshotIdOrTimestamp());
-                } else if (timeTravel.getType().equals(TimeTravel.Type.SNAPSHOT_TIMESTAMP)) {
-                    dataSourceProperties.put(ICEBERG_SNAPSHOT_TIMESTAMP_PROPERTY_KEY, timeTravel.getSnapshotIdOrTimestamp());
+            if (timeTravel != null) {
+                if (timeTravel.getType() == TimeTravel.Type.SNAPSHOT_ID) {
+                    dataSourceProperties.put(ICEBERG_SNAPSHOT_ID_PROPERTY_KEY, timeTravelValue);
+                } else if (timeTravel.getType() == TimeTravel.Type.SNAPSHOT_TIMESTAMP) {
+                    dataSourceProperties.put(ICEBERG_SNAPSHOT_TIMESTAMP_PROPERTY_KEY, timeTravelValue);
                 } else {
                     throw new IllegalStateException("Unknown snapshot type");
                 }
diff --git a/asterixdb/asterix-app/src/test/java/org/apache/asterix/optimizer/rules/util/TimeTravelUtilTest.java b/asterixdb/asterix-app/src/test/java/org/apache/asterix/optimizer/rules/util/TimeTravelUtilTest.java
new file mode 100644
index 0000000..f373ee3
--- /dev/null
+++ b/asterixdb/asterix-app/src/test/java/org/apache/asterix/optimizer/rules/util/TimeTravelUtilTest.java
@@ -0,0 +1,185 @@
+/*
+ * 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 org.apache.asterix.optimizer.rules.util;
+
+import static org.apache.hyracks.util.annotations.AiProvenance.Agent.CLAUDE_OPUS_5;
+import static org.apache.hyracks.util.annotations.AiProvenance.ContributionKind.TEST_GENERATED;
+import static org.apache.hyracks.util.annotations.AiProvenance.Tool.CLAUDE_CODE_UI;
+
+import java.time.LocalDate;
+import java.time.LocalDateTime;
+import java.time.ZoneOffset;
+
+import org.apache.asterix.common.exceptions.CompilationException;
+import org.apache.asterix.common.exceptions.ErrorCode;
+import org.apache.asterix.lang.common.util.FunctionUtil;
+import org.apache.asterix.om.base.ABoolean;
+import org.apache.asterix.om.base.ADate;
+import org.apache.asterix.om.base.ADateTime;
+import org.apache.asterix.om.base.ADouble;
+import org.apache.asterix.om.base.AInt16;
+import org.apache.asterix.om.base.AInt32;
+import org.apache.asterix.om.base.AInt64;
+import org.apache.asterix.om.base.AInt8;
+import org.apache.asterix.om.base.AString;
+import org.apache.asterix.om.base.ATime;
+import org.apache.asterix.om.base.IAObject;
+import org.apache.asterix.om.constants.AsterixConstantValue;
+import org.apache.asterix.om.functions.BuiltinFunctions;
+import org.apache.commons.lang3.mutable.MutableObject;
+import org.apache.hyracks.algebricks.core.algebra.expressions.ConstantExpression;
+import org.apache.hyracks.algebricks.core.algebra.expressions.ScalarFunctionCallExpression;
+import org.apache.hyracks.algebricks.core.algebra.operators.logical.TimeTravel;
+import org.apache.hyracks.util.annotations.AiProvenance;
+import org.junit.Assert;
+import org.junit.Test;
+
+/**
+ * Covers the conversion of a folded {@code AT SNAPSHOT} / {@code AT TIMESTAMP} value into the string form the
+ * Iceberg reader parses, and the rejection of values it could not make sense of.
+ *
+ * @see org.apache.asterix.external.util.iceberg.IcebergSnapshotUtils
+ */
+@AiProvenance(agent = CLAUDE_OPUS_5, tool = CLAUDE_CODE_UI, contributionKind = TEST_GENERATED)
+public class TimeTravelUtilTest {
+
+    @Test
+    public void testSnapshotId() throws Exception {
+        Assert.assertEquals("8574821", snapshotId(new AInt64(8574821L)));
+        Assert.assertEquals("42", snapshotId(new AInt32(42)));
+        // a string passes straight through, which is what the original literal-only syntax produced
+        Assert.assertEquals("8574821", snapshotId(new AString("8574821")));
+    }
+
+    @Test
+    public void testTimestampEpochMillis() throws Exception {
+        Assert.assertEquals("1755648000000", timestamp(new AInt64(1755648000000L)));
+    }
+
+    @Test
+    public void testTimestampDate() throws Exception {
+        int epochDay = (int) LocalDate.of(2026, 8, 20).toEpochDay();
+        Assert.assertEquals("2026-08-20", timestamp(new ADate(epochDay)));
+    }
+
+    @Test
+    public void testTimestampDateTimeAlwaysHasSeconds() throws Exception {
+        long midnight = LocalDateTime.of(2026, 8, 20, 0, 0, 0).toInstant(ZoneOffset.UTC).toEpochMilli();
+        Assert.assertEquals("2026-08-20T00:00:00", timestamp(new ADateTime(midnight)));
+    }
+
+    @Test
+    public void testTimestampDateTimeKeepsMillis() throws Exception {
+        long instant = LocalDateTime.of(2026, 8, 20, 13, 45, 30, 123_000_000).toInstant(ZoneOffset.UTC).toEpochMilli();
+        Assert.assertEquals("2026-08-20T13:45:30.123", timestamp(new ADateTime(instant)));
+    }
+
+    /**
+     * A snapshot id is an integer; a date there is a mistake worth naming rather than passing on to fail as an
+     * unparseable number further down.
+     */
+    @Test
+    public void testDateIsRejectedForSnapshotId() {
+        assertRejected(TimeTravel.Type.SNAPSHOT_ID, new ADate((int) LocalDate.of(2026, 8, 20).toEpochDay()));
+        assertRejected(TimeTravel.Type.SNAPSHOT_ID, new ADateTime(0L));
+    }
+
+    @Test
+    public void testUnusableTypesAreRejected() {
+        for (TimeTravel.Type type : TimeTravel.Type.values()) {
+            assertRejected(type, new ADouble(1.5));
+            assertRejected(type, ABoolean.TRUE);
+            // a bare time of day cannot identify a snapshot
+            assertRejected(type, new ATime(0));
+        }
+    }
+
+    /**
+     * The value normally arrives already folded; anything still holding a function call means folding could not
+     * reduce it, and the scan has nothing usable.
+     */
+    @Test
+    public void testUnfoldedValueIsRejected() {
+        ScalarFunctionCallExpression notConstant =
+                new ScalarFunctionCallExpression(FunctionUtil.getFunctionInfo(BuiltinFunctions.CURRENT_DATE));
+        TimeTravel timeTravel = new TimeTravel(new MutableObject<>(notConstant), TimeTravel.Type.SNAPSHOT_TIMESTAMP);
+        try {
+            TimeTravelUtil.resolve(timeTravel, null);
+            Assert.fail("expected an unfolded value to be rejected");
+        } catch (CompilationException e) {
+            Assert.assertEquals(ErrorCode.EXPECTED_CONSTANT_VALUE.intValue(), e.getErrorCode());
+        }
+    }
+
+    @Test
+    public void testFoldedValueIsRead() throws Exception {
+        TimeTravel timeTravel = new TimeTravel(
+                new MutableObject<>(new ConstantExpression(new AsterixConstantValue(new AInt64(8574821L)))),
+                TimeTravel.Type.SNAPSHOT_ID);
+        Assert.assertEquals("8574821", TimeTravelUtil.resolve(timeTravel, null));
+    }
+
+    @Test
+    public void testSmallIntegerWidths() throws Exception {
+        Assert.assertEquals("7", snapshotId(new AInt8((byte) 7)));
+        Assert.assertEquals("300", snapshotId(new AInt16((short) 300)));
+    }
+
+    /**
+     * Snapshot ids are longs and Iceberg does not promise them positive, so the sign has to
+     * survive rather than being formatted away.
+     */
+    @Test
+    public void testNegativeAndExtremeSnapshotIds() throws Exception {
+        Assert.assertEquals("-8574821", snapshotId(new AInt64(-8574821L)));
+        Assert.assertEquals(String.valueOf(Long.MAX_VALUE), snapshotId(new AInt64(Long.MAX_VALUE)));
+        Assert.assertEquals(String.valueOf(Long.MIN_VALUE), snapshotId(new AInt64(Long.MIN_VALUE)));
+    }
+
+    @Test
+    public void testEpochBoundaries() throws Exception {
+        Assert.assertEquals("1970-01-01", timestamp(new ADate(0)));
+        Assert.assertEquals("1970-01-01T00:00:00", timestamp(new ADateTime(0L)));
+        // before the epoch: the date must go backwards, not wrap
+        Assert.assertEquals("1969-12-31", timestamp(new ADate(-1)));
+    }
+
+    @Test
+    public void testEmptyStringPassesThroughForTheReaderToReject() throws Exception {
+        // not this layer's job to validate the string's shape -- IcebergSnapshotUtils parses it
+        Assert.assertEquals("", snapshotId(new AString("")));
+    }
+
+    private static String snapshotId(IAObject value) throws CompilationException {
+        return TimeTravelUtil.stringify(value, TimeTravel.Type.SNAPSHOT_ID, null);
+    }
+
+    private static String timestamp(IAObject value) throws CompilationException {
+        return TimeTravelUtil.stringify(value, TimeTravel.Type.SNAPSHOT_TIMESTAMP, null);
+    }
+
+    private static void assertRejected(TimeTravel.Type type, IAObject value) {
+        try {
+            String resolved = TimeTravelUtil.stringify(value, type, null);
+            Assert.fail("expected " + value.getType().getTypeTag() + " to be rejected, got " + resolved);
+        } catch (CompilationException e) {
+            Assert.assertEquals(ErrorCode.INVALID_TIME_TRAVEL_VALUE.intValue(), e.getErrorCode());
+        }
+    }
+}
diff --git a/asterixdb/asterix-app/src/test/resources/optimizerts/queries/time-travel/time-travel-expression.sqlpp b/asterixdb/asterix-app/src/test/resources/optimizerts/queries/time-travel/time-travel-expression.sqlpp
new file mode 100644
index 0000000..f17d68d
--- /dev/null
+++ b/asterixdb/asterix-app/src/test/resources/optimizerts/queries/time-travel/time-travel-expression.sqlpp
@@ -0,0 +1,52 @@
+/*
+ * 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.
+ */
+
+/*
+ * Description  : AT SNAPSHOT / AT TIMESTAMP accept any expression that folds to a constant, which is resolved
+ *              : during expression-to-plan translation. This exercises that resolution: the datasets here are
+ *              : internal, so the resolved value is discarded and the plan is the plain plan -- what is being
+ *              : tested is that each of these forms compiles at all.
+ * Expected Res : Success
+ */
+
+drop dataverse timetravel if exists;
+create dataverse timetravel;
+
+use timetravel;
+
+create type timetravel.UserType as {
+  uid : integer
+};
+
+create dataset Users(UserType) primary key uid;
+
+create dataset Visitors(UserType) primary key uid;
+
+-- param $ts:string=8574821
+
+select element {'a': u.uid, 'b': v.uid, 'c': w.uid, 'd': x.uid, 'e': y.uid, 'f': z.uid}
+from Users as u AT SNAPSHOT 8574821,
+     Users as v AT TIMESTAMP date("2026-08-20") - duration("P2D")
+     join Visitors as w AT TIMESTAMP unix_time_from_date_in_ms(date("2026-08-20")) on v.uid = w.uid,
+     -- a year-month duration, and one off a datetime carrying a millisecond fraction
+     Visitors as x AT TIMESTAMP date("2026-08-20") - duration("P3M"),
+     Users as y AT TIMESTAMP datetime("2026-08-20T13:45:30.123") - duration("P3M"),
+     Users as z AT SNAPSHOT $ts
+where u.uid = v.uid and w.uid = x.uid and x.uid = y.uid and y.uid = z.uid
+;
diff --git a/asterixdb/asterix-app/src/test/resources/optimizerts/results/time-travel/time-travel-expression.plan b/asterixdb/asterix-app/src/test/resources/optimizerts/results/time-travel/time-travel-expression.plan
new file mode 100644
index 0000000..7e6bee7
--- /dev/null
+++ b/asterixdb/asterix-app/src/test/resources/optimizerts/results/time-travel/time-travel-expression.plan
@@ -0,0 +1,138 @@
+distribute result [$$119]
+-- DISTRIBUTE_RESULT  |PARTITIONED|
+  exchange
+  -- ONE_TO_ONE_EXCHANGE  |PARTITIONED|
+    assign [$$119] <- [{"a": $$126, "b": $$127, "c": $$128, "d": $$129, "e": $$130, "f": $$131}] project: [$$119]
+    -- ASSIGN  |PARTITIONED|
+      exchange
+      -- ONE_TO_ONE_EXCHANGE  |PARTITIONED|
+        join (eq($$130, $$131))
+        -- HYBRID_HASH_JOIN [$$130][$$131]  |PARTITIONED|
+          exchange
+          -- ONE_TO_ONE_EXCHANGE  |PARTITIONED|
+            join (eq($$129, $$130))
+            -- HYBRID_HASH_JOIN [$$129][$$130]  |PARTITIONED|
+              exchange
+              -- ONE_TO_ONE_EXCHANGE  |PARTITIONED|
+                join (eq($$128, $$129))
+                -- HYBRID_HASH_JOIN [$$128][$$129]  |PARTITIONED|
+                  exchange
+                  -- ONE_TO_ONE_EXCHANGE  |PARTITIONED|
+                    join (eq($$126, $$127))
+                    -- HYBRID_HASH_JOIN [$$126][$$127]  |PARTITIONED|
+                      exchange
+                      -- ONE_TO_ONE_EXCHANGE  |PARTITIONED|
+                        assign [$$126] <- [$$127] project: [$$126]
+                        -- ASSIGN  |PARTITIONED|
+                          exchange
+                          -- ONE_TO_ONE_EXCHANGE  |PARTITIONED|
+                            replicate
+                            -- REPLICATE  |PARTITIONED|
+                              exchange
+                              -- ONE_TO_ONE_EXCHANGE  |PARTITIONED|
+                                project ([$$127])
+                                -- STREAM_PROJECT  |PARTITIONED|
+                                  exchange
+                                  -- ONE_TO_ONE_EXCHANGE  |PARTITIONED|
+                                    data-scan []<-[$$127, $$v] <- timetravel.Users
+                                    -- DATASOURCE_SCAN  |PARTITIONED|
+                                      exchange
+                                      -- ONE_TO_ONE_EXCHANGE  |PARTITIONED|
+                                        empty-tuple-source
+                                        -- EMPTY_TUPLE_SOURCE  |PARTITIONED|
+                      exchange
+                      -- ONE_TO_ONE_EXCHANGE  |PARTITIONED|
+                        join (eq($$127, $$128))
+                        -- HYBRID_HASH_JOIN [$$127][$$128]  |PARTITIONED|
+                          exchange
+                          -- ONE_TO_ONE_EXCHANGE  |PARTITIONED|
+                            replicate
+                            -- REPLICATE  |PARTITIONED|
+                              exchange
+                              -- ONE_TO_ONE_EXCHANGE  |PARTITIONED|
+                                project ([$$127])
+                                -- STREAM_PROJECT  |PARTITIONED|
+                                  exchange
+                                  -- ONE_TO_ONE_EXCHANGE  |PARTITIONED|
+                                    data-scan []<-[$$127, $$v] <- timetravel.Users
+                                    -- DATASOURCE_SCAN  |PARTITIONED|
+                                      exchange
+                                      -- ONE_TO_ONE_EXCHANGE  |PARTITIONED|
+                                        empty-tuple-source
+                                        -- EMPTY_TUPLE_SOURCE  |PARTITIONED|
+                          exchange
+                          -- ONE_TO_ONE_EXCHANGE  |PARTITIONED|
+                            replicate
+                            -- REPLICATE  |PARTITIONED|
+                              exchange
+                              -- ONE_TO_ONE_EXCHANGE  |PARTITIONED|
+                                project ([$$128])
+                                -- STREAM_PROJECT  |PARTITIONED|
+                                  exchange
+                                  -- ONE_TO_ONE_EXCHANGE  |PARTITIONED|
+                                    data-scan []<-[$$128, $$w] <- timetravel.Visitors
+                                    -- DATASOURCE_SCAN  |PARTITIONED|
+                                      exchange
+                                      -- ONE_TO_ONE_EXCHANGE  |PARTITIONED|
+                                        empty-tuple-source
+                                        -- EMPTY_TUPLE_SOURCE  |PARTITIONED|
+                  exchange
+                  -- ONE_TO_ONE_EXCHANGE  |PARTITIONED|
+                    assign [$$129] <- [$$128] project: [$$129]
+                    -- ASSIGN  |PARTITIONED|
+                      exchange
+                      -- ONE_TO_ONE_EXCHANGE  |PARTITIONED|
+                        replicate
+                        -- REPLICATE  |PARTITIONED|
+                          exchange
+                          -- ONE_TO_ONE_EXCHANGE  |PARTITIONED|
+                            project ([$$128])
+                            -- STREAM_PROJECT  |PARTITIONED|
+                              exchange
+                              -- ONE_TO_ONE_EXCHANGE  |PARTITIONED|
+                                data-scan []<-[$$128, $$w] <- timetravel.Visitors
+                                -- DATASOURCE_SCAN  |PARTITIONED|
+                                  exchange
+                                  -- ONE_TO_ONE_EXCHANGE  |PARTITIONED|
+                                    empty-tuple-source
+                                    -- EMPTY_TUPLE_SOURCE  |PARTITIONED|
+              exchange
+              -- ONE_TO_ONE_EXCHANGE  |PARTITIONED|
+                assign [$$130] <- [$$127] project: [$$130]
+                -- ASSIGN  |PARTITIONED|
+                  exchange
+                  -- ONE_TO_ONE_EXCHANGE  |PARTITIONED|
+                    replicate
+                    -- REPLICATE  |PARTITIONED|
+                      exchange
+                      -- ONE_TO_ONE_EXCHANGE  |PARTITIONED|
+                        project ([$$127])
+                        -- STREAM_PROJECT  |PARTITIONED|
+                          exchange
+                          -- ONE_TO_ONE_EXCHANGE  |PARTITIONED|
+                            data-scan []<-[$$127, $$v] <- timetravel.Users
+                            -- DATASOURCE_SCAN  |PARTITIONED|
+                              exchange
+                              -- ONE_TO_ONE_EXCHANGE  |PARTITIONED|
+                                empty-tuple-source
+                                -- EMPTY_TUPLE_SOURCE  |PARTITIONED|
+          exchange
+          -- ONE_TO_ONE_EXCHANGE  |PARTITIONED|
+            assign [$$131] <- [$$127] project: [$$131]
+            -- ASSIGN  |PARTITIONED|
+              exchange
+              -- ONE_TO_ONE_EXCHANGE  |PARTITIONED|
+                replicate
+                -- REPLICATE  |PARTITIONED|
+                  exchange
+                  -- ONE_TO_ONE_EXCHANGE  |PARTITIONED|
+                    project ([$$127])
+                    -- STREAM_PROJECT  |PARTITIONED|
+                      exchange
+                      -- ONE_TO_ONE_EXCHANGE  |PARTITIONED|
+                        data-scan []<-[$$127, $$v] <- timetravel.Users
+                        -- DATASOURCE_SCAN  |PARTITIONED|
+                          exchange
+                          -- ONE_TO_ONE_EXCHANGE  |PARTITIONED|
+                            empty-tuple-source
+                            -- EMPTY_TUPLE_SOURCE  |PARTITIONED|
diff --git a/asterixdb/asterix-app/src/test/resources/runtimets/queries_sqlpp/iceberg/snapshots/test.085.query.sqlpp b/asterixdb/asterix-app/src/test/resources/runtimets/queries_sqlpp/iceberg/snapshots/test.085.query.sqlpp
new file mode 100644
index 0000000..f1d8d61
--- /dev/null
+++ b/asterixdb/asterix-app/src/test/resources/runtimets/queries_sqlpp/iceberg/snapshots/test.085.query.sqlpp
@@ -0,0 +1,24 @@
+/*
+ * 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.
+ */
+
+// a bare integer snapshot id: the value is an expression now, not only a string literal.
+// snapshot 1 holds ids 1-800, so pointing this latest-reading collection at it gives 800
+// rather than the 997 it returns on its own
+SELECT count(*) as count
+FROM usersNoSnapshot AT snapshot %SNAPSHOT_1_SNAPSHOT_ID%;
diff --git a/asterixdb/asterix-app/src/test/resources/runtimets/queries_sqlpp/iceberg/snapshots/test.090.query.sqlpp b/asterixdb/asterix-app/src/test/resources/runtimets/queries_sqlpp/iceberg/snapshots/test.090.query.sqlpp
new file mode 100644
index 0000000..1e2c581
--- /dev/null
+++ b/asterixdb/asterix-app/src/test/resources/runtimets/queries_sqlpp/iceberg/snapshots/test.090.query.sqlpp
@@ -0,0 +1,23 @@
+/*
+ * 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.
+ */
+
+// a bare integer timestamp (epoch millis). The captured instant is just after snapshot 1 was
+// committed, so it resolves to snapshot 1: ids 1-800 => 800, not the latest 997
+SELECT count(*) as count
+FROM usersNoSnapshot AT timestamp %SNAPSHOT_1_TIMESTAMP_LONG_PLACEHOLDER%;
diff --git a/asterixdb/asterix-app/src/test/resources/runtimets/queries_sqlpp/iceberg/snapshots/test.100.query.sqlpp b/asterixdb/asterix-app/src/test/resources/runtimets/queries_sqlpp/iceberg/snapshots/test.100.query.sqlpp
new file mode 100644
index 0000000..a9e9901
--- /dev/null
+++ b/asterixdb/asterix-app/src/test/resources/runtimets/queries_sqlpp/iceberg/snapshots/test.100.query.sqlpp
@@ -0,0 +1,24 @@
+/*
+ * 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.
+ */
+
+// a datetime constructor call, folded at compile time. The instant is just after snapshot 2,
+// which holds ids 1-1000 => 1000. This collection's own snapshotId pins it to snapshot 1 (800),
+// so the statement's value has to win for the answer to be 1000
+SELECT count(*) as count
+FROM usersYesSnapshot AT timestamp datetime("%SNAPSHOT_2_TIMESTAMP_DATETIME_PLACEHOLDER%");
diff --git a/asterixdb/asterix-app/src/test/resources/runtimets/queries_sqlpp/iceberg/snapshots/test.110.query.sqlpp b/asterixdb/asterix-app/src/test/resources/runtimets/queries_sqlpp/iceberg/snapshots/test.110.query.sqlpp
new file mode 100644
index 0000000..fc2f3a8
--- /dev/null
+++ b/asterixdb/asterix-app/src/test/resources/runtimets/queries_sqlpp/iceberg/snapshots/test.110.query.sqlpp
@@ -0,0 +1,23 @@
+/*
+ * 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.
+ */
+
+// the same instant converted to epoch millis by a folded function call, so it must still land on
+// snapshot 2: ids 1-1000 => 1000, again overriding the collection's own snapshot 1 (800)
+SELECT count(*) as count
+FROM usersYesSnapshot AT timestamp unix_time_from_datetime_in_ms(datetime("%SNAPSHOT_2_TIMESTAMP_DATETIME_PLACEHOLDER%"));
diff --git a/asterixdb/asterix-app/src/test/resources/runtimets/queries_sqlpp/iceberg/snapshots/test.120.query.sqlpp b/asterixdb/asterix-app/src/test/resources/runtimets/queries_sqlpp/iceberg/snapshots/test.120.query.sqlpp
new file mode 100644
index 0000000..ed976d9
--- /dev/null
+++ b/asterixdb/asterix-app/src/test/resources/runtimets/queries_sqlpp/iceberg/snapshots/test.120.query.sqlpp
@@ -0,0 +1,24 @@
+/*
+ * 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.
+ */
+
+// date arithmetic: the date is the day after the last commit, and subtracting a zero duration
+// must not shift it, so this resolves to the latest snapshot -- ids 1-1000 less the deleted ids
+// 10/20/30 => 997, overriding the collection's own snapshot 1 (800)
+SELECT count(*) as count
+FROM usersYesSnapshot AT timestamp date("%SNAPSHOT_3_TIMESTAMP_DATE_PLACEHOLDER%") - duration("P0D");
diff --git a/asterixdb/asterix-app/src/test/resources/runtimets/queries_sqlpp/iceberg/snapshots/test.140.query.sqlpp b/asterixdb/asterix-app/src/test/resources/runtimets/queries_sqlpp/iceberg/snapshots/test.140.query.sqlpp
new file mode 100644
index 0000000..c0e3c0d
--- /dev/null
+++ b/asterixdb/asterix-app/src/test/resources/runtimets/queries_sqlpp/iceberg/snapshots/test.140.query.sqlpp
@@ -0,0 +1,25 @@
+/*
+ * 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.
+ */
+
+// the same collection at two snapshots and at latest, in one statement: each subquery resolves
+// its own value. snapshot 1 is ids 1-800 (800), snapshot 2 is ids 1-1000 (1000), and latest is
+// ids 1-1000 less the deleted ids 10/20/30 (997)
+SELECT (SELECT VALUE count(*) FROM usersNoSnapshot AT snapshot %SNAPSHOT_1_SNAPSHOT_ID%)[0] AS s1,
+       (SELECT VALUE count(*) FROM usersNoSnapshot AT timestamp "%SNAPSHOT_2_TIMESTAMP_DATETIME_PLACEHOLDER%")[0] AS s2,
+       (SELECT VALUE count(*) FROM usersNoSnapshot)[0] AS latest;
diff --git a/asterixdb/asterix-app/src/test/resources/runtimets/queries_sqlpp/iceberg/snapshots/test.150.query.sqlpp b/asterixdb/asterix-app/src/test/resources/runtimets/queries_sqlpp/iceberg/snapshots/test.150.query.sqlpp
new file mode 100644
index 0000000..abc9faa
--- /dev/null
+++ b/asterixdb/asterix-app/src/test/resources/runtimets/queries_sqlpp/iceberg/snapshots/test.150.query.sqlpp
@@ -0,0 +1,25 @@
+/*
+ * 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.
+ */
+
+// a JOIN clause carries its own snapshot. The left side reads latest (ids 1-1000 less the
+// deleted ids 10/20/30) and the right side snapshot 1 (ids 1-800), so the ids present in both
+// are 1-800 less 10/20/30 => 797
+SELECT count(*) AS count
+FROM usersNoSnapshot AS a
+JOIN usersNoSnapshot AS b AT snapshot %SNAPSHOT_1_SNAPSHOT_ID% ON a.id = b.id;
diff --git a/asterixdb/asterix-app/src/test/resources/runtimets/queries_sqlpp/iceberg/snapshots/test.160.query.sqlpp b/asterixdb/asterix-app/src/test/resources/runtimets/queries_sqlpp/iceberg/snapshots/test.160.query.sqlpp
new file mode 100644
index 0000000..4bf5bc3
--- /dev/null
+++ b/asterixdb/asterix-app/src/test/resources/runtimets/queries_sqlpp/iceberg/snapshots/test.160.query.sqlpp
@@ -0,0 +1,25 @@
+/*
+ * 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.
+ */
+
+// the snapshot survives the RIGHT OUTER JOIN rewrite, which swaps the join inputs. A right outer
+// join keeps every row of the snapshotted right side, so the count is snapshot 1's own
+// cardinality -- ids 1-800 => 800. The left side's missing 10/20/30 only null out those rows
+SELECT count(*) AS count
+FROM usersNoSnapshot AS a
+RIGHT OUTER JOIN usersNoSnapshot AS b AT snapshot %SNAPSHOT_1_SNAPSHOT_ID% ON a.id = b.id;
diff --git a/asterixdb/asterix-app/src/test/resources/runtimets/queries_sqlpp/iceberg/snapshots/test.170.query.sqlpp b/asterixdb/asterix-app/src/test/resources/runtimets/queries_sqlpp/iceberg/snapshots/test.170.query.sqlpp
new file mode 100644
index 0000000..d0cde59
--- /dev/null
+++ b/asterixdb/asterix-app/src/test/resources/runtimets/queries_sqlpp/iceberg/snapshots/test.170.query.sqlpp
@@ -0,0 +1,25 @@
+/*
+ * 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.
+ */
+
+-- param $ts:string=2030-01-01
+// the value supplied as a query parameter. 2030-01-01 is past every snapshot, so it resolves to
+// the latest one: ids 1-1000 less the deleted ids 10/20/30 => 997. A generated snapshot id
+// cannot be used here because the harness extracts parameters before it substitutes the
+// snapshot placeholders
+SELECT count(*) AS count FROM usersNoSnapshot AT timestamp $ts;
diff --git a/asterixdb/asterix-app/src/test/resources/runtimets/queries_sqlpp/iceberg/snapshots/test.180.query.sqlpp b/asterixdb/asterix-app/src/test/resources/runtimets/queries_sqlpp/iceberg/snapshots/test.180.query.sqlpp
new file mode 100644
index 0000000..93262f0
--- /dev/null
+++ b/asterixdb/asterix-app/src/test/resources/runtimets/queries_sqlpp/iceberg/snapshots/test.180.query.sqlpp
@@ -0,0 +1,23 @@
+/*
+ * 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.
+ */
+
+// a value bound earlier in the statement by WITH. It still has to fold to snapshot 1's id, so
+// this reads snapshot 1 -- ids 1-800 => 800, not the latest 997
+WITH s AS %SNAPSHOT_1_SNAPSHOT_ID%
+SELECT count(*) AS count FROM usersNoSnapshot AT snapshot s;
diff --git a/asterixdb/asterix-app/src/test/resources/runtimets/queries_sqlpp/iceberg/snapshots/test.190.query.sqlpp b/asterixdb/asterix-app/src/test/resources/runtimets/queries_sqlpp/iceberg/snapshots/test.190.query.sqlpp
new file mode 100644
index 0000000..5341db1
--- /dev/null
+++ b/asterixdb/asterix-app/src/test/resources/runtimets/queries_sqlpp/iceberg/snapshots/test.190.query.sqlpp
@@ -0,0 +1,22 @@
+/*
+ * 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.
+ */
+
+// arithmetic on the snapshot id itself: adding zero must fold back to the same id, so this stays
+// on snapshot 1 -- ids 1-800 => 800 rather than the latest 997
+SELECT count(*) AS count FROM usersNoSnapshot AT snapshot %SNAPSHOT_1_SNAPSHOT_ID% + 0;
diff --git a/asterixdb/asterix-app/src/test/resources/runtimets/queries_sqlpp/iceberg/snapshots/test.200.query.sqlpp b/asterixdb/asterix-app/src/test/resources/runtimets/queries_sqlpp/iceberg/snapshots/test.200.query.sqlpp
new file mode 100644
index 0000000..e9a7d83
--- /dev/null
+++ b/asterixdb/asterix-app/src/test/resources/runtimets/queries_sqlpp/iceberg/snapshots/test.200.query.sqlpp
@@ -0,0 +1,23 @@
+/*
+ * 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.
+ */
+
+// a time travelling scan inside a subquery: the value belongs to the inner FROM term, so the
+// outer count still sees only snapshot 1's ids 1-800 => 800, not the latest 997
+SELECT count(*) AS count
+FROM (SELECT VALUE u FROM usersNoSnapshot AS u AT snapshot %SNAPSHOT_1_SNAPSHOT_ID%) AS t;
diff --git a/asterixdb/asterix-app/src/test/resources/runtimets/queries_sqlpp/iceberg/snapshots/test.210.ddl.sqlpp b/asterixdb/asterix-app/src/test/resources/runtimets/queries_sqlpp/iceberg/snapshots/test.210.ddl.sqlpp
new file mode 100644
index 0000000..ccd3b9a
--- /dev/null
+++ b/asterixdb/asterix-app/src/test/resources/runtimets/queries_sqlpp/iceberg/snapshots/test.210.ddl.sqlpp
@@ -0,0 +1,22 @@
+/*
+ * 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.
+ */
+
+CREATE FUNCTION snapId() {
+  %SNAPSHOT_1_SNAPSHOT_ID%
+};
diff --git a/asterixdb/asterix-app/src/test/resources/runtimets/queries_sqlpp/iceberg/snapshots/test.220.query.sqlpp b/asterixdb/asterix-app/src/test/resources/runtimets/queries_sqlpp/iceberg/snapshots/test.220.query.sqlpp
new file mode 100644
index 0000000..84ef5c0
--- /dev/null
+++ b/asterixdb/asterix-app/src/test/resources/runtimets/queries_sqlpp/iceberg/snapshots/test.220.query.sqlpp
@@ -0,0 +1,22 @@
+/*
+ * 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.
+ */
+
+// a user-defined function whose body is a constant, inlined into the value before folding. It
+// returns snapshot 1's id, so this reads ids 1-800 => 800 rather than the latest 997
+SELECT count(*) AS count FROM usersNoSnapshot AT snapshot snapId();
diff --git a/asterixdb/asterix-app/src/test/resources/runtimets/queries_sqlpp/iceberg/snapshots/test.230.ddl.sqlpp b/asterixdb/asterix-app/src/test/resources/runtimets/queries_sqlpp/iceberg/snapshots/test.230.ddl.sqlpp
new file mode 100644
index 0000000..02af603
--- /dev/null
+++ b/asterixdb/asterix-app/src/test/resources/runtimets/queries_sqlpp/iceberg/snapshots/test.230.ddl.sqlpp
@@ -0,0 +1,20 @@
+/*
+ * 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.
+ */
+
+DROP FUNCTION snapId@0;
diff --git a/asterixdb/asterix-app/src/test/resources/runtimets/queries_sqlpp/iceberg/snapshots/test.240.query.sqlpp b/asterixdb/asterix-app/src/test/resources/runtimets/queries_sqlpp/iceberg/snapshots/test.240.query.sqlpp
new file mode 100644
index 0000000..207cb4f
--- /dev/null
+++ b/asterixdb/asterix-app/src/test/resources/runtimets/queries_sqlpp/iceberg/snapshots/test.240.query.sqlpp
@@ -0,0 +1,27 @@
+/*
+ * 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.
+ */
+
+// a snapshotted JOIN that precedes a RIGHT OUTER JOIN: the rewrite rebuilds the preceding
+// clause, and its snapshot has to survive that rebuild. a and c read usersYesSnapshot, pinned to
+// snapshot 1 (ids 1-800), and b is aimed at snapshot 1 too, so all 800 ids match => 800. Had the
+// rebuild dropped b's snapshot, b would read latest, lose 10/20/30, and give 797
+SELECT count(b.id) AS matched
+FROM usersYesSnapshot AS a
+JOIN usersNoSnapshot AS b AT snapshot %SNAPSHOT_1_SNAPSHOT_ID% ON a.id = b.id
+RIGHT OUTER JOIN usersYesSnapshot AS c ON b.id = c.id;
diff --git a/asterixdb/asterix-app/src/test/resources/runtimets/queries_sqlpp/iceberg/snapshots/test.250.ddl.sqlpp b/asterixdb/asterix-app/src/test/resources/runtimets/queries_sqlpp/iceberg/snapshots/test.250.ddl.sqlpp
new file mode 100644
index 0000000..82cff1a
--- /dev/null
+++ b/asterixdb/asterix-app/src/test/resources/runtimets/queries_sqlpp/iceberg/snapshots/test.250.ddl.sqlpp
@@ -0,0 +1,24 @@
+/*
+ * 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.
+ */
+
+CREATE FUNCTION joinedAtSnapshot() {
+  (SELECT count(b.id) AS matched
+   FROM usersYesSnapshot AS a
+   JOIN usersNoSnapshot AS b AT snapshot %SNAPSHOT_1_SNAPSHOT_ID% ON a.id = b.id)[0].matched
+};
diff --git a/asterixdb/asterix-app/src/test/resources/runtimets/queries_sqlpp/iceberg/snapshots/test.260.query.sqlpp b/asterixdb/asterix-app/src/test/resources/runtimets/queries_sqlpp/iceberg/snapshots/test.260.query.sqlpp
new file mode 100644
index 0000000..d7e2eb0
--- /dev/null
+++ b/asterixdb/asterix-app/src/test/resources/runtimets/queries_sqlpp/iceberg/snapshots/test.260.query.sqlpp
@@ -0,0 +1,23 @@
+/*
+ * 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.
+ */
+
+// a snapshotted JOIN inside a user-defined function body: inlining the function clones the
+// clause, which is the path the AST cloners take. Same shape as test.240 without the outer right
+// outer join, so all 800 of snapshot 1's ids match => 800; a dropped snapshot would give 797
+SELECT joinedAtSnapshot() AS matched;
diff --git a/asterixdb/asterix-app/src/test/resources/runtimets/queries_sqlpp/iceberg/snapshots/test.270.ddl.sqlpp b/asterixdb/asterix-app/src/test/resources/runtimets/queries_sqlpp/iceberg/snapshots/test.270.ddl.sqlpp
new file mode 100644
index 0000000..e7f32f4
--- /dev/null
+++ b/asterixdb/asterix-app/src/test/resources/runtimets/queries_sqlpp/iceberg/snapshots/test.270.ddl.sqlpp
@@ -0,0 +1,20 @@
+/*
+ * 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.
+ */
+
+DROP FUNCTION joinedAtSnapshot@0;
diff --git a/asterixdb/asterix-app/src/test/resources/runtimets/queries_sqlpp/iceberg/snapshots/test.280.ddl.sqlpp b/asterixdb/asterix-app/src/test/resources/runtimets/queries_sqlpp/iceberg/snapshots/test.280.ddl.sqlpp
new file mode 100644
index 0000000..db996c9
--- /dev/null
+++ b/asterixdb/asterix-app/src/test/resources/runtimets/queries_sqlpp/iceberg/snapshots/test.280.ddl.sqlpp
@@ -0,0 +1,22 @@
+/*
+ * 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.
+ */
+
+// the view body pins snapshot 1 (ids 1-800); test.290 counts through it and must see 800
+CREATE VIEW usersAtSnapshot AS
+SELECT VALUE u FROM usersNoSnapshot AS u AT snapshot %SNAPSHOT_1_SNAPSHOT_ID%;
diff --git a/asterixdb/asterix-app/src/test/resources/runtimets/queries_sqlpp/iceberg/snapshots/test.290.query.sqlpp b/asterixdb/asterix-app/src/test/resources/runtimets/queries_sqlpp/iceberg/snapshots/test.290.query.sqlpp
new file mode 100644
index 0000000..ca7634e
--- /dev/null
+++ b/asterixdb/asterix-app/src/test/resources/runtimets/queries_sqlpp/iceberg/snapshots/test.290.query.sqlpp
@@ -0,0 +1,23 @@
+/*
+ * 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.
+ */
+
+// a view over a time travelling scan. A view body is stored as source text and re-parsed on use,
+// so this exercises the grammar a second time rather than the AST that produced it. The view pins
+// snapshot 1, so counting through it gives ids 1-800 => 800, not the latest 997
+SELECT count(*) AS count FROM usersAtSnapshot;
diff --git a/asterixdb/asterix-app/src/test/resources/runtimets/queries_sqlpp/iceberg/snapshots/test.300.ddl.sqlpp b/asterixdb/asterix-app/src/test/resources/runtimets/queries_sqlpp/iceberg/snapshots/test.300.ddl.sqlpp
new file mode 100644
index 0000000..388772a
--- /dev/null
+++ b/asterixdb/asterix-app/src/test/resources/runtimets/queries_sqlpp/iceberg/snapshots/test.300.ddl.sqlpp
@@ -0,0 +1,21 @@
+/*
+ * 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.
+ */
+
+// drop the view created by test.280 so the collections can be dropped at the end
+DROP VIEW usersAtSnapshot;
diff --git a/asterixdb/asterix-app/src/test/resources/runtimets/queries_sqlpp/iceberg/snapshots/test.310.query.sqlpp b/asterixdb/asterix-app/src/test/resources/runtimets/queries_sqlpp/iceberg/snapshots/test.310.query.sqlpp
new file mode 100644
index 0000000..eff3705
--- /dev/null
+++ b/asterixdb/asterix-app/src/test/resources/runtimets/queries_sqlpp/iceberg/snapshots/test.310.query.sqlpp
@@ -0,0 +1,29 @@
+/*
+ * 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.
+ */
+
+// GROUP BY above a snapshotted JOIN: the group-by sugar rewrite clones the FROM clause, so the
+// JOIN's snapshot has to survive that clone. The left side reads latest (ids 1-1000 less the
+// deleted ids 10/20/30) and the right side snapshot 1 (ids 1-800), so 797 ids match: 397 at or
+// below 400 (400 less the deleted 10/20/30) and 400 above it. Had the clone dropped the snapshot,
+// the right side would read latest too and the false group would be 600 instead of 400
+SELECT lo, count(*) AS c
+FROM usersNoSnapshot AS a
+JOIN usersNoSnapshot AS b AT snapshot %SNAPSHOT_1_SNAPSHOT_ID% ON a.id = b.id
+GROUP BY a.id <= 400 AS lo
+ORDER BY lo;
diff --git a/asterixdb/asterix-app/src/test/resources/runtimets/queries_sqlpp/iceberg/snapshots/test.320.query.sqlpp b/asterixdb/asterix-app/src/test/resources/runtimets/queries_sqlpp/iceberg/snapshots/test.320.query.sqlpp
new file mode 100644
index 0000000..0811800
--- /dev/null
+++ b/asterixdb/asterix-app/src/test/resources/runtimets/queries_sqlpp/iceberg/snapshots/test.320.query.sqlpp
@@ -0,0 +1,25 @@
+/*
+ * 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.
+ */
+
+// a window function over a snapshotted scan: the window rewrite restructures the select block
+// above the FROM term. row_number() numbers the snapshot's own rows, so the highest is snapshot
+// 1's cardinality -- ids 1-800 => 800, where the latest snapshot would reach 997
+SELECT max(t.rn) AS maxRn
+FROM (SELECT row_number() OVER (ORDER BY u.id) AS rn
+      FROM usersNoSnapshot AS u AT snapshot %SNAPSHOT_1_SNAPSHOT_ID%) AS t;
diff --git a/asterixdb/asterix-app/src/test/resources/runtimets/queries_sqlpp/iceberg/snapshots/test.330.query.sqlpp b/asterixdb/asterix-app/src/test/resources/runtimets/queries_sqlpp/iceberg/snapshots/test.330.query.sqlpp
new file mode 100644
index 0000000..251e991
--- /dev/null
+++ b/asterixdb/asterix-app/src/test/resources/runtimets/queries_sqlpp/iceberg/snapshots/test.330.query.sqlpp
@@ -0,0 +1,26 @@
+/*
+ * 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.
+ */
+
+// UNION ALL of the same collection at a snapshot and at latest: each branch is its own FROM term,
+// built by the set-operation rewrite, so each keeps its own value. snapshot 1 contributes 800 rows
+// (ids 1-800) and latest contributes 997 (ids 1-1000 less the deleted ids 10/20/30) => 1797
+SELECT count(*) AS c
+FROM (SELECT u.id FROM usersNoSnapshot AS u AT snapshot %SNAPSHOT_1_SNAPSHOT_ID%
+      UNION ALL
+      SELECT v.id FROM usersNoSnapshot AS v) AS t;
diff --git a/asterixdb/asterix-app/src/test/resources/runtimets/queries_sqlpp/iceberg/snapshots/test.340.query.sqlpp b/asterixdb/asterix-app/src/test/resources/runtimets/queries_sqlpp/iceberg/snapshots/test.340.query.sqlpp
new file mode 100644
index 0000000..15c5a4f
--- /dev/null
+++ b/asterixdb/asterix-app/src/test/resources/runtimets/queries_sqlpp/iceberg/snapshots/test.340.query.sqlpp
@@ -0,0 +1,27 @@
+/*
+ * 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.
+ */
+
+// RIGHT OUTER JOIN with a snapshot on *both* sides: the rewrite swaps the join inputs, so both
+// values have to survive the swap. Counting the LEFT id is what discriminates -- the join keeps
+// all 800 right rows either way, but a.id is null wherever the left side lacks that id. Both
+// sides read snapshot 1 (ids 1-800), so every row matches => 800; had the left snapshot been
+// dropped, the left side would read latest, lose 10/20/30, and give 797
+SELECT count(a.id) AS matched
+FROM usersNoSnapshot AS a AT snapshot %SNAPSHOT_1_SNAPSHOT_ID%
+RIGHT OUTER JOIN usersNoSnapshot AS b AT snapshot %SNAPSHOT_1_SNAPSHOT_ID% ON a.id = b.id;
diff --git a/asterixdb/asterix-app/src/test/resources/runtimets/queries_sqlpp/iceberg/snapshots/test.360.query.sqlpp b/asterixdb/asterix-app/src/test/resources/runtimets/queries_sqlpp/iceberg/snapshots/test.360.query.sqlpp
new file mode 100644
index 0000000..87ba481
--- /dev/null
+++ b/asterixdb/asterix-app/src/test/resources/runtimets/queries_sqlpp/iceberg/snapshots/test.360.query.sqlpp
@@ -0,0 +1,26 @@
+/*
+ * 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.
+ */
+
+// three FROM terms, a different value on each, so the result has to discriminate all three:
+// a reads snapshot 1 (ids 1-800), b snapshot 2 (ids 1-1000), and c latest (ids 1-1000 less the
+// deleted ids 10/20/30). The ids present in all three are 1-800 less 10/20/30 => 797
+SELECT count(*) AS c
+FROM usersNoSnapshot AS a AT snapshot %SNAPSHOT_1_SNAPSHOT_ID%
+JOIN usersNoSnapshot AS b AT timestamp "%SNAPSHOT_2_TIMESTAMP_DATETIME_PLACEHOLDER%" ON a.id = b.id
+JOIN usersNoSnapshot AS c ON b.id = c.id;
diff --git a/asterixdb/asterix-app/src/test/resources/runtimets/queries_sqlpp/misc/time-travel-negative/time-travel-negative.1.ddl.sqlpp b/asterixdb/asterix-app/src/test/resources/runtimets/queries_sqlpp/misc/time-travel-negative/time-travel-negative.1.ddl.sqlpp
new file mode 100644
index 0000000..a4881f3
--- /dev/null
+++ b/asterixdb/asterix-app/src/test/resources/runtimets/queries_sqlpp/misc/time-travel-negative/time-travel-negative.1.ddl.sqlpp
@@ -0,0 +1,24 @@
+/*
+ * 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.
+ */
+
+drop dataverse tt if exists;
+create dataverse tt;
+use tt;
+create type tt.T as { uid : integer };
+create dataset ds(T) primary key uid;
diff --git a/asterixdb/asterix-app/src/test/resources/runtimets/queries_sqlpp/misc/time-travel-negative/time-travel-negative.10.query.sqlpp b/asterixdb/asterix-app/src/test/resources/runtimets/queries_sqlpp/misc/time-travel-negative/time-travel-negative.10.query.sqlpp
new file mode 100644
index 0000000..4413402
--- /dev/null
+++ b/asterixdb/asterix-app/src/test/resources/runtimets/queries_sqlpp/misc/time-travel-negative/time-travel-negative.10.query.sqlpp
@@ -0,0 +1,23 @@
+/*
+ * 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.
+ */
+
+use tt;
+// nor can a boolean
+// expect: ASX1260, reporting a boolean
+select value d.uid from ds as d AT SNAPSHOT true;
diff --git a/asterixdb/asterix-app/src/test/resources/runtimets/queries_sqlpp/misc/time-travel-negative/time-travel-negative.11.query.sqlpp b/asterixdb/asterix-app/src/test/resources/runtimets/queries_sqlpp/misc/time-travel-negative/time-travel-negative.11.query.sqlpp
new file mode 100644
index 0000000..3970428
--- /dev/null
+++ b/asterixdb/asterix-app/src/test/resources/runtimets/queries_sqlpp/misc/time-travel-negative/time-travel-negative.11.query.sqlpp
@@ -0,0 +1,23 @@
+/*
+ * 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.
+ */
+
+use tt;
+// the JOIN clause validates its own value, not only the FROM term
+// expect: ASX1106 "Expected constant value", raised for the JOIN clause rather than the FROM term
+select value d.uid from ds as d join ds as e AT SNAPSHOT uuid() on d.uid = e.uid;
diff --git a/asterixdb/asterix-app/src/test/resources/runtimets/queries_sqlpp/misc/time-travel-negative/time-travel-negative.12.query.sqlpp b/asterixdb/asterix-app/src/test/resources/runtimets/queries_sqlpp/misc/time-travel-negative/time-travel-negative.12.query.sqlpp
new file mode 100644
index 0000000..5b8c07e
--- /dev/null
+++ b/asterixdb/asterix-app/src/test/resources/runtimets/queries_sqlpp/misc/time-travel-negative/time-travel-negative.12.query.sqlpp
@@ -0,0 +1,27 @@
+/*
+ * 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.
+ */
+
+use tt;
+
+// the value is resolved before the FROM term opens its own scope, so the term cannot see the
+// variable it is itself binding. Expect ASX1073 "undefined identifier d" pointing at the value,
+// not ASX1106 "expected constant value" pointing at the whole clause: had the value been resolved
+// inside the scope, d would bind to the term's own item and become a per-row field access, which
+// fails much later and much less helpfully
+select value d.uid from ds as d AT SNAPSHOT d.uid;
diff --git a/asterixdb/asterix-app/src/test/resources/runtimets/queries_sqlpp/misc/time-travel-negative/time-travel-negative.13.query.sqlpp b/asterixdb/asterix-app/src/test/resources/runtimets/queries_sqlpp/misc/time-travel-negative/time-travel-negative.13.query.sqlpp
new file mode 100644
index 0000000..58d007b
--- /dev/null
+++ b/asterixdb/asterix-app/src/test/resources/runtimets/queries_sqlpp/misc/time-travel-negative/time-travel-negative.13.query.sqlpp
@@ -0,0 +1,25 @@
+/*
+ * 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.
+ */
+
+use tt;
+
+// a variable bound by a *preceding* FROM term IS in scope here, so x resolves cleanly. Expect
+// ASX1106 "expected constant value" instead: a per-row value can never be folded. This is the
+// free-variable path rather than the unresolved-identifier one in case 12
+select value d.uid from ds as x, ds as d AT SNAPSHOT x.uid;
diff --git a/asterixdb/asterix-app/src/test/resources/runtimets/queries_sqlpp/misc/time-travel-negative/time-travel-negative.14.query.sqlpp b/asterixdb/asterix-app/src/test/resources/runtimets/queries_sqlpp/misc/time-travel-negative/time-travel-negative.14.query.sqlpp
new file mode 100644
index 0000000..c52a309
--- /dev/null
+++ b/asterixdb/asterix-app/src/test/resources/runtimets/queries_sqlpp/misc/time-travel-negative/time-travel-negative.14.query.sqlpp
@@ -0,0 +1,25 @@
+/*
+ * 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.
+ */
+
+use tt;
+
+// the grammar accepts a value and a positional variable on the same term, since AT introduces
+// both. Expect ASX1079: time travel needs a collection, and positional variables are rejected
+// over collections, so this combination parses but can never execute
+select value d.uid from ds as d AT SNAPSHOT 1 AT p;
diff --git a/asterixdb/asterix-app/src/test/resources/runtimets/queries_sqlpp/misc/time-travel-negative/time-travel-negative.15.ddl.sqlpp b/asterixdb/asterix-app/src/test/resources/runtimets/queries_sqlpp/misc/time-travel-negative/time-travel-negative.15.ddl.sqlpp
new file mode 100644
index 0000000..b8e46ca
--- /dev/null
+++ b/asterixdb/asterix-app/src/test/resources/runtimets/queries_sqlpp/misc/time-travel-negative/time-travel-negative.15.ddl.sqlpp
@@ -0,0 +1,20 @@
+/*
+ * 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.
+ */
+
+drop dataverse tt if exists;
diff --git a/asterixdb/asterix-app/src/test/resources/runtimets/queries_sqlpp/misc/time-travel-negative/time-travel-negative.2.query.sqlpp b/asterixdb/asterix-app/src/test/resources/runtimets/queries_sqlpp/misc/time-travel-negative/time-travel-negative.2.query.sqlpp
new file mode 100644
index 0000000..163a079
--- /dev/null
+++ b/asterixdb/asterix-app/src/test/resources/runtimets/queries_sqlpp/misc/time-travel-negative/time-travel-negative.2.query.sqlpp
@@ -0,0 +1,23 @@
+/*
+ * 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.
+ */
+
+use tt;
+// non-deterministic: cannot be folded to a constant
+// expect: ASX1106 "Expected constant value" -- uuid() is non-deterministic, so it never folds
+select value d.uid from ds as d AT SNAPSHOT uuid();
diff --git a/asterixdb/asterix-app/src/test/resources/runtimets/queries_sqlpp/misc/time-travel-negative/time-travel-negative.3.query.sqlpp b/asterixdb/asterix-app/src/test/resources/runtimets/queries_sqlpp/misc/time-travel-negative/time-travel-negative.3.query.sqlpp
new file mode 100644
index 0000000..3779a9e
--- /dev/null
+++ b/asterixdb/asterix-app/src/test/resources/runtimets/queries_sqlpp/misc/time-travel-negative/time-travel-negative.3.query.sqlpp
@@ -0,0 +1,23 @@
+/*
+ * 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.
+ */
+
+use tt;
+// a snapshot id is an integer, not a date
+// expect: ASX1260, reporting a date where an integer or string is required
+select value d.uid from ds as d AT SNAPSHOT date("2026-08-20");
diff --git a/asterixdb/asterix-app/src/test/resources/runtimets/queries_sqlpp/misc/time-travel-negative/time-travel-negative.4.query.sqlpp b/asterixdb/asterix-app/src/test/resources/runtimets/queries_sqlpp/misc/time-travel-negative/time-travel-negative.4.query.sqlpp
new file mode 100644
index 0000000..1b76bdc
--- /dev/null
+++ b/asterixdb/asterix-app/src/test/resources/runtimets/queries_sqlpp/misc/time-travel-negative/time-travel-negative.4.query.sqlpp
@@ -0,0 +1,23 @@
+/*
+ * 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.
+ */
+
+use tt;
+// date arithmetic takes a duration, not a number; the mismatch folds to null
+// expect: ASX1260, reporting null -- the date/number mismatch folds to null before it is checked
+select value d.uid from ds as d AT TIMESTAMP date("2026-08-20") - 1;
diff --git a/asterixdb/asterix-app/src/test/resources/runtimets/queries_sqlpp/misc/time-travel-negative/time-travel-negative.5.query.sqlpp b/asterixdb/asterix-app/src/test/resources/runtimets/queries_sqlpp/misc/time-travel-negative/time-travel-negative.5.query.sqlpp
new file mode 100644
index 0000000..115e84f
--- /dev/null
+++ b/asterixdb/asterix-app/src/test/resources/runtimets/queries_sqlpp/misc/time-travel-negative/time-travel-negative.5.query.sqlpp
@@ -0,0 +1,23 @@
+/*
+ * 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.
+ */
+
+use tt;
+// a fractional number cannot identify a snapshot
+// expect: ASX1260, reporting a double
+select value d.uid from ds as d AT TIMESTAMP 1.5;
diff --git a/asterixdb/asterix-app/src/test/resources/runtimets/queries_sqlpp/misc/time-travel-negative/time-travel-negative.6.query.sqlpp b/asterixdb/asterix-app/src/test/resources/runtimets/queries_sqlpp/misc/time-travel-negative/time-travel-negative.6.query.sqlpp
new file mode 100644
index 0000000..4b58399
--- /dev/null
+++ b/asterixdb/asterix-app/src/test/resources/runtimets/queries_sqlpp/misc/time-travel-negative/time-travel-negative.6.query.sqlpp
@@ -0,0 +1,23 @@
+/*
+ * 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.
+ */
+
+use tt;
+// a subquery needs operators of its own, so it is not a compile-time constant
+// expect: ASX1106 "Expected constant value" -- the subquery needs operators of its own
+select value d.uid from ds as d AT SNAPSHOT (select value 1 from ds);
diff --git a/asterixdb/asterix-app/src/test/resources/runtimets/queries_sqlpp/misc/time-travel-negative/time-travel-negative.7.query.sqlpp b/asterixdb/asterix-app/src/test/resources/runtimets/queries_sqlpp/misc/time-travel-negative/time-travel-negative.7.query.sqlpp
new file mode 100644
index 0000000..8296c2c
--- /dev/null
+++ b/asterixdb/asterix-app/src/test/resources/runtimets/queries_sqlpp/misc/time-travel-negative/time-travel-negative.7.query.sqlpp
@@ -0,0 +1,23 @@
+/*
+ * 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.
+ */
+
+use tt;
+// no value was supplied for the parameter
+// expect: ASX1086 "No value for parameter: $ts"
+select value d.uid from ds as d AT SNAPSHOT $ts;
diff --git a/asterixdb/asterix-app/src/test/resources/runtimets/queries_sqlpp/misc/time-travel-negative/time-travel-negative.8.query.sqlpp b/asterixdb/asterix-app/src/test/resources/runtimets/queries_sqlpp/misc/time-travel-negative/time-travel-negative.8.query.sqlpp
new file mode 100644
index 0000000..f9d286d
--- /dev/null
+++ b/asterixdb/asterix-app/src/test/resources/runtimets/queries_sqlpp/misc/time-travel-negative/time-travel-negative.8.query.sqlpp
@@ -0,0 +1,24 @@
+/*
+ * 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.
+ */
+
+use tt;
+// the current_* functions read the job start time, so their value is not known while the query is
+// being compiled
+// expect: ASX1106 "Expected constant value" -- current_date() reads the job start time, set after compilation
+select value d.uid from ds as d AT TIMESTAMP current_date() - duration("P3M");
diff --git a/asterixdb/asterix-app/src/test/resources/runtimets/queries_sqlpp/misc/time-travel-negative/time-travel-negative.9.query.sqlpp b/asterixdb/asterix-app/src/test/resources/runtimets/queries_sqlpp/misc/time-travel-negative/time-travel-negative.9.query.sqlpp
new file mode 100644
index 0000000..bdadba2
--- /dev/null
+++ b/asterixdb/asterix-app/src/test/resources/runtimets/queries_sqlpp/misc/time-travel-negative/time-travel-negative.9.query.sqlpp
@@ -0,0 +1,23 @@
+/*
+ * 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.
+ */
+
+use tt;
+// a bare time of day cannot identify a snapshot
+// expect: ASX1260, reporting a time
+select value d.uid from ds as d AT TIMESTAMP time("12:00:00");
diff --git a/asterixdb/asterix-app/src/test/resources/runtimets/results/iceberg/snapshots/result.085.adm b/asterixdb/asterix-app/src/test/resources/runtimets/results/iceberg/snapshots/result.085.adm
new file mode 100644
index 0000000..be08cd8
--- /dev/null
+++ b/asterixdb/asterix-app/src/test/resources/runtimets/results/iceberg/snapshots/result.085.adm
@@ -0,0 +1 @@
+{ "count": 800 }
\ No newline at end of file
diff --git a/asterixdb/asterix-app/src/test/resources/runtimets/results/iceberg/snapshots/result.090.adm b/asterixdb/asterix-app/src/test/resources/runtimets/results/iceberg/snapshots/result.090.adm
new file mode 100644
index 0000000..be08cd8
--- /dev/null
+++ b/asterixdb/asterix-app/src/test/resources/runtimets/results/iceberg/snapshots/result.090.adm
@@ -0,0 +1 @@
+{ "count": 800 }
\ No newline at end of file
diff --git a/asterixdb/asterix-app/src/test/resources/runtimets/results/iceberg/snapshots/result.100.adm b/asterixdb/asterix-app/src/test/resources/runtimets/results/iceberg/snapshots/result.100.adm
new file mode 100644
index 0000000..21e48a2
--- /dev/null
+++ b/asterixdb/asterix-app/src/test/resources/runtimets/results/iceberg/snapshots/result.100.adm
@@ -0,0 +1 @@
+{ "count": 1000 }
\ No newline at end of file
diff --git a/asterixdb/asterix-app/src/test/resources/runtimets/results/iceberg/snapshots/result.110.adm b/asterixdb/asterix-app/src/test/resources/runtimets/results/iceberg/snapshots/result.110.adm
new file mode 100644
index 0000000..21e48a2
--- /dev/null
+++ b/asterixdb/asterix-app/src/test/resources/runtimets/results/iceberg/snapshots/result.110.adm
@@ -0,0 +1 @@
+{ "count": 1000 }
\ No newline at end of file
diff --git a/asterixdb/asterix-app/src/test/resources/runtimets/results/iceberg/snapshots/result.120.adm b/asterixdb/asterix-app/src/test/resources/runtimets/results/iceberg/snapshots/result.120.adm
new file mode 100644
index 0000000..0d88a9d
--- /dev/null
+++ b/asterixdb/asterix-app/src/test/resources/runtimets/results/iceberg/snapshots/result.120.adm
@@ -0,0 +1 @@
+{ "count": 997 }
\ No newline at end of file
diff --git a/asterixdb/asterix-app/src/test/resources/runtimets/results/iceberg/snapshots/result.140.adm b/asterixdb/asterix-app/src/test/resources/runtimets/results/iceberg/snapshots/result.140.adm
new file mode 100644
index 0000000..29ae63f
--- /dev/null
+++ b/asterixdb/asterix-app/src/test/resources/runtimets/results/iceberg/snapshots/result.140.adm
@@ -0,0 +1 @@
+{ "s1": 800, "s2": 1000, "latest": 997 }
\ No newline at end of file
diff --git a/asterixdb/asterix-app/src/test/resources/runtimets/results/iceberg/snapshots/result.150.adm b/asterixdb/asterix-app/src/test/resources/runtimets/results/iceberg/snapshots/result.150.adm
new file mode 100644
index 0000000..4de53e2
--- /dev/null
+++ b/asterixdb/asterix-app/src/test/resources/runtimets/results/iceberg/snapshots/result.150.adm
@@ -0,0 +1 @@
+{ "count": 797 }
\ No newline at end of file
diff --git a/asterixdb/asterix-app/src/test/resources/runtimets/results/iceberg/snapshots/result.160.adm b/asterixdb/asterix-app/src/test/resources/runtimets/results/iceberg/snapshots/result.160.adm
new file mode 100644
index 0000000..be08cd8
--- /dev/null
+++ b/asterixdb/asterix-app/src/test/resources/runtimets/results/iceberg/snapshots/result.160.adm
@@ -0,0 +1 @@
+{ "count": 800 }
\ No newline at end of file
diff --git a/asterixdb/asterix-app/src/test/resources/runtimets/results/iceberg/snapshots/result.170.adm b/asterixdb/asterix-app/src/test/resources/runtimets/results/iceberg/snapshots/result.170.adm
new file mode 100644
index 0000000..0d88a9d
--- /dev/null
+++ b/asterixdb/asterix-app/src/test/resources/runtimets/results/iceberg/snapshots/result.170.adm
@@ -0,0 +1 @@
+{ "count": 997 }
\ No newline at end of file
diff --git a/asterixdb/asterix-app/src/test/resources/runtimets/results/iceberg/snapshots/result.180.adm b/asterixdb/asterix-app/src/test/resources/runtimets/results/iceberg/snapshots/result.180.adm
new file mode 100644
index 0000000..be08cd8
--- /dev/null
+++ b/asterixdb/asterix-app/src/test/resources/runtimets/results/iceberg/snapshots/result.180.adm
@@ -0,0 +1 @@
+{ "count": 800 }
\ No newline at end of file
diff --git a/asterixdb/asterix-app/src/test/resources/runtimets/results/iceberg/snapshots/result.190.adm b/asterixdb/asterix-app/src/test/resources/runtimets/results/iceberg/snapshots/result.190.adm
new file mode 100644
index 0000000..be08cd8
--- /dev/null
+++ b/asterixdb/asterix-app/src/test/resources/runtimets/results/iceberg/snapshots/result.190.adm
@@ -0,0 +1 @@
+{ "count": 800 }
\ No newline at end of file
diff --git a/asterixdb/asterix-app/src/test/resources/runtimets/results/iceberg/snapshots/result.200.adm b/asterixdb/asterix-app/src/test/resources/runtimets/results/iceberg/snapshots/result.200.adm
new file mode 100644
index 0000000..be08cd8
--- /dev/null
+++ b/asterixdb/asterix-app/src/test/resources/runtimets/results/iceberg/snapshots/result.200.adm
@@ -0,0 +1 @@
+{ "count": 800 }
\ No newline at end of file
diff --git a/asterixdb/asterix-app/src/test/resources/runtimets/results/iceberg/snapshots/result.220.adm b/asterixdb/asterix-app/src/test/resources/runtimets/results/iceberg/snapshots/result.220.adm
new file mode 100644
index 0000000..be08cd8
--- /dev/null
+++ b/asterixdb/asterix-app/src/test/resources/runtimets/results/iceberg/snapshots/result.220.adm
@@ -0,0 +1 @@
+{ "count": 800 }
\ No newline at end of file
diff --git a/asterixdb/asterix-app/src/test/resources/runtimets/results/iceberg/snapshots/result.240.adm b/asterixdb/asterix-app/src/test/resources/runtimets/results/iceberg/snapshots/result.240.adm
new file mode 100644
index 0000000..ffd8cad
--- /dev/null
+++ b/asterixdb/asterix-app/src/test/resources/runtimets/results/iceberg/snapshots/result.240.adm
@@ -0,0 +1 @@
+{ "matched": 800 }
\ No newline at end of file
diff --git a/asterixdb/asterix-app/src/test/resources/runtimets/results/iceberg/snapshots/result.260.adm b/asterixdb/asterix-app/src/test/resources/runtimets/results/iceberg/snapshots/result.260.adm
new file mode 100644
index 0000000..ffd8cad
--- /dev/null
+++ b/asterixdb/asterix-app/src/test/resources/runtimets/results/iceberg/snapshots/result.260.adm
@@ -0,0 +1 @@
+{ "matched": 800 }
\ No newline at end of file
diff --git a/asterixdb/asterix-app/src/test/resources/runtimets/results/iceberg/snapshots/result.290.adm b/asterixdb/asterix-app/src/test/resources/runtimets/results/iceberg/snapshots/result.290.adm
new file mode 100644
index 0000000..be08cd8
--- /dev/null
+++ b/asterixdb/asterix-app/src/test/resources/runtimets/results/iceberg/snapshots/result.290.adm
@@ -0,0 +1 @@
+{ "count": 800 }
\ No newline at end of file
diff --git a/asterixdb/asterix-app/src/test/resources/runtimets/results/iceberg/snapshots/result.310.adm b/asterixdb/asterix-app/src/test/resources/runtimets/results/iceberg/snapshots/result.310.adm
new file mode 100644
index 0000000..a84cac2
--- /dev/null
+++ b/asterixdb/asterix-app/src/test/resources/runtimets/results/iceberg/snapshots/result.310.adm
@@ -0,0 +1,2 @@
+{ "lo": false, "c": 400 }
+{ "lo": true, "c": 397 }
\ No newline at end of file
diff --git a/asterixdb/asterix-app/src/test/resources/runtimets/results/iceberg/snapshots/result.320.adm b/asterixdb/asterix-app/src/test/resources/runtimets/results/iceberg/snapshots/result.320.adm
new file mode 100644
index 0000000..17f5935
--- /dev/null
+++ b/asterixdb/asterix-app/src/test/resources/runtimets/results/iceberg/snapshots/result.320.adm
@@ -0,0 +1 @@
+{ "maxRn": 800 }
\ No newline at end of file
diff --git a/asterixdb/asterix-app/src/test/resources/runtimets/results/iceberg/snapshots/result.330.adm b/asterixdb/asterix-app/src/test/resources/runtimets/results/iceberg/snapshots/result.330.adm
new file mode 100644
index 0000000..2decc56
--- /dev/null
+++ b/asterixdb/asterix-app/src/test/resources/runtimets/results/iceberg/snapshots/result.330.adm
@@ -0,0 +1 @@
+{ "c": 1797 }
\ No newline at end of file
diff --git a/asterixdb/asterix-app/src/test/resources/runtimets/results/iceberg/snapshots/result.340.adm b/asterixdb/asterix-app/src/test/resources/runtimets/results/iceberg/snapshots/result.340.adm
new file mode 100644
index 0000000..ffd8cad
--- /dev/null
+++ b/asterixdb/asterix-app/src/test/resources/runtimets/results/iceberg/snapshots/result.340.adm
@@ -0,0 +1 @@
+{ "matched": 800 }
\ No newline at end of file
diff --git a/asterixdb/asterix-app/src/test/resources/runtimets/results/iceberg/snapshots/result.360.adm b/asterixdb/asterix-app/src/test/resources/runtimets/results/iceberg/snapshots/result.360.adm
new file mode 100644
index 0000000..eb31ab1
--- /dev/null
+++ b/asterixdb/asterix-app/src/test/resources/runtimets/results/iceberg/snapshots/result.360.adm
@@ -0,0 +1 @@
+{ "c": 797 }
\ No newline at end of file
diff --git a/asterixdb/asterix-app/src/test/resources/runtimets/sqlpp_queries.xml b/asterixdb/asterix-app/src/test/resources/runtimets/sqlpp_queries.xml
index d56879a..abe66ee 100644
--- a/asterixdb/asterix-app/src/test/resources/runtimets/sqlpp_queries.xml
+++ b/asterixdb/asterix-app/src/test/resources/runtimets/sqlpp_queries.xml
@@ -7241,6 +7241,24 @@
   </test-group>
   <test-group name="misc">
     <test-case FilePath="misc">
+      <compilation-unit name="time-travel-negative">
+        <output-dir compare="Text">time-travel-negative</output-dir>
+        <expected-error>ASX1106: Expected constant value</expected-error>
+        <expected-error>ASX1260: Invalid 'AT SNAPSHOT' value: expected an integer or a string, but got date</expected-error>
+        <expected-error>ASX1260: Invalid 'AT TIMESTAMP' value: expected an integer, a string, a date or a datetime, but got null</expected-error>
+        <expected-error>ASX1260: Invalid 'AT TIMESTAMP' value: expected an integer, a string, a date or a datetime, but got double</expected-error>
+        <expected-error>ASX1106: Expected constant value</expected-error>
+        <expected-error>ASX1086: No value for parameter: $ts</expected-error>
+        <expected-error>ASX1106: Expected constant value</expected-error>
+        <expected-error>ASX1260: Invalid 'AT TIMESTAMP' value: expected an integer, a string, a date or a datetime, but got time</expected-error>
+        <expected-error>ASX1260: Invalid 'AT SNAPSHOT' value: expected an integer or a string, but got boolean</expected-error>
+        <expected-error>ASX1106: Expected constant value</expected-error>
+        <expected-error>ASX1073: Cannot resolve alias reference for undefined identifier d</expected-error>
+        <expected-error>ASX1106: Expected constant value</expected-error>
+        <expected-error>ASX1079: Compilation error: No positional variables are allowed over datasets</expected-error>
+      </compilation-unit>
+    </test-case>
+    <test-case FilePath="misc">
       <compilation-unit name="big_in_list/000">
         <output-dir compare="Text">big_in_list/000</output-dir>
       </compilation-unit>
diff --git a/asterixdb/asterix-common/src/main/java/org/apache/asterix/common/exceptions/ErrorCode.java b/asterixdb/asterix-common/src/main/java/org/apache/asterix/common/exceptions/ErrorCode.java
index 38173ba..89718b6 100644
--- a/asterixdb/asterix-common/src/main/java/org/apache/asterix/common/exceptions/ErrorCode.java
+++ b/asterixdb/asterix-common/src/main/java/org/apache/asterix/common/exceptions/ErrorCode.java
@@ -368,6 +368,7 @@
     COMPILATION_ANN_DISTANCE_METRIC_MISMATCH(1257),
     COMPILATION_VECTOR_INDEX_CREATION_FAILED(1258),
     UNSUPPORTED_MULTIPLE_STATEMENTS_FOR_DELIVERY_MODE(1259),
+    INVALID_TIME_TRAVEL_VALUE(1260),
 
     // Feed errors
     DATAFLOW_ILLEGAL_STATE(3001),
diff --git a/asterixdb/asterix-common/src/main/resources/asx_errormsg/en.properties b/asterixdb/asterix-common/src/main/resources/asx_errormsg/en.properties
index a5114b1..eb1e999 100644
--- a/asterixdb/asterix-common/src/main/resources/asx_errormsg/en.properties
+++ b/asterixdb/asterix-common/src/main/resources/asx_errormsg/en.properties
@@ -370,6 +370,7 @@
 1257 = ANN distance metric '%1$s' does not match vector index '%2$s' (similarity in metadata: '%3$s')
 1258 = Failed to create vector index. %1$s
 1259 = %1$s execution is not supported for a request with multiple statements
+1260 = Invalid 'AT %1$s' value: expected %2$s, but got %3$s
 
 # Feed Errors
 3001 = Illegal state.
diff --git a/asterixdb/asterix-lang-sqlpp/src/main/java/org/apache/asterix/lang/sqlpp/JoinClauseRightInput.java b/asterixdb/asterix-lang-sqlpp/src/main/java/org/apache/asterix/lang/sqlpp/JoinClauseRightInput.java
index a454654..e6b0ed3 100644
--- a/asterixdb/asterix-lang-sqlpp/src/main/java/org/apache/asterix/lang/sqlpp/JoinClauseRightInput.java
+++ b/asterixdb/asterix-lang-sqlpp/src/main/java/org/apache/asterix/lang/sqlpp/JoinClauseRightInput.java
@@ -20,16 +20,16 @@
 
 import org.apache.asterix.lang.common.base.Expression;
 import org.apache.asterix.lang.common.expression.VariableExpr;
-import org.apache.hyracks.algebricks.core.algebra.operators.logical.TimeTravel;
+import org.apache.asterix.lang.sqlpp.struct.TimeTravelSpec;
 
 public class JoinClauseRightInput {
     private final Expression rightExpr;
     private final VariableExpr rightVar;
     private final VariableExpr posVar;
-    private final TimeTravel timeTravel;
+    private final TimeTravelSpec timeTravel;
 
     public JoinClauseRightInput(Expression rightExpr, VariableExpr rightVar, VariableExpr posVar,
-            TimeTravel timeTravel) {
+            TimeTravelSpec timeTravel) {
         this.rightExpr = rightExpr;
         this.rightVar = rightVar;
         this.posVar = posVar;
@@ -48,7 +48,7 @@
         return posVar;
     }
 
-    public TimeTravel getTimeTravel() {
+    public TimeTravelSpec getTimeTravel() {
         return timeTravel;
     }
 }
diff --git a/asterixdb/asterix-lang-sqlpp/src/main/java/org/apache/asterix/lang/sqlpp/clause/AbstractBinaryCorrelateClause.java b/asterixdb/asterix-lang-sqlpp/src/main/java/org/apache/asterix/lang/sqlpp/clause/AbstractBinaryCorrelateClause.java
index 8c178f0..f50fe18 100644
--- a/asterixdb/asterix-lang-sqlpp/src/main/java/org/apache/asterix/lang/sqlpp/clause/AbstractBinaryCorrelateClause.java
+++ b/asterixdb/asterix-lang-sqlpp/src/main/java/org/apache/asterix/lang/sqlpp/clause/AbstractBinaryCorrelateClause.java
@@ -24,21 +24,21 @@
 import org.apache.asterix.lang.common.base.AbstractClause;
 import org.apache.asterix.lang.common.base.Expression;
 import org.apache.asterix.lang.common.expression.VariableExpr;
-import org.apache.hyracks.algebricks.core.algebra.operators.logical.TimeTravel;
+import org.apache.asterix.lang.sqlpp.struct.TimeTravelSpec;
 
 public abstract class AbstractBinaryCorrelateClause extends AbstractClause {
 
     private Expression rightExpr;
     private final VariableExpr rightVar;
     private final VariableExpr rightPosVar;
-    private final TimeTravel timeTravel;
+    private TimeTravelSpec timeTravel;
 
     public AbstractBinaryCorrelateClause(Expression rightExpr, VariableExpr rightVar, VariableExpr rightPosVar) {
         this(rightExpr, rightVar, rightPosVar, null);
     }
 
     public AbstractBinaryCorrelateClause(Expression rightExpr, VariableExpr rightVar, VariableExpr rightPosVar,
-            TimeTravel timeTravel) {
+            TimeTravelSpec timeTravel) {
         this.rightExpr = rightExpr;
         this.rightVar = rightVar;
         this.rightPosVar = rightPosVar;
@@ -69,10 +69,14 @@
         return timeTravel != null;
     }
 
-    public TimeTravel getTimeTravel() {
+    public TimeTravelSpec getTimeTravel() {
         return timeTravel;
     }
 
+    public void setTimeTravel(TimeTravelSpec timeTravel) {
+        this.timeTravel = timeTravel;
+    }
+
     @Override
     public int hashCode() {
         return Objects.hash(rightExpr, rightPosVar, rightVar, timeTravel);
diff --git a/asterixdb/asterix-lang-sqlpp/src/main/java/org/apache/asterix/lang/sqlpp/clause/AbstractBinaryCorrelateWithConditionClause.java b/asterixdb/asterix-lang-sqlpp/src/main/java/org/apache/asterix/lang/sqlpp/clause/AbstractBinaryCorrelateWithConditionClause.java
index cfebe24..8434c08 100644
--- a/asterixdb/asterix-lang-sqlpp/src/main/java/org/apache/asterix/lang/sqlpp/clause/AbstractBinaryCorrelateWithConditionClause.java
+++ b/asterixdb/asterix-lang-sqlpp/src/main/java/org/apache/asterix/lang/sqlpp/clause/AbstractBinaryCorrelateWithConditionClause.java
@@ -21,14 +21,14 @@
 
 import org.apache.asterix.lang.common.base.Expression;
 import org.apache.asterix.lang.common.expression.VariableExpr;
-import org.apache.hyracks.algebricks.core.algebra.operators.logical.TimeTravel;
+import org.apache.asterix.lang.sqlpp.struct.TimeTravelSpec;
 
 public abstract class AbstractBinaryCorrelateWithConditionClause extends AbstractBinaryCorrelateClause {
 
     private Expression conditionExpr;
 
     public AbstractBinaryCorrelateWithConditionClause(Expression rightExpr, VariableExpr rightVar,
-            VariableExpr rightPosVar, Expression conditionExpr, TimeTravel timeTravel) {
+            VariableExpr rightPosVar, Expression conditionExpr, TimeTravelSpec timeTravel) {
         super(rightExpr, rightVar, rightPosVar, timeTravel);
         this.conditionExpr = conditionExpr;
     }
diff --git a/asterixdb/asterix-lang-sqlpp/src/main/java/org/apache/asterix/lang/sqlpp/clause/FromTerm.java b/asterixdb/asterix-lang-sqlpp/src/main/java/org/apache/asterix/lang/sqlpp/clause/FromTerm.java
index 09bb2a5..57d4c41 100644
--- a/asterixdb/asterix-lang-sqlpp/src/main/java/org/apache/asterix/lang/sqlpp/clause/FromTerm.java
+++ b/asterixdb/asterix-lang-sqlpp/src/main/java/org/apache/asterix/lang/sqlpp/clause/FromTerm.java
@@ -28,15 +28,15 @@
 import org.apache.asterix.lang.common.base.Expression;
 import org.apache.asterix.lang.common.expression.VariableExpr;
 import org.apache.asterix.lang.common.visitor.base.ILangVisitor;
+import org.apache.asterix.lang.sqlpp.struct.TimeTravelSpec;
 import org.apache.asterix.lang.sqlpp.visitor.base.ISqlppVisitor;
-import org.apache.hyracks.algebricks.core.algebra.operators.logical.TimeTravel;
 
 public class FromTerm extends AbstractClause {
     private Expression leftExpr;
     private final VariableExpr leftVar;
     private final VariableExpr posVar;
     private final List<AbstractBinaryCorrelateClause> correlateClauses = new ArrayList<>();
-    private final TimeTravel timeTravel;
+    private TimeTravelSpec timeTravel;
 
     public FromTerm(Expression leftExpr, VariableExpr leftVar, VariableExpr posVar,
             List<AbstractBinaryCorrelateClause> correlateClauses) {
@@ -44,7 +44,7 @@
     }
 
     public FromTerm(Expression leftExpr, VariableExpr leftVar, VariableExpr posVar,
-            List<AbstractBinaryCorrelateClause> correlateClauses, TimeTravel timeTravel) {
+            List<AbstractBinaryCorrelateClause> correlateClauses, TimeTravelSpec timeTravel) {
         this.leftExpr = leftExpr;
         this.leftVar = leftVar;
         this.posVar = posVar;
@@ -96,10 +96,14 @@
         return timeTravel != null;
     }
 
-    public TimeTravel getTimeTravel() {
+    public TimeTravelSpec getTimeTravel() {
         return timeTravel;
     }
 
+    public void setTimeTravel(TimeTravelSpec timeTravel) {
+        this.timeTravel = timeTravel;
+    }
+
     @Override
     public String toString() {
         return leftExpr + " AS " + leftVar;
diff --git a/asterixdb/asterix-lang-sqlpp/src/main/java/org/apache/asterix/lang/sqlpp/clause/JoinClause.java b/asterixdb/asterix-lang-sqlpp/src/main/java/org/apache/asterix/lang/sqlpp/clause/JoinClause.java
index ca0ad70..32b5bdc 100644
--- a/asterixdb/asterix-lang-sqlpp/src/main/java/org/apache/asterix/lang/sqlpp/clause/JoinClause.java
+++ b/asterixdb/asterix-lang-sqlpp/src/main/java/org/apache/asterix/lang/sqlpp/clause/JoinClause.java
@@ -27,8 +27,8 @@
 import org.apache.asterix.lang.common.expression.VariableExpr;
 import org.apache.asterix.lang.common.visitor.base.ILangVisitor;
 import org.apache.asterix.lang.sqlpp.optype.JoinType;
+import org.apache.asterix.lang.sqlpp.struct.TimeTravelSpec;
 import org.apache.asterix.lang.sqlpp.visitor.base.ISqlppVisitor;
-import org.apache.hyracks.algebricks.core.algebra.operators.logical.TimeTravel;
 
 public class JoinClause extends AbstractBinaryCorrelateWithConditionClause {
 
@@ -42,7 +42,7 @@
     }
 
     public JoinClause(JoinType joinType, Expression rightExpr, VariableExpr rightVar, VariableExpr rightPosVar,
-            Expression conditionExpr, Literal.Type outerJoinMissingValueType, TimeTravel timeTravel) {
+            Expression conditionExpr, Literal.Type outerJoinMissingValueType, TimeTravelSpec timeTravel) {
         super(rightExpr, rightVar, rightPosVar, conditionExpr, timeTravel);
         this.joinType = joinType;
         setOuterJoinMissingValueType(outerJoinMissingValueType);
diff --git a/asterixdb/asterix-lang-sqlpp/src/main/java/org/apache/asterix/lang/sqlpp/rewrites/visitor/SqlppGatherFunctionCallsVisitor.java b/asterixdb/asterix-lang-sqlpp/src/main/java/org/apache/asterix/lang/sqlpp/rewrites/visitor/SqlppGatherFunctionCallsVisitor.java
index 21cba5e..674bcbc 100644
--- a/asterixdb/asterix-lang-sqlpp/src/main/java/org/apache/asterix/lang/sqlpp/rewrites/visitor/SqlppGatherFunctionCallsVisitor.java
+++ b/asterixdb/asterix-lang-sqlpp/src/main/java/org/apache/asterix/lang/sqlpp/rewrites/visitor/SqlppGatherFunctionCallsVisitor.java
@@ -19,6 +19,9 @@
 
 package org.apache.asterix.lang.sqlpp.rewrites.visitor;
 
+import static org.apache.hyracks.util.annotations.AiProvenance.Agent.CLAUDE_OPUS_5;
+import static org.apache.hyracks.util.annotations.AiProvenance.Tool.CLAUDE_CODE_UI;
+
 import java.util.Collection;
 
 import org.apache.asterix.common.exceptions.CompilationException;
@@ -47,8 +50,10 @@
 import org.apache.asterix.lang.sqlpp.expression.SelectExpression;
 import org.apache.asterix.lang.sqlpp.expression.WindowExpression;
 import org.apache.asterix.lang.sqlpp.struct.SetOperationRight;
+import org.apache.asterix.lang.sqlpp.struct.TimeTravelSpec;
 import org.apache.asterix.lang.sqlpp.visitor.base.ISqlppVisitor;
 import org.apache.hyracks.algebricks.common.utils.Pair;
+import org.apache.hyracks.util.annotations.AiProvenance;
 
 public final class SqlppGatherFunctionCallsVisitor extends GatherFunctionCallsVisitor
         implements ISqlppVisitor<Void, Void> {
@@ -68,6 +73,7 @@
     @Override
     public Void visit(FromTerm fromTerm, Void arg) throws CompilationException {
         fromTerm.getLeftExpression().accept(this, arg);
+        gatherTimeTravelCalls(fromTerm.getTimeTravel(), arg);
         for (AbstractBinaryCorrelateClause correlateClause : fromTerm.getCorrelateClauses()) {
             correlateClause.accept(this, arg);
         }
@@ -77,6 +83,7 @@
     @Override
     public Void visit(JoinClause joinClause, Void arg) throws CompilationException {
         joinClause.getRightExpression().accept(this, arg);
+        gatherTimeTravelCalls(joinClause.getTimeTravel(), arg);
         joinClause.getConditionExpression().accept(this, arg);
         return null;
     }
@@ -271,4 +278,11 @@
         }
         return null;
     }
+
+    @AiProvenance(agent = CLAUDE_OPUS_5, tool = CLAUDE_CODE_UI)
+    private void gatherTimeTravelCalls(TimeTravelSpec timeTravel, Void arg) throws CompilationException {
+        if (timeTravel != null) {
+            timeTravel.getValueExpression().accept(this, arg);
+        }
+    }
 }
diff --git a/asterixdb/asterix-lang-sqlpp/src/main/java/org/apache/asterix/lang/sqlpp/rewrites/visitor/SqlppInlineUdfsVisitor.java b/asterixdb/asterix-lang-sqlpp/src/main/java/org/apache/asterix/lang/sqlpp/rewrites/visitor/SqlppInlineUdfsVisitor.java
index d4057c6..3b7599b 100644
--- a/asterixdb/asterix-lang-sqlpp/src/main/java/org/apache/asterix/lang/sqlpp/rewrites/visitor/SqlppInlineUdfsVisitor.java
+++ b/asterixdb/asterix-lang-sqlpp/src/main/java/org/apache/asterix/lang/sqlpp/rewrites/visitor/SqlppInlineUdfsVisitor.java
@@ -18,6 +18,9 @@
  */
 package org.apache.asterix.lang.sqlpp.rewrites.visitor;
 
+import static org.apache.hyracks.util.annotations.AiProvenance.Agent.CLAUDE_OPUS_5;
+import static org.apache.hyracks.util.annotations.AiProvenance.Tool.CLAUDE_CODE_UI;
+
 import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
@@ -52,10 +55,12 @@
 import org.apache.asterix.lang.sqlpp.expression.SelectExpression;
 import org.apache.asterix.lang.sqlpp.expression.WindowExpression;
 import org.apache.asterix.lang.sqlpp.struct.SetOperationRight;
+import org.apache.asterix.lang.sqlpp.struct.TimeTravelSpec;
 import org.apache.asterix.lang.sqlpp.util.SqlppRewriteUtil;
 import org.apache.asterix.lang.sqlpp.visitor.SqlppCloneAndSubstituteVariablesVisitor;
 import org.apache.asterix.lang.sqlpp.visitor.base.ISqlppVisitor;
 import org.apache.hyracks.algebricks.common.utils.Pair;
+import org.apache.hyracks.util.annotations.AiProvenance;
 
 public class SqlppInlineUdfsVisitor extends AbstractInlineUdfsVisitor implements ISqlppVisitor<Boolean, Void> {
 
@@ -94,6 +99,7 @@
         Pair<Boolean, Expression> p = inlineUdfsAndViewsInExpr(fromTerm.getLeftExpression());
         fromTerm.setLeftExpression(p.second);
         changed |= p.first;
+        changed |= inlineUdfsAndViewsInTimeTravel(fromTerm.getTimeTravel());
         for (AbstractBinaryCorrelateClause correlateClause : fromTerm.getCorrelateClauses()) {
             changed |= correlateClause.accept(this, arg);
         }
@@ -106,7 +112,8 @@
         joinClause.setRightExpression(p1.second);
         Pair<Boolean, Expression> p2 = inlineUdfsAndViewsInExpr(joinClause.getConditionExpression());
         joinClause.setConditionExpression(p2.second);
-        return p1.first || p2.first;
+        boolean timeTravelChanged = inlineUdfsAndViewsInTimeTravel(joinClause.getTimeTravel());
+        return p1.first || p2.first || timeTravelChanged;
     }
 
     @Override
@@ -317,4 +324,14 @@
         }
         return varExprMap;
     }
+
+    @AiProvenance(agent = CLAUDE_OPUS_5, tool = CLAUDE_CODE_UI)
+    private boolean inlineUdfsAndViewsInTimeTravel(TimeTravelSpec timeTravel) throws CompilationException {
+        if (timeTravel == null) {
+            return false;
+        }
+        Pair<Boolean, Expression> p = inlineUdfsAndViewsInExpr(timeTravel.getValueExpression());
+        timeTravel.setValueExpression(p.second);
+        return p.first;
+    }
 }
diff --git a/asterixdb/asterix-lang-sqlpp/src/main/java/org/apache/asterix/lang/sqlpp/rewrites/visitor/SqlppRightJoinRewriteVisitor.java b/asterixdb/asterix-lang-sqlpp/src/main/java/org/apache/asterix/lang/sqlpp/rewrites/visitor/SqlppRightJoinRewriteVisitor.java
index fd26655..8c8b8a5 100644
--- a/asterixdb/asterix-lang-sqlpp/src/main/java/org/apache/asterix/lang/sqlpp/rewrites/visitor/SqlppRightJoinRewriteVisitor.java
+++ b/asterixdb/asterix-lang-sqlpp/src/main/java/org/apache/asterix/lang/sqlpp/rewrites/visitor/SqlppRightJoinRewriteVisitor.java
@@ -52,11 +52,11 @@
 import org.apache.asterix.lang.sqlpp.expression.SelectExpression;
 import org.apache.asterix.lang.sqlpp.optype.JoinType;
 import org.apache.asterix.lang.sqlpp.struct.SetOperationInput;
+import org.apache.asterix.lang.sqlpp.struct.TimeTravelSpec;
 import org.apache.asterix.lang.sqlpp.util.SqlppRewriteUtil;
 import org.apache.asterix.lang.sqlpp.util.SqlppVariableUtil;
 import org.apache.asterix.lang.sqlpp.visitor.base.AbstractSqlppSimpleExpressionVisitor;
 import org.apache.hyracks.algebricks.common.utils.Pair;
-import org.apache.hyracks.algebricks.core.algebra.operators.logical.TimeTravel;
 import org.apache.hyracks.api.exceptions.SourceLocation;
 
 /**
@@ -150,7 +150,7 @@
         Expression fromExpr = fromTerm.getLeftExpression();
         VariableExpr fromVar = fromTerm.getLeftVariable();
         VariableExpr fromPosVar = fromTerm.getPositionalVariable();
-        TimeTravel fromTimeTravel = fromTerm.getTimeTravel();
+        TimeTravelSpec fromTimeTravel = fromTerm.getTimeTravel();
 
         List<AbstractBinaryCorrelateClause> correlateClauses = fromTerm.getCorrelateClauses();
         for (int i = 0; i < correlateClauses.size(); i++) {
@@ -163,7 +163,7 @@
                 Expression rightExpr = joinClause.getRightExpression();
                 VariableExpr rightVar = joinClause.getRightVariable();
                 VariableExpr rightPosVar = joinClause.getPositionalVariable();
-                TimeTravel rightTimeTravel = joinClause.getTimeTravel();
+                TimeTravelSpec rightTimeTravel = joinClause.getTimeTravel();
                 Expression condExpr = joinClause.getConditionExpression();
                 Literal.Type outerMissingValueType = joinClause.getOuterJoinMissingValueType();
                 if (i == 0) {
@@ -251,11 +251,11 @@
                                 newPrecedingClause = new JoinClause(joinPrecedingClause.getJoinType(),
                                         joinPrecedingClause.getRightExpression(),
                                         newVariableExpr(newPrecedingClauseRightVar, precedingClauseRightVarSourceLoc),
-                                        newPrecedingClauseRightPosVar != null
-                                                ? newVariableExpr(newPrecedingClauseRightPosVar,
-                                                        precedingClauseRightPosVarSourceLoc)
+                                        newPrecedingClauseRightPosVar != null ? newVariableExpr(
+                                                newPrecedingClauseRightPosVar, precedingClauseRightPosVarSourceLoc)
                                                 : null,
-                                        newCondExpr, joinPrecedingClause.getOuterJoinMissingValueType());
+                                        newCondExpr, joinPrecedingClause.getOuterJoinMissingValueType(),
+                                        joinPrecedingClause.getTimeTravel());
                                 newPrecedingClause.setSourceLocation(precedingClauseSourceLoc);
                                 break;
                             case UNNEST_CLAUSE:
@@ -351,7 +351,7 @@
     }
 
     private Expression createRightSelectExpression(Expression fromExpr, VarIdentifier fromVar, VarIdentifier fromPosVar,
-            List<AbstractBinaryCorrelateClause> correlateClauseList, TimeTravel timeTravel,
+            List<AbstractBinaryCorrelateClause> correlateClauseList, TimeTravelSpec timeTravel,
             List<Projection> projectList, SourceLocation sourceLoc) {
         FromTerm newFromTerm = new FromTerm(fromExpr, newVariableExpr(fromVar, sourceLoc),
                 fromPosVar != null ? newVariableExpr(fromPosVar, sourceLoc) : null, correlateClauseList, timeTravel);
diff --git a/asterixdb/asterix-lang-sqlpp/src/main/java/org/apache/asterix/lang/sqlpp/struct/TimeTravelSpec.java b/asterixdb/asterix-lang-sqlpp/src/main/java/org/apache/asterix/lang/sqlpp/struct/TimeTravelSpec.java
new file mode 100644
index 0000000..9276569
--- /dev/null
+++ b/asterixdb/asterix-lang-sqlpp/src/main/java/org/apache/asterix/lang/sqlpp/struct/TimeTravelSpec.java
@@ -0,0 +1,102 @@
+/*
+ * 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 org.apache.asterix.lang.sqlpp.struct;
+
+import static org.apache.hyracks.util.annotations.AiProvenance.Agent.CLAUDE_OPUS_5;
+import static org.apache.hyracks.util.annotations.AiProvenance.Tool.CLAUDE_CODE_UI;
+
+import java.util.Objects;
+
+import org.apache.asterix.lang.common.base.Expression;
+import org.apache.hyracks.algebricks.core.algebra.operators.logical.TimeTravel;
+import org.apache.hyracks.api.exceptions.SourceLocation;
+import org.apache.hyracks.util.annotations.AiProvenance;
+
+/**
+ * The unresolved form of an {@code AT SNAPSHOT} / {@code AT TIMESTAMP} specification.
+ * <p>
+ * The value is carried as an {@link Expression} so that the language rewrites (function call resolution,
+ * operator-to-function-call conversion, UDF inlining, ...) can process it like any other expression. It must
+ * reduce to a compile-time constant: expression-to-plan translation folds it into the {@link TimeTravel} that
+ * the plan carries, because the snapshot selects which Iceberg manifest/data files the scan reads and therefore
+ * has to be known while the plan is still being built.
+ */
+@AiProvenance(agent = CLAUDE_OPUS_5, tool = CLAUDE_CODE_UI)
+public final class TimeTravelSpec {
+
+    private final TimeTravel.Type type;
+
+    private Expression valueExpr;
+
+    private SourceLocation sourceLoc;
+
+    public TimeTravelSpec(Expression valueExpr, TimeTravel.Type type) {
+        this.valueExpr = valueExpr;
+        this.type = type;
+    }
+
+    public Expression getValueExpression() {
+        return valueExpr;
+    }
+
+    public void setValueExpression(Expression valueExpr) {
+        this.valueExpr = valueExpr;
+    }
+
+    public TimeTravel.Type getType() {
+        return type;
+    }
+
+    public SourceLocation getSourceLocation() {
+        return sourceLoc;
+    }
+
+    public void setSourceLocation(SourceLocation sourceLoc) {
+        this.sourceLoc = sourceLoc;
+    }
+
+    /**
+     * @return the {@code SNAPSHOT} / {@code TIMESTAMP} keyword this specification was written with.
+     */
+    public String getKeyword() {
+        return type.getKeyword();
+    }
+
+    @Override
+    public int hashCode() {
+        return Objects.hash(valueExpr, type);
+    }
+
+    @Override
+    public boolean equals(Object object) {
+        if (this == object) {
+            return true;
+        }
+        if (!(object instanceof TimeTravelSpec target)) {
+            return false;
+        }
+        return type == target.type && Objects.equals(valueExpr, target.valueExpr);
+    }
+
+    @Override
+    public String toString() {
+        return type.getKeyword() + " " + valueExpr;
+    }
+}
diff --git a/asterixdb/asterix-lang-sqlpp/src/main/java/org/apache/asterix/lang/sqlpp/visitor/DeepCopyVisitor.java b/asterixdb/asterix-lang-sqlpp/src/main/java/org/apache/asterix/lang/sqlpp/visitor/DeepCopyVisitor.java
index c02240d..cc53eb3 100644
--- a/asterixdb/asterix-lang-sqlpp/src/main/java/org/apache/asterix/lang/sqlpp/visitor/DeepCopyVisitor.java
+++ b/asterixdb/asterix-lang-sqlpp/src/main/java/org/apache/asterix/lang/sqlpp/visitor/DeepCopyVisitor.java
@@ -18,6 +18,9 @@
  */
 package org.apache.asterix.lang.sqlpp.visitor;
 
+import static org.apache.hyracks.util.annotations.AiProvenance.Agent.CLAUDE_OPUS_5;
+import static org.apache.hyracks.util.annotations.AiProvenance.Tool.CLAUDE_CODE_UI;
+
 import java.util.ArrayList;
 import java.util.HashMap;
 import java.util.List;
@@ -73,9 +76,10 @@
 import org.apache.asterix.lang.sqlpp.expression.WindowExpression;
 import org.apache.asterix.lang.sqlpp.struct.SetOperationInput;
 import org.apache.asterix.lang.sqlpp.struct.SetOperationRight;
+import org.apache.asterix.lang.sqlpp.struct.TimeTravelSpec;
 import org.apache.asterix.lang.sqlpp.visitor.base.AbstractSqlppQueryExpressionVisitor;
 import org.apache.hyracks.algebricks.common.utils.Pair;
-import org.apache.hyracks.algebricks.core.algebra.operators.logical.TimeTravel;
+import org.apache.hyracks.util.annotations.AiProvenance;
 
 public class DeepCopyVisitor extends AbstractSqlppQueryExpressionVisitor<ILangExpression, Void> {
 
@@ -97,7 +101,7 @@
         VariableExpr fromVar = (VariableExpr) fromTerm.getLeftVariable().accept(this, arg);
         VariableExpr positionVar = fromTerm.getPositionalVariable() == null ? null
                 : (VariableExpr) fromTerm.getPositionalVariable().accept(this, arg);
-        TimeTravel timeTravel = fromTerm.getTimeTravel();
+        TimeTravelSpec timeTravel = copy(fromTerm.getTimeTravel(), arg);
 
         // Visits join/unnest/nest clauses.
         List<AbstractBinaryCorrelateClause> correlateClauses = new ArrayList<>();
@@ -117,7 +121,7 @@
                 : (VariableExpr) joinClause.getPositionalVariable().accept(this, arg);
         Expression conditionExpresion = (Expression) joinClause.getConditionExpression().accept(this, arg);
         JoinClause copy = new JoinClause(joinClause.getJoinType(), rightExpression, rightVar, rightPositionVar,
-                conditionExpresion, joinClause.getOuterJoinMissingValueType());
+                conditionExpresion, joinClause.getOuterJoinMissingValueType(), copy(joinClause.getTimeTravel(), arg));
         copy.setSourceLocation(joinClause.getSourceLocation());
         return copy;
     }
@@ -587,4 +591,15 @@
         }
         return newFieldList;
     }
+
+    @AiProvenance(agent = CLAUDE_OPUS_5, tool = CLAUDE_CODE_UI)
+    private TimeTravelSpec copy(TimeTravelSpec timeTravel, Void arg) throws CompilationException {
+        if (timeTravel == null) {
+            return null;
+        }
+        TimeTravelSpec copy = new TimeTravelSpec((Expression) timeTravel.getValueExpression().accept(this, arg),
+                timeTravel.getType());
+        copy.setSourceLocation(timeTravel.getSourceLocation());
+        return copy;
+    }
 }
diff --git a/asterixdb/asterix-lang-sqlpp/src/main/java/org/apache/asterix/lang/sqlpp/visitor/FreeVariableVisitor.java b/asterixdb/asterix-lang-sqlpp/src/main/java/org/apache/asterix/lang/sqlpp/visitor/FreeVariableVisitor.java
index 917d056..161252b 100644
--- a/asterixdb/asterix-lang-sqlpp/src/main/java/org/apache/asterix/lang/sqlpp/visitor/FreeVariableVisitor.java
+++ b/asterixdb/asterix-lang-sqlpp/src/main/java/org/apache/asterix/lang/sqlpp/visitor/FreeVariableVisitor.java
@@ -18,6 +18,9 @@
  */
 package org.apache.asterix.lang.sqlpp.visitor;
 
+import static org.apache.hyracks.util.annotations.AiProvenance.Agent.CLAUDE_OPUS_5;
+import static org.apache.hyracks.util.annotations.AiProvenance.Tool.CLAUDE_CODE_UI;
+
 import java.util.Collection;
 import java.util.HashSet;
 import java.util.List;
@@ -71,9 +74,11 @@
 import org.apache.asterix.lang.sqlpp.expression.SelectExpression;
 import org.apache.asterix.lang.sqlpp.expression.WindowExpression;
 import org.apache.asterix.lang.sqlpp.struct.SetOperationRight;
+import org.apache.asterix.lang.sqlpp.struct.TimeTravelSpec;
 import org.apache.asterix.lang.sqlpp.util.SqlppVariableUtil;
 import org.apache.asterix.lang.sqlpp.visitor.base.AbstractSqlppQueryExpressionVisitor;
 import org.apache.hyracks.algebricks.common.utils.Pair;
+import org.apache.hyracks.util.annotations.AiProvenance;
 
 public class FreeVariableVisitor extends AbstractSqlppQueryExpressionVisitor<Void, Collection<VariableExpr>> {
 
@@ -104,6 +109,7 @@
 
         // Visit the left expression of a from term.
         fromTerm.getLeftExpression().accept(this, freeVars);
+        collectTimeTravelFreeVariables(fromTerm.getTimeTravel(), freeVars);
 
         // Adds binding variables.
         bindingVariables.add(fromTerm.getLeftVariable());
@@ -121,6 +127,7 @@
                 clauseFreeVars.clear();
             }
             clause.getRightExpression().accept(this, clauseFreeVars);
+            collectTimeTravelFreeVariables(clause.getTimeTravel(), clauseFreeVars);
 
             switch (clause.getClauseType()) {
                 case UNNEST_CLAUSE:
@@ -578,4 +585,17 @@
         selectFreeVars.removeAll(gbyBindingVars);
         selectFreeVars.removeAll(gbyLetsBindingVars);
     }
+
+    /**
+     * A time travel value has to be a compile-time constant, so it should not reference anything. Its free
+     * variables are still collected so that a reference is reported here rather than silently surviving into
+     * the rewrites that consume this information.
+     */
+    @AiProvenance(agent = CLAUDE_OPUS_5, tool = CLAUDE_CODE_UI)
+    private void collectTimeTravelFreeVariables(TimeTravelSpec timeTravel, Collection<VariableExpr> freeVars)
+            throws CompilationException {
+        if (timeTravel != null) {
+            timeTravel.getValueExpression().accept(this, freeVars);
+        }
+    }
 }
diff --git a/asterixdb/asterix-lang-sqlpp/src/main/java/org/apache/asterix/lang/sqlpp/visitor/SqlppAstPrintVisitor.java b/asterixdb/asterix-lang-sqlpp/src/main/java/org/apache/asterix/lang/sqlpp/visitor/SqlppAstPrintVisitor.java
index 48555dc..2bebebb 100644
--- a/asterixdb/asterix-lang-sqlpp/src/main/java/org/apache/asterix/lang/sqlpp/visitor/SqlppAstPrintVisitor.java
+++ b/asterixdb/asterix-lang-sqlpp/src/main/java/org/apache/asterix/lang/sqlpp/visitor/SqlppAstPrintVisitor.java
@@ -92,9 +92,9 @@
         out.print(skip(step) + "AS ");
         fromTerm.getLeftVariable().accept(this, 0);
         if (fromTerm.hasTimeTravel()) {
-            String type = fromTerm.getTimeTravel().getType().getKeyword().toUpperCase();
-            String value = fromTerm.getTimeTravel().getSnapshotIdOrTimestamp();
-            out.println(" AT " + type + " " + value);
+            out.print(" AT " + fromTerm.getTimeTravel().getKeyword().toUpperCase() + " ");
+            fromTerm.getTimeTravel().getValueExpression().accept(this, 0);
+            out.println();
         }
         if (fromTerm.hasPositionalVariable()) {
             out.println(" AT");
diff --git a/asterixdb/asterix-lang-sqlpp/src/main/java/org/apache/asterix/lang/sqlpp/visitor/SqlppCloneAndSubstituteVariablesVisitor.java b/asterixdb/asterix-lang-sqlpp/src/main/java/org/apache/asterix/lang/sqlpp/visitor/SqlppCloneAndSubstituteVariablesVisitor.java
index 26d4287..c9d34d4 100644
--- a/asterixdb/asterix-lang-sqlpp/src/main/java/org/apache/asterix/lang/sqlpp/visitor/SqlppCloneAndSubstituteVariablesVisitor.java
+++ b/asterixdb/asterix-lang-sqlpp/src/main/java/org/apache/asterix/lang/sqlpp/visitor/SqlppCloneAndSubstituteVariablesVisitor.java
@@ -18,6 +18,9 @@
  */
 package org.apache.asterix.lang.sqlpp.visitor;
 
+import static org.apache.hyracks.util.annotations.AiProvenance.Agent.CLAUDE_OPUS_5;
+import static org.apache.hyracks.util.annotations.AiProvenance.Tool.CLAUDE_CODE_UI;
+
 import java.util.ArrayList;
 import java.util.List;
 
@@ -56,8 +59,11 @@
 import org.apache.asterix.lang.sqlpp.expression.WindowExpression;
 import org.apache.asterix.lang.sqlpp.struct.SetOperationInput;
 import org.apache.asterix.lang.sqlpp.struct.SetOperationRight;
+import org.apache.asterix.lang.sqlpp.struct.TimeTravelSpec;
+import org.apache.asterix.lang.sqlpp.util.SqlppRewriteUtil;
 import org.apache.asterix.lang.sqlpp.visitor.base.ISqlppVisitor;
 import org.apache.hyracks.algebricks.common.utils.Pair;
+import org.apache.hyracks.util.annotations.AiProvenance;
 
 public class SqlppCloneAndSubstituteVariablesVisitor extends CloneAndSubstituteVariablesVisitor implements
         ISqlppVisitor<Pair<ILangExpression, VariableSubstitutionEnvironment>, VariableSubstitutionEnvironment> {
@@ -120,8 +126,8 @@
                 }
             }
         }
-        FromTerm newFromTerm =
-                new FromTerm(newLeftExpr, newLeftVar, newLeftPosVar, newCorrelateClauses, fromTerm.getTimeTravel());
+        FromTerm newFromTerm = new FromTerm(newLeftExpr, newLeftVar, newLeftPosVar, newCorrelateClauses,
+                copyTimeTravel(fromTerm.getTimeTravel()));
         newFromTerm.setSourceLocation(fromTerm.getSourceLocation());
         return new Pair<>(newFromTerm, currentEnv);
     }
@@ -147,7 +153,7 @@
         Expression conditionExpr = (Expression) joinClause.getConditionExpression().accept(this, currentEnv).first;
 
         JoinClause newJoinClause = new JoinClause(joinClause.getJoinType(), newRightExpr, newRightVar, newRightPosVar,
-                conditionExpr, joinClause.getOuterJoinMissingValueType());
+                conditionExpr, joinClause.getOuterJoinMissingValueType(), copyTimeTravel(joinClause.getTimeTravel()));
         newJoinClause.setSourceLocation(joinClause.getSourceLocation());
         return new Pair<>(newJoinClause, currentEnv);
     }
@@ -449,4 +455,19 @@
         newWinExpr.addHints(winExpr.getHints());
         return new Pair<>(newWinExpr, env);
     }
+
+    /**
+     * Deep-copies a time travel specification. The value expression cannot reference variables, so there is
+     * nothing to substitute, but the clone must not share the (mutable) expression with the original.
+     */
+    @AiProvenance(agent = CLAUDE_OPUS_5, tool = CLAUDE_CODE_UI)
+    private TimeTravelSpec copyTimeTravel(TimeTravelSpec timeTravel) throws CompilationException {
+        if (timeTravel == null) {
+            return null;
+        }
+        TimeTravelSpec copy = new TimeTravelSpec(
+                (Expression) SqlppRewriteUtil.deepCopy(timeTravel.getValueExpression()), timeTravel.getType());
+        copy.setSourceLocation(timeTravel.getSourceLocation());
+        return copy;
+    }
 }
diff --git a/asterixdb/asterix-lang-sqlpp/src/main/java/org/apache/asterix/lang/sqlpp/visitor/SqlppFormatPrintVisitor.java b/asterixdb/asterix-lang-sqlpp/src/main/java/org/apache/asterix/lang/sqlpp/visitor/SqlppFormatPrintVisitor.java
index 57e1e71..8c45211 100644
--- a/asterixdb/asterix-lang-sqlpp/src/main/java/org/apache/asterix/lang/sqlpp/visitor/SqlppFormatPrintVisitor.java
+++ b/asterixdb/asterix-lang-sqlpp/src/main/java/org/apache/asterix/lang/sqlpp/visitor/SqlppFormatPrintVisitor.java
@@ -85,9 +85,9 @@
         out.print(" as ");
         fromTerm.getLeftVariable().accept(this, step + 2);
         if (fromTerm.hasTimeTravel()) {
-            String type = fromTerm.getTimeTravel().getType().getKeyword();
-            String value = fromTerm.getTimeTravel().getSnapshotIdOrTimestamp();
-            out.println(" at " + type + " " + value);
+            out.print(" at " + fromTerm.getTimeTravel().getKeyword() + " ");
+            fromTerm.getTimeTravel().getValueExpression().accept(this, step + 2);
+            out.println();
         }
         if (fromTerm.hasPositionalVariable()) {
             out.print(" at ");
diff --git a/asterixdb/asterix-lang-sqlpp/src/main/java/org/apache/asterix/lang/sqlpp/visitor/base/AbstractSqlppContainsExpressionVisitor.java b/asterixdb/asterix-lang-sqlpp/src/main/java/org/apache/asterix/lang/sqlpp/visitor/base/AbstractSqlppContainsExpressionVisitor.java
index 69916bb..04b5579 100644
--- a/asterixdb/asterix-lang-sqlpp/src/main/java/org/apache/asterix/lang/sqlpp/visitor/base/AbstractSqlppContainsExpressionVisitor.java
+++ b/asterixdb/asterix-lang-sqlpp/src/main/java/org/apache/asterix/lang/sqlpp/visitor/base/AbstractSqlppContainsExpressionVisitor.java
@@ -19,6 +19,9 @@
 
 package org.apache.asterix.lang.sqlpp.visitor.base;
 
+import static org.apache.hyracks.util.annotations.AiProvenance.Agent.CLAUDE_OPUS_5;
+import static org.apache.hyracks.util.annotations.AiProvenance.Tool.CLAUDE_CODE_UI;
+
 import java.util.Collection;
 import java.util.List;
 
@@ -65,7 +68,9 @@
 import org.apache.asterix.lang.sqlpp.expression.SelectExpression;
 import org.apache.asterix.lang.sqlpp.expression.WindowExpression;
 import org.apache.asterix.lang.sqlpp.struct.SetOperationRight;
+import org.apache.asterix.lang.sqlpp.struct.TimeTravelSpec;
 import org.apache.hyracks.algebricks.common.utils.Pair;
+import org.apache.hyracks.util.annotations.AiProvenance;
 
 /**
  * Base class for visitors that search for expressions having certain properties and return a boolean value
@@ -89,6 +94,9 @@
         if (visit(fromTerm.getLeftExpression(), arg)) {
             return true;
         }
+        if (visitTimeTravel(fromTerm.getTimeTravel(), arg)) {
+            return true;
+        }
         if (fromTerm.hasCorrelateClauses()) {
             for (AbstractBinaryCorrelateClause correlateClause : fromTerm.getCorrelateClauses()) {
                 if (correlateClause.accept(this, arg)) {
@@ -101,7 +109,8 @@
 
     @Override
     public Boolean visit(JoinClause joinClause, T arg) throws CompilationException {
-        return visit(joinClause.getRightExpression(), arg) || visit(joinClause.getConditionExpression(), arg);
+        return visit(joinClause.getRightExpression(), arg) || visitTimeTravel(joinClause.getTimeTravel(), arg)
+                || visit(joinClause.getConditionExpression(), arg);
     }
 
     @Override
@@ -346,4 +355,9 @@
         }
         return false;
     }
+
+    @AiProvenance(agent = CLAUDE_OPUS_5, tool = CLAUDE_CODE_UI)
+    protected Boolean visitTimeTravel(TimeTravelSpec timeTravel, T arg) throws CompilationException {
+        return timeTravel != null && visit(timeTravel.getValueExpression(), arg);
+    }
 }
diff --git a/asterixdb/asterix-lang-sqlpp/src/main/java/org/apache/asterix/lang/sqlpp/visitor/base/AbstractSqlppExpressionScopingVisitor.java b/asterixdb/asterix-lang-sqlpp/src/main/java/org/apache/asterix/lang/sqlpp/visitor/base/AbstractSqlppExpressionScopingVisitor.java
index a7e1f90..5773d22 100644
--- a/asterixdb/asterix-lang-sqlpp/src/main/java/org/apache/asterix/lang/sqlpp/visitor/base/AbstractSqlppExpressionScopingVisitor.java
+++ b/asterixdb/asterix-lang-sqlpp/src/main/java/org/apache/asterix/lang/sqlpp/visitor/base/AbstractSqlppExpressionScopingVisitor.java
@@ -107,6 +107,14 @@
         // Visit the left expression of a from term.
         fromTerm.setLeftExpression(visit(fromTerm.getLeftExpression(), fromTerm));
 
+        // Resolved before the new scope opens, so the value cannot see this term's own binding variables.
+        // Inside the scope, "FROM Users AS c AT SNAPSHOT c.uid" would bind c to the term's own item
+        // variable and the value would become a per-row field access, failing much later and much less
+        // helpfully -- ASX1106 "expected constant value" pointing at the whole clause, rather than ASX1073
+        // "undefined identifier c" pointing at the c. It would also put this term's own binding variable
+        // into its free variable set, which the rewrites that read that set have no reason to expect.
+        visitTimeTravel(fromTerm.getTimeTravel(), fromTerm);
+
         scopeChecker.createNewScope();
 
         // Registers the data item variable.
@@ -135,6 +143,10 @@
         // we defer the check to the query optimizer.
         joinClause.setRightExpression(visit(joinClause.getRightExpression(), joinClause));
 
+        // Before the right branch's variables are registered, for the same reason as in visit(FromTerm):
+        // "JOIN d AT SNAPSHOT d.x" must not bind d to the branch it is selecting the snapshot for.
+        visitTimeTravel(joinClause.getTimeTravel(), joinClause);
+
         // Registers the data item variable.
         VariableExpr rightVar = joinClause.getRightVariable();
         addNewVarSymbolToScope(scopeChecker.getCurrentScope(), rightVar.getVar(), rightVar.getSourceLocation(),
diff --git a/asterixdb/asterix-lang-sqlpp/src/main/java/org/apache/asterix/lang/sqlpp/visitor/base/AbstractSqlppSimpleExpressionVisitor.java b/asterixdb/asterix-lang-sqlpp/src/main/java/org/apache/asterix/lang/sqlpp/visitor/base/AbstractSqlppSimpleExpressionVisitor.java
index 3e8c875..4ba7768 100644
--- a/asterixdb/asterix-lang-sqlpp/src/main/java/org/apache/asterix/lang/sqlpp/visitor/base/AbstractSqlppSimpleExpressionVisitor.java
+++ b/asterixdb/asterix-lang-sqlpp/src/main/java/org/apache/asterix/lang/sqlpp/visitor/base/AbstractSqlppSimpleExpressionVisitor.java
@@ -18,6 +18,9 @@
  */
 package org.apache.asterix.lang.sqlpp.visitor.base;
 
+import static org.apache.hyracks.util.annotations.AiProvenance.Agent.CLAUDE_OPUS_5;
+import static org.apache.hyracks.util.annotations.AiProvenance.Tool.CLAUDE_CODE_UI;
+
 import java.util.ArrayList;
 import java.util.List;
 
@@ -70,7 +73,9 @@
 import org.apache.asterix.lang.sqlpp.expression.SelectExpression;
 import org.apache.asterix.lang.sqlpp.expression.WindowExpression;
 import org.apache.asterix.lang.sqlpp.struct.SetOperationRight;
+import org.apache.asterix.lang.sqlpp.struct.TimeTravelSpec;
 import org.apache.hyracks.algebricks.common.utils.Pair;
+import org.apache.hyracks.util.annotations.AiProvenance;
 
 public class AbstractSqlppSimpleExpressionVisitor
         extends AbstractSqlppQueryExpressionVisitor<Expression, ILangExpression> {
@@ -87,6 +92,7 @@
     public Expression visit(FromTerm fromTerm, ILangExpression arg) throws CompilationException {
         // Visit the left expression of a from term.
         fromTerm.setLeftExpression(visit(fromTerm.getLeftExpression(), arg));
+        visitTimeTravel(fromTerm.getTimeTravel(), arg);
 
         // Visits join/unnest/nest clauses.
         for (AbstractBinaryCorrelateClause correlateClause : fromTerm.getCorrelateClauses()) {
@@ -98,6 +104,7 @@
     @Override
     public Expression visit(JoinClause joinClause, ILangExpression arg) throws CompilationException {
         joinClause.setRightExpression(visit(joinClause.getRightExpression(), arg));
+        visitTimeTravel(joinClause.getTimeTravel(), arg);
         joinClause.setConditionExpression(visit(joinClause.getConditionExpression(), arg));
         return null;
     }
@@ -473,4 +480,17 @@
         }
         return newExprList;
     }
+
+    /**
+     * Visits the value expression of an {@code AT SNAPSHOT} / {@code AT TIMESTAMP} specification, if there is
+     * one. The value has to reduce to a compile-time constant, but it still goes through the regular expression
+     * rewrites (function call resolution, operator-to-function-call conversion, UDF inlining, ...) so that
+     * anything foldable actually gets a chance to fold.
+     */
+    @AiProvenance(agent = CLAUDE_OPUS_5, tool = CLAUDE_CODE_UI)
+    protected void visitTimeTravel(TimeTravelSpec timeTravel, ILangExpression arg) throws CompilationException {
+        if (timeTravel != null) {
+            timeTravel.setValueExpression(visit(timeTravel.getValueExpression(), arg));
+        }
+    }
 }
diff --git a/asterixdb/asterix-lang-sqlpp/src/main/javacc/SQLPP.jj b/asterixdb/asterix-lang-sqlpp/src/main/javacc/SQLPP.jj
index e1fdd76..7566735 100644
--- a/asterixdb/asterix-lang-sqlpp/src/main/javacc/SQLPP.jj
+++ b/asterixdb/asterix-lang-sqlpp/src/main/javacc/SQLPP.jj
@@ -210,6 +210,7 @@
 import org.apache.asterix.lang.sqlpp.clause.SelectRegular;
 import org.apache.asterix.lang.sqlpp.clause.SelectSetOperation;
 import org.apache.hyracks.algebricks.core.algebra.operators.logical.TimeTravel;
+import org.apache.asterix.lang.sqlpp.struct.TimeTravelSpec;
 import org.apache.asterix.lang.sqlpp.clause.UnnestClause;
 import org.apache.asterix.lang.common.clause.WhereClause;
 import org.apache.asterix.lang.sqlpp.expression.ChangeExpression;
@@ -743,6 +744,10 @@
       return laIdentifier(1, image);
     }
 
+    private boolean laTimeTravelKeyword() {
+      return laIdentifier(SNAPSHOT) || laIdentifier(TIMESTAMP);
+    }
+
     private Token fetchHint(Token token, SqlppHint... expectedHints) {
       Token hintToken = token.specialToken;
       if (hintToken == null) {
@@ -6227,8 +6232,7 @@
   AbstractBinaryCorrelateClause correlateClause = null;
   List<AbstractBinaryCorrelateClause> correlateClauses = new ArrayList<AbstractBinaryCorrelateClause>();
 
-  String snapshotOrTimestamp = null;
-  TimeTravel.Type timeTravelType = TimeTravel.Type.SNAPSHOT_ID;
+  TimeTravelSpec timeTravel = null;
 }
 {
   leftExpr = Expression()
@@ -6244,29 +6248,14 @@
   (
     <AT>
     (
-        LOOKAHEAD( { laIdentifier(SNAPSHOT) } )
-        <IDENTIFIER> snapshotOrTimestamp = StringLiteral()
-        { timeTravelType = TimeTravel.Type.SNAPSHOT_ID; }
-        (
-          <AT> posVar = Variable()
-        )?
-      | LOOKAHEAD( { laIdentifier(TIMESTAMP) } )
-        <IDENTIFIER> snapshotOrTimestamp = StringLiteral()
-        { timeTravelType = TimeTravel.Type.SNAPSHOT_TIMESTAMP; }
+        LOOKAHEAD( { laTimeTravelKeyword() } )
+        timeTravel = TimeTravelSpec()
         (
           <AT> posVar = Variable()
         )?
       | posVar = Variable()
         (
-          <AT>
-          (
-            LOOKAHEAD( { laIdentifier(SNAPSHOT) } )
-            <IDENTIFIER> snapshotOrTimestamp = StringLiteral()
-            { timeTravelType = TimeTravel.Type.SNAPSHOT_ID; }
-          | LOOKAHEAD( { laIdentifier(TIMESTAMP) } )
-            <IDENTIFIER> snapshotOrTimestamp = StringLiteral()
-            { timeTravelType = TimeTravel.Type.SNAPSHOT_TIMESTAMP; }
-          )
+          <AT> timeTravel = TimeTravelSpec()
         )?
       )
   )?
@@ -6286,13 +6275,7 @@
     if (leftVar == null) {
         leftVar = ExpressionToVariableUtil.getGeneratedVariable(leftExpr, true);
     }
-    FromTerm fromTerm;
-    if (snapshotOrTimestamp != null) {
-      TimeTravel timeTravel = new TimeTravel(snapshotOrTimestamp, timeTravelType);
-      fromTerm = new FromTerm(leftExpr, leftVar, posVar, correlateClauses, timeTravel);
-    } else {
-      fromTerm = new FromTerm(leftExpr, leftVar, posVar, correlateClauses);
-    }
+    FromTerm fromTerm = new FromTerm(leftExpr, leftVar, posVar, correlateClauses, timeTravel);
     fromTerm.setSourceLocation(leftExpr.getSourceLocation());
     return fromTerm;
   }
@@ -6339,14 +6322,35 @@
   }
 }
 
+TimeTravelSpec TimeTravelSpec() throws ParseException :
+{
+  Token startToken = null;
+  Expression valueExpr = null;
+  TimeTravel.Type timeTravelType = null;
+}
+{
+  (
+      LOOKAHEAD( { laIdentifier(SNAPSHOT) } )
+      <IDENTIFIER> { startToken = token; timeTravelType = TimeTravel.Type.SNAPSHOT_ID; }
+      valueExpr = Expression()
+    | LOOKAHEAD( { laIdentifier(TIMESTAMP) } )
+      <IDENTIFIER> { startToken = token; timeTravelType = TimeTravel.Type.SNAPSHOT_TIMESTAMP; }
+      valueExpr = Expression()
+  )
+  {
+    TimeTravelSpec timeTravel = new TimeTravelSpec(valueExpr, timeTravelType);
+    timeTravel.setSourceLocation(getSourceLocation(startToken));
+    return timeTravel;
+  }
+}
+
 JoinClauseRightInput JoinClauseRightInput() throws ParseException :
 {
     Expression rightExpr = null;
     VariableExpr rightVar = null;
     VariableExpr posVar = null;
 
-    String snapshotOrTimestamp = null;
-    TimeTravel.Type timeTravelType = TimeTravel.Type.SNAPSHOT_ID;
+    TimeTravelSpec timeTravel = null;
 }
 {
   rightExpr = Expression()
@@ -6363,29 +6367,14 @@
   (
       <AT>
       (
-        LOOKAHEAD( { laIdentifier(SNAPSHOT) } )
-        <IDENTIFIER> snapshotOrTimestamp = StringLiteral()
-        { timeTravelType = TimeTravel.Type.SNAPSHOT_ID; }
-        (
-          <AT> posVar = Variable()
-        )?
-      | LOOKAHEAD( { laIdentifier(TIMESTAMP) } )
-        <IDENTIFIER> snapshotOrTimestamp = StringLiteral()
-        { timeTravelType = TimeTravel.Type.SNAPSHOT_TIMESTAMP; }
+        LOOKAHEAD( { laTimeTravelKeyword() } )
+        timeTravel = TimeTravelSpec()
         (
           <AT> posVar = Variable()
         )?
       | posVar = Variable()
         (
-          <AT>
-          (
-            LOOKAHEAD( { laIdentifier(SNAPSHOT) } )
-            <IDENTIFIER> snapshotOrTimestamp = StringLiteral()
-            { timeTravelType = TimeTravel.Type.SNAPSHOT_ID; }
-          | LOOKAHEAD( { laIdentifier(TIMESTAMP) } )
-            <IDENTIFIER> snapshotOrTimestamp = StringLiteral()
-            { timeTravelType = TimeTravel.Type.SNAPSHOT_TIMESTAMP; }
-          )
+          <AT> timeTravel = TimeTravelSpec()
         )?
       )
   )?
@@ -6393,10 +6382,6 @@
     if (rightVar == null) {
       rightVar = ExpressionToVariableUtil.getGeneratedVariable(rightExpr, true);
     }
-    TimeTravel timeTravel = null;
-    if (snapshotOrTimestamp != null) {
-      timeTravel = new TimeTravel(snapshotOrTimestamp, timeTravelType);
-    }
     return new JoinClauseRightInput(rightExpr, rightVar, posVar, timeTravel);
   }
 }
diff --git a/asterixdb/asterix-lang-sqlpp/src/test/java/org/apache/asterix/lang/sqlpp/parser/TimeTravelParserTest.java b/asterixdb/asterix-lang-sqlpp/src/test/java/org/apache/asterix/lang/sqlpp/parser/TimeTravelParserTest.java
new file mode 100644
index 0000000..b9727dd
--- /dev/null
+++ b/asterixdb/asterix-lang-sqlpp/src/test/java/org/apache/asterix/lang/sqlpp/parser/TimeTravelParserTest.java
@@ -0,0 +1,281 @@
+/*
+ * 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 org.apache.asterix.lang.sqlpp.parser;
+
+import static org.apache.hyracks.util.annotations.AiProvenance.Agent.CLAUDE_OPUS_5;
+import static org.apache.hyracks.util.annotations.AiProvenance.ContributionKind.TEST_GENERATED;
+import static org.apache.hyracks.util.annotations.AiProvenance.Tool.CLAUDE_CODE_UI;
+
+import java.util.List;
+
+import org.apache.asterix.common.exceptions.CompilationException;
+import org.apache.asterix.common.metadata.NamespaceResolver;
+import org.apache.asterix.lang.common.base.Expression;
+import org.apache.asterix.lang.common.base.IParser;
+import org.apache.asterix.lang.common.base.IParserFactory;
+import org.apache.asterix.lang.common.base.Literal;
+import org.apache.asterix.lang.common.base.Statement;
+import org.apache.asterix.lang.common.expression.LiteralExpr;
+import org.apache.asterix.lang.common.statement.Query;
+import org.apache.asterix.lang.sqlpp.clause.FromTerm;
+import org.apache.asterix.lang.sqlpp.clause.JoinClause;
+import org.apache.asterix.lang.sqlpp.expression.SelectExpression;
+import org.apache.asterix.lang.sqlpp.struct.TimeTravelSpec;
+import org.apache.hyracks.algebricks.core.algebra.operators.logical.TimeTravel;
+import org.apache.hyracks.util.annotations.AiProvenance;
+import org.junit.Assert;
+import org.junit.Test;
+
+/**
+ * Grammar coverage for the {@code AT SNAPSHOT} / {@code AT TIMESTAMP} value, which accepts an arbitrary
+ * expression rather than only a string literal. Whether the expression can actually be folded to a constant is
+ * decided later, during expression-to-plan translation, so this only pins down what parses and into what.
+ */
+@AiProvenance(agent = CLAUDE_OPUS_5, tool = CLAUDE_CODE_UI, contributionKind = TEST_GENERATED)
+public class TimeTravelParserTest {
+
+    @Test
+    public void testStringLiteralSnapshot() throws Exception {
+        TimeTravelSpec spec = fromTermTimeTravel("SELECT * FROM c AT SNAPSHOT \"8574821\";");
+        Assert.assertEquals(TimeTravel.Type.SNAPSHOT_ID, spec.getType());
+        Assert.assertEquals("8574821", stringLiteral(spec));
+    }
+
+    /**
+     * A bare integer used to be a parse error, which made the natural spelling of a snapshot id unusable.
+     */
+    @Test
+    public void testIntegerLiteralSnapshot() throws Exception {
+        TimeTravelSpec spec = fromTermTimeTravel("SELECT * FROM c AT SNAPSHOT 8574821;");
+        Assert.assertEquals(TimeTravel.Type.SNAPSHOT_ID, spec.getType());
+        Assert.assertEquals(Expression.Kind.LITERAL_EXPRESSION, spec.getValueExpression().getKind());
+        Assert.assertEquals(Literal.Type.LONG, ((LiteralExpr) spec.getValueExpression()).getValue().getLiteralType());
+    }
+
+    @Test
+    public void testTimestampKeyword() throws Exception {
+        TimeTravelSpec spec = fromTermTimeTravel("SELECT * FROM c AT TIMESTAMP \"2026-08-20\";");
+        Assert.assertEquals(TimeTravel.Type.SNAPSHOT_TIMESTAMP, spec.getType());
+        Assert.assertEquals("2026-08-20", stringLiteral(spec));
+    }
+
+    @Test
+    public void testNamedParameter() throws Exception {
+        TimeTravelSpec spec = fromTermTimeTravel("SELECT * FROM c AT TIMESTAMP $ts;");
+        Assert.assertEquals(Expression.Kind.VARIABLE_EXPRESSION, spec.getValueExpression().getKind());
+    }
+
+    @Test
+    public void testPositionalParameter() throws Exception {
+        Assert.assertEquals(Expression.Kind.VARIABLE_EXPRESSION,
+                fromTermTimeTravel("SELECT * FROM c AT SNAPSHOT $1;").getValueExpression().getKind());
+        Assert.assertEquals(Expression.Kind.VARIABLE_EXPRESSION,
+                fromTermTimeTravel("SELECT * FROM c AT SNAPSHOT ?;").getValueExpression().getKind());
+    }
+
+    @Test
+    public void testDateArithmetic() throws Exception {
+        TimeTravelSpec spec = fromTermTimeTravel("SELECT * FROM c AT TIMESTAMP current_date() - duration(\"P1D\");");
+        Assert.assertEquals(TimeTravel.Type.SNAPSHOT_TIMESTAMP, spec.getType());
+        Assert.assertEquals(Expression.Kind.OP_EXPRESSION, spec.getValueExpression().getKind());
+    }
+
+    @Test
+    public void testFunctionCall() throws Exception {
+        TimeTravelSpec spec =
+                fromTermTimeTravel("SELECT * FROM c AT TIMESTAMP unix_time_from_date_in_ms(date(\"2026-08-20\"));");
+        Assert.assertEquals(Expression.Kind.CALL_EXPRESSION, spec.getValueExpression().getKind());
+    }
+
+    /**
+     * {@code AT} is overloaded: it introduces both the time travel value and the positional variable, in either
+     * order.
+     */
+    @Test
+    public void testTimeTravelThenPositionalVariable() throws Exception {
+        FromTerm fromTerm = fromTerm("SELECT * FROM c AT SNAPSHOT 1 AT p;");
+        Assert.assertTrue(fromTerm.hasTimeTravel());
+        Assert.assertTrue(fromTerm.hasPositionalVariable());
+    }
+
+    @Test
+    public void testPositionalVariableThenTimeTravel() throws Exception {
+        FromTerm fromTerm = fromTerm("SELECT * FROM c AT p AT SNAPSHOT 1;");
+        Assert.assertTrue(fromTerm.hasTimeTravel());
+        Assert.assertTrue(fromTerm.hasPositionalVariable());
+    }
+
+    @Test
+    public void testPositionalVariableOnly() throws Exception {
+        FromTerm fromTerm = fromTerm("SELECT * FROM c AT p;");
+        Assert.assertFalse(fromTerm.hasTimeTravel());
+        Assert.assertTrue(fromTerm.hasPositionalVariable());
+    }
+
+    /**
+     * The value is a full expression now, so the parser has to stop at the {@code AT} that introduces the
+     * positional variable instead of trying to continue the expression. The bare-literal cases above cannot
+     * detect that, because a literal ends where an expression could not have continued anyway.
+     */
+    @Test
+    public void testTimeTravelExpressionThenPositionalVariable() throws Exception {
+        FromTerm fromTerm = fromTerm("SELECT * FROM c AT SNAPSHOT 5 - 4 AT p;");
+        Assert.assertEquals(Expression.Kind.OP_EXPRESSION, fromTerm.getTimeTravel().getValueExpression().getKind());
+        Assert.assertTrue(fromTerm.hasPositionalVariable());
+    }
+
+    @Test
+    public void testPositionalVariableThenTimeTravelExpression() throws Exception {
+        FromTerm fromTerm = fromTerm("SELECT * FROM c AT p AT SNAPSHOT 5 - 4;");
+        Assert.assertEquals(Expression.Kind.OP_EXPRESSION, fromTerm.getTimeTravel().getValueExpression().getKind());
+        Assert.assertTrue(fromTerm.hasPositionalVariable());
+    }
+
+    /**
+     * Alias, value and positional variable together: the alias binds before either {@code AT}, so all three
+     * have to come out distinct.
+     */
+    @Test
+    public void testAliasTimeTravelAndPositionalVariable() throws Exception {
+        FromTerm fromTerm = fromTerm("SELECT * FROM c AS x AT SNAPSHOT 5 - 4 AT p;");
+        Assert.assertEquals(Expression.Kind.OP_EXPRESSION, fromTerm.getTimeTravel().getValueExpression().getKind());
+        Assert.assertTrue(fromTerm.hasPositionalVariable());
+        Assert.assertNotEquals(fromTerm.getLeftVariable().getVar(), fromTerm.getPositionalVariable().getVar());
+    }
+
+    /**
+     * The JOIN clause repeats the same overloaded {@code AT} structure, from a separate grammar production.
+     */
+    @Test
+    public void testJoinTimeTravelAndPositionalVariable() throws Exception {
+        FromTerm fromTerm = fromTerm("SELECT * FROM c JOIN d AS y AT SNAPSHOT 5 - 4 AT q ON c.id = d.id;");
+        JoinClause joinClause = (JoinClause) fromTerm.getCorrelateClauses().get(0);
+        Assert.assertEquals(Expression.Kind.OP_EXPRESSION, joinClause.getTimeTravel().getValueExpression().getKind());
+        Assert.assertTrue(joinClause.hasPositionalVariable());
+    }
+
+    /**
+     * {@code SNAPSHOT} and {@code TIMESTAMP} are not reserved words, but the lookahead that selects the time
+     * travel branch keys on them, so neither can name a positional variable. This predates widening the value
+     * to an expression; it is pinned here so it stays a decision rather than becoming a surprise.
+     */
+    @Test
+    public void testTimeTravelKeywordCannotNamePositionalVariable() throws Exception {
+        assertParseError("SELECT * FROM c AT snapshot;");
+        assertParseError("SELECT * FROM c AT timestamp;");
+    }
+
+    @Test
+    public void testJoinClause() throws Exception {
+        FromTerm fromTerm = fromTerm("SELECT * FROM c JOIN d AT SNAPSHOT 5 - 4 ON c.id = d.id;");
+        JoinClause joinClause = (JoinClause) fromTerm.getCorrelateClauses().get(0);
+        Assert.assertTrue(joinClause.hasTimeTravel());
+        Assert.assertEquals(Expression.Kind.OP_EXPRESSION, joinClause.getTimeTravel().getValueExpression().getKind());
+    }
+
+    @Test
+    public void testMissingValueIsSyntaxError() throws Exception {
+        assertParseError("SELECT * FROM c AT SNAPSHOT;");
+        assertParseError("SELECT * FROM c AT TIMESTAMP;");
+    }
+
+    @Test
+    public void testArithmeticOnSnapshotId() throws Exception {
+        Assert.assertEquals(Expression.Kind.OP_EXPRESSION,
+                fromTermTimeTravel("SELECT * FROM c AT SNAPSHOT 8574821 + 0;").getValueExpression().getKind());
+    }
+
+    /**
+     * The value is an expression, so a parenthesised one parses like any other.
+     */
+    @Test
+    public void testParenthesized() throws Exception {
+        Assert.assertEquals(TimeTravel.Type.SNAPSHOT_ID,
+                fromTermTimeTravel("SELECT * FROM c AT SNAPSHOT (8574821);").getType());
+    }
+
+    @Test
+    public void testCrossJoin() throws Exception {
+        FromTerm fromTerm = fromTerm("SELECT * FROM c CROSS JOIN d AT SNAPSHOT 5;");
+        JoinClause joinClause = (JoinClause) fromTerm.getCorrelateClauses().get(0);
+        Assert.assertTrue(joinClause.hasTimeTravel());
+    }
+
+    @Test
+    public void testLeftOuterJoin() throws Exception {
+        FromTerm fromTerm = fromTerm("SELECT * FROM c LEFT OUTER JOIN d AT SNAPSHOT 5 ON c.id = d.id;");
+        Assert.assertTrue(((JoinClause) fromTerm.getCorrelateClauses().get(0)).hasTimeTravel());
+    }
+
+    @Test
+    public void testRightOuterJoin() throws Exception {
+        FromTerm fromTerm = fromTerm("SELECT * FROM c RIGHT OUTER JOIN d AT SNAPSHOT 5 ON c.id = d.id;");
+        Assert.assertTrue(((JoinClause) fromTerm.getCorrelateClauses().get(0)).hasTimeTravel());
+    }
+
+    /**
+     * UNNEST shares the correlate-clause base with JOIN but the grammar never attaches a value
+     * to it, so this must remain a syntax error rather than quietly parsing.
+     */
+    @Test
+    public void testUnnestTakesNoTimeTravel() throws Exception {
+        assertParseError("SELECT * FROM c UNNEST c.items AS i AT SNAPSHOT 5;");
+    }
+
+    @Test
+    public void testBothClausesInOneQuery() throws Exception {
+        FromTerm fromTerm = fromTerm("SELECT * FROM c AT SNAPSHOT 1 JOIN d AT SNAPSHOT 2 ON c.id = d.id;");
+        Assert.assertTrue(fromTerm.hasTimeTravel());
+        Assert.assertTrue(((JoinClause) fromTerm.getCorrelateClauses().get(0)).hasTimeTravel());
+    }
+
+    private static String stringLiteral(TimeTravelSpec spec) {
+        Expression valueExpr = spec.getValueExpression();
+        Assert.assertEquals(Expression.Kind.LITERAL_EXPRESSION, valueExpr.getKind());
+        return ((LiteralExpr) valueExpr).getValue().getStringValue();
+    }
+
+    private static TimeTravelSpec fromTermTimeTravel(String query) throws Exception {
+        FromTerm fromTerm = fromTerm(query);
+        Assert.assertTrue("no time travel was parsed", fromTerm.hasTimeTravel());
+        return fromTerm.getTimeTravel();
+    }
+
+    private static FromTerm fromTerm(String query) throws Exception {
+        List<Statement> statements = parse(query);
+        Assert.assertEquals(1, statements.size());
+        SelectExpression selectExpr = (SelectExpression) ((Query) statements.get(0)).getBody();
+        return selectExpr.getSelectSetOperation().getLeftInput().getSelectBlock().getFromClause().getFromTerms().get(0);
+    }
+
+    private static List<Statement> parse(String query) throws CompilationException {
+        IParserFactory factory = new SqlppParserFactory(new NamespaceResolver(false));
+        IParser parser = factory.createParser(query);
+        return parser.parse();
+    }
+
+    private static void assertParseError(String query) {
+        try {
+            parse(query);
+            Assert.fail("expected a syntax error for: " + query);
+        } catch (CompilationException e) {
+            Assert.assertTrue(e.getMessage(), e.getMessage().contains("Syntax error"));
+        }
+    }
+}
diff --git a/hyracks-fullstack/algebricks/algebricks-core/src/main/java/org/apache/hyracks/algebricks/core/algebra/operators/logical/AbstractUnnestNonMapOperator.java b/hyracks-fullstack/algebricks/algebricks-core/src/main/java/org/apache/hyracks/algebricks/core/algebra/operators/logical/AbstractUnnestNonMapOperator.java
index fed8a53..f1b7517 100644
--- a/hyracks-fullstack/algebricks/algebricks-core/src/main/java/org/apache/hyracks/algebricks/core/algebra/operators/logical/AbstractUnnestNonMapOperator.java
+++ b/hyracks-fullstack/algebricks/algebricks-core/src/main/java/org/apache/hyracks/algebricks/core/algebra/operators/logical/AbstractUnnestNonMapOperator.java
@@ -19,14 +19,20 @@
 
 package org.apache.hyracks.algebricks.core.algebra.operators.logical;
 
+import static org.apache.hyracks.util.annotations.AiProvenance.Agent.CLAUDE_OPUS_5;
+import static org.apache.hyracks.util.annotations.AiProvenance.Tool.CLAUDE_CODE_UI;
+
 import java.util.ArrayList;
 import java.util.List;
 
 import org.apache.commons.lang3.mutable.Mutable;
+import org.apache.hyracks.algebricks.common.exceptions.AlgebricksException;
 import org.apache.hyracks.algebricks.core.algebra.base.ILogicalExpression;
 import org.apache.hyracks.algebricks.core.algebra.base.LogicalVariable;
 import org.apache.hyracks.algebricks.core.algebra.properties.FilteredVariablePropagationPolicy;
 import org.apache.hyracks.algebricks.core.algebra.properties.VariablePropagationPolicy;
+import org.apache.hyracks.algebricks.core.algebra.visitors.ILogicalExpressionReferenceTransform;
+import org.apache.hyracks.util.annotations.AiProvenance;
 
 public abstract class AbstractUnnestNonMapOperator extends AbstractUnnestOperator {
 
@@ -76,6 +82,21 @@
         this.positionalVariableType = positionalVariableType;
     }
 
+    /**
+     * Also transforms the time travel value, so that it is folded, type-checked and printed by the same rules
+     * that handle every other expression an operator carries -- constant folding in particular, which is what
+     * reduces it to the constant the scan needs.
+     */
+    @Override
+    @AiProvenance(agent = CLAUDE_OPUS_5, tool = CLAUDE_CODE_UI)
+    public boolean acceptExpressionTransform(ILogicalExpressionReferenceTransform visitor) throws AlgebricksException {
+        boolean changed = super.acceptExpressionTransform(visitor);
+        if (timeTravel != null) {
+            changed |= visitor.transform(timeTravel.getValueExpressionRef());
+        }
+        return changed;
+    }
+
     public Object getPositionalVariableType() {
         return positionalVariableType;
     }
diff --git a/hyracks-fullstack/algebricks/algebricks-core/src/main/java/org/apache/hyracks/algebricks/core/algebra/operators/logical/TimeTravel.java b/hyracks-fullstack/algebricks/algebricks-core/src/main/java/org/apache/hyracks/algebricks/core/algebra/operators/logical/TimeTravel.java
index 52325fd..f1628c3 100644
--- a/hyracks-fullstack/algebricks/algebricks-core/src/main/java/org/apache/hyracks/algebricks/core/algebra/operators/logical/TimeTravel.java
+++ b/hyracks-fullstack/algebricks/algebricks-core/src/main/java/org/apache/hyracks/algebricks/core/algebra/operators/logical/TimeTravel.java
@@ -19,8 +19,26 @@
 
 package org.apache.hyracks.algebricks.core.algebra.operators.logical;
 
+import static org.apache.hyracks.util.annotations.AiProvenance.Agent.CLAUDE_OPUS_5;
+import static org.apache.hyracks.util.annotations.AiProvenance.ContributionKind.REFACTORED;
+import static org.apache.hyracks.util.annotations.AiProvenance.Tool.CLAUDE_CODE_UI;
+
 import java.util.Objects;
 
+import org.apache.commons.lang3.mutable.Mutable;
+import org.apache.hyracks.algebricks.core.algebra.base.ILogicalExpression;
+import org.apache.hyracks.util.annotations.AiProvenance;
+
+/**
+ * The {@code AT SNAPSHOT} / {@code AT TIMESTAMP} specification of a time travelling scan.
+ * <p>
+ * The value is an ordinary expression reference, exposed through
+ * {@link AbstractUnnestNonMapOperator#acceptExpressionTransform} like any other operator expression, so
+ * constant folding reduces it in the normal course of optimization. It has to end up a constant: the value
+ * selects which snapshot's manifest and data files the scan reads, so it must be known while the plan is still
+ * being built, and whoever consumes it (see {@code DatasetRewriter}) rejects anything that did not fold.
+ */
+@AiProvenance(agent = CLAUDE_OPUS_5, tool = CLAUDE_CODE_UI, contributionKind = REFACTORED)
 public class TimeTravel {
 
     public enum Type {
@@ -38,16 +56,20 @@
         }
     }
 
-    private final String snapshotIdOrTimestamp;
+    private final Mutable<ILogicalExpression> valueExpressionRef;
     private final Type type;
 
-    public TimeTravel(String snapshotIdOrTimestamp, Type type) {
-        this.snapshotIdOrTimestamp = snapshotIdOrTimestamp;
+    public TimeTravel(Mutable<ILogicalExpression> valueExpressionRef, Type type) {
+        this.valueExpressionRef = valueExpressionRef;
         this.type = type;
     }
 
-    public String getSnapshotIdOrTimestamp() {
-        return snapshotIdOrTimestamp;
+    public Mutable<ILogicalExpression> getValueExpressionRef() {
+        return valueExpressionRef;
+    }
+
+    public ILogicalExpression getValueExpression() {
+        return valueExpressionRef.getValue();
     }
 
     public Type getType() {
@@ -62,17 +84,16 @@
         if (!(object instanceof TimeTravel target)) {
             return false;
         }
-        return Objects.equals(snapshotIdOrTimestamp, target.getSnapshotIdOrTimestamp())
-                && Objects.equals(type, target.getType());
+        return type == target.type && Objects.equals(getValueExpression(), target.getValueExpression());
     }
 
     @Override
     public int hashCode() {
-        return Objects.hash(snapshotIdOrTimestamp, type);
+        return Objects.hash(getValueExpression(), type);
     }
 
     @Override
     public String toString() {
-        return type.getKeyword() + " " + snapshotIdOrTimestamp;
+        return type.getKeyword() + " " + getValueExpression();
     }
 }
diff --git a/hyracks-fullstack/algebricks/algebricks-core/src/main/java/org/apache/hyracks/algebricks/core/algebra/operators/logical/visitors/LogicalOperatorDeepCopyWithNewVariablesVisitor.java b/hyracks-fullstack/algebricks/algebricks-core/src/main/java/org/apache/hyracks/algebricks/core/algebra/operators/logical/visitors/LogicalOperatorDeepCopyWithNewVariablesVisitor.java
index 132469f..f9a78c8 100644
--- a/hyracks-fullstack/algebricks/algebricks-core/src/main/java/org/apache/hyracks/algebricks/core/algebra/operators/logical/visitors/LogicalOperatorDeepCopyWithNewVariablesVisitor.java
+++ b/hyracks-fullstack/algebricks/algebricks-core/src/main/java/org/apache/hyracks/algebricks/core/algebra/operators/logical/visitors/LogicalOperatorDeepCopyWithNewVariablesVisitor.java
@@ -18,6 +18,9 @@
  */
 package org.apache.hyracks.algebricks.core.algebra.operators.logical.visitors;
 
+import static org.apache.hyracks.util.annotations.AiProvenance.Agent.CLAUDE_OPUS_5;
+import static org.apache.hyracks.util.annotations.AiProvenance.Tool.CLAUDE_CODE_UI;
+
 import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.HashSet;
@@ -67,6 +70,7 @@
 import org.apache.hyracks.algebricks.core.algebra.operators.logical.SplitOperator;
 import org.apache.hyracks.algebricks.core.algebra.operators.logical.SubplanOperator;
 import org.apache.hyracks.algebricks.core.algebra.operators.logical.SwitchOperator;
+import org.apache.hyracks.algebricks.core.algebra.operators.logical.TimeTravel;
 import org.apache.hyracks.algebricks.core.algebra.operators.logical.TokenizeOperator;
 import org.apache.hyracks.algebricks.core.algebra.operators.logical.UnionAllOperator;
 import org.apache.hyracks.algebricks.core.algebra.operators.logical.UnnestMapOperator;
@@ -78,6 +82,7 @@
 import org.apache.hyracks.algebricks.core.algebra.util.OperatorManipulationUtil;
 import org.apache.hyracks.algebricks.core.algebra.util.OperatorPropertiesUtil;
 import org.apache.hyracks.algebricks.core.algebra.visitors.IQueryOperatorVisitor;
+import org.apache.hyracks.util.annotations.AiProvenance;
 
 /**
  * This visitor deep-copies a query plan but uses a new set of variables. Method
@@ -562,7 +567,8 @@
     public ILogicalOperator visitUnnestOperator(UnnestOperator op, ILogicalOperator arg) throws AlgebricksException {
         UnnestOperator opCopy = new UnnestOperator(deepCopyVariable(op.getVariable()),
                 exprDeepCopyVisitor.deepCopyExpressionReference(op.getExpressionRef()),
-                deepCopyVariable(op.getPositionalVariable()), op.getPositionalVariableType(), op.getTimeTravel());
+                deepCopyVariable(op.getPositionalVariable()), op.getPositionalVariableType(),
+                deepCopyTimeTravel(op.getTimeTravel()));
         deepCopyInputsAnnotationsAndExecutionMode(op, arg, opCopy);
         return opCopy;
     }
@@ -600,7 +606,7 @@
         LeftOuterUnnestOperator opCopy = new LeftOuterUnnestOperator(deepCopyVariable(op.getVariable()),
                 exprDeepCopyVisitor.deepCopyExpressionReference(op.getExpressionRef()),
                 deepCopyVariable(op.getPositionalVariable()), op.getPositionalVariableType(), op.getMissingValue(),
-                op.getTimeTravel());
+                deepCopyTimeTravel(op.getTimeTravel()));
         deepCopyInputsAnnotationsAndExecutionMode(op, arg, opCopy);
         return opCopy;
     }
@@ -642,4 +648,11 @@
     public LinkedHashMap<LogicalVariable, LogicalVariable> getInputToOutputVariableMapping() {
         return inputVarToOutputVarMapping;
     }
+
+    @AiProvenance(agent = CLAUDE_OPUS_5, tool = CLAUDE_CODE_UI)
+    private TimeTravel deepCopyTimeTravel(TimeTravel timeTravel) throws AlgebricksException {
+        return timeTravel == null ? null
+                : new TimeTravel(exprDeepCopyVisitor.deepCopyExpressionReference(timeTravel.getValueExpressionRef()),
+                        timeTravel.getType());
+    }
 }
diff --git a/hyracks-fullstack/algebricks/algebricks-core/src/main/java/org/apache/hyracks/algebricks/core/algebra/operators/logical/visitors/OperatorDeepCopyVisitor.java b/hyracks-fullstack/algebricks/algebricks-core/src/main/java/org/apache/hyracks/algebricks/core/algebra/operators/logical/visitors/OperatorDeepCopyVisitor.java
index dde83bf..b724194 100644
--- a/hyracks-fullstack/algebricks/algebricks-core/src/main/java/org/apache/hyracks/algebricks/core/algebra/operators/logical/visitors/OperatorDeepCopyVisitor.java
+++ b/hyracks-fullstack/algebricks/algebricks-core/src/main/java/org/apache/hyracks/algebricks/core/algebra/operators/logical/visitors/OperatorDeepCopyVisitor.java
@@ -19,6 +19,9 @@
 
 package org.apache.hyracks.algebricks.core.algebra.operators.logical.visitors;
 
+import static org.apache.hyracks.util.annotations.AiProvenance.Agent.CLAUDE_OPUS_5;
+import static org.apache.hyracks.util.annotations.AiProvenance.Tool.CLAUDE_CODE_UI;
+
 import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.HashMap;
@@ -67,6 +70,7 @@
 import org.apache.hyracks.algebricks.core.algebra.operators.logical.SplitOperator;
 import org.apache.hyracks.algebricks.core.algebra.operators.logical.SubplanOperator;
 import org.apache.hyracks.algebricks.core.algebra.operators.logical.SwitchOperator;
+import org.apache.hyracks.algebricks.core.algebra.operators.logical.TimeTravel;
 import org.apache.hyracks.algebricks.core.algebra.operators.logical.TokenizeOperator;
 import org.apache.hyracks.algebricks.core.algebra.operators.logical.UnionAllOperator;
 import org.apache.hyracks.algebricks.core.algebra.operators.logical.UnnestMapOperator;
@@ -75,6 +79,7 @@
 import org.apache.hyracks.algebricks.core.algebra.operators.logical.WriteOperator;
 import org.apache.hyracks.algebricks.core.algebra.util.OperatorManipulationUtil;
 import org.apache.hyracks.algebricks.core.algebra.visitors.ILogicalOperatorVisitor;
+import org.apache.hyracks.util.annotations.AiProvenance;
 
 public class OperatorDeepCopyVisitor implements ILogicalOperatorVisitor<ILogicalOperator, Void> {
 
@@ -242,7 +247,7 @@
     @Override
     public ILogicalOperator visitUnnestOperator(UnnestOperator op, Void arg) throws AlgebricksException {
         return new UnnestOperator(op.getVariable(), deepCopyExpressionRef(op.getExpressionRef()),
-                op.getPositionalVariable(), op.getPositionalVariableType(), op.getTimeTravel());
+                op.getPositionalVariable(), op.getPositionalVariableType(), deepCopyTimeTravel(op.getTimeTravel()));
     }
 
     @Override
@@ -433,7 +438,8 @@
     public ILogicalOperator visitLeftOuterUnnestOperator(LeftOuterUnnestOperator op, Void arg)
             throws AlgebricksException {
         return new LeftOuterUnnestOperator(op.getVariable(), deepCopyExpressionRef(op.getExpressionRef()),
-                op.getPositionalVariable(), op.getPositionalVariableType(), op.getMissingValue(), op.getTimeTravel());
+                op.getPositionalVariable(), op.getPositionalVariableType(), op.getMissingValue(),
+                deepCopyTimeTravel(op.getTimeTravel()));
     }
 
     @Override
@@ -471,4 +477,10 @@
         }
         return newWinOp;
     }
+
+    @AiProvenance(agent = CLAUDE_OPUS_5, tool = CLAUDE_CODE_UI)
+    private TimeTravel deepCopyTimeTravel(TimeTravel timeTravel) throws AlgebricksException {
+        return timeTravel == null ? null
+                : new TimeTravel(deepCopyExpressionRef(timeTravel.getValueExpressionRef()), timeTravel.getType());
+    }
 }
diff --git a/hyracks-fullstack/algebricks/algebricks-core/src/main/java/org/apache/hyracks/algebricks/core/algebra/prettyprint/LogicalOperatorPrettyPrintVisitor.java b/hyracks-fullstack/algebricks/algebricks-core/src/main/java/org/apache/hyracks/algebricks/core/algebra/prettyprint/LogicalOperatorPrettyPrintVisitor.java
index d999f0c..a1a9c9c 100644
--- a/hyracks-fullstack/algebricks/algebricks-core/src/main/java/org/apache/hyracks/algebricks/core/algebra/prettyprint/LogicalOperatorPrettyPrintVisitor.java
+++ b/hyracks-fullstack/algebricks/algebricks-core/src/main/java/org/apache/hyracks/algebricks/core/algebra/prettyprint/LogicalOperatorPrettyPrintVisitor.java
@@ -366,7 +366,7 @@
         if (op.hasTimeTravel()) {
             TimeTravel timeTravel = op.getTimeTravel();
             buffer.append(" at ").append(timeTravel.getType().getKeyword()).append(" ")
-                    .append(timeTravel.getSnapshotIdOrTimestamp());
+                    .append(timeTravel.getValueExpression().accept(exprVisitor, indent));
         }
         if (op.hasPositionalVariable()) {
             buffer.append(" at ").append(String.valueOf(op.getPositionalVariable()));
@@ -384,7 +384,7 @@
         if (op.hasTimeTravel()) {
             TimeTravel timeTravel = op.getTimeTravel();
             buffer.append(" at ").append(timeTravel.getType().getKeyword()).append(" ")
-                    .append(timeTravel.getSnapshotIdOrTimestamp());
+                    .append(timeTravel.getValueExpression().accept(exprVisitor, indent));
         }
         if (op.getPositionalVariable() != null) {
             buffer.append(" at " + op.getPositionalVariable());
diff --git a/hyracks-fullstack/algebricks/algebricks-core/src/main/java/org/apache/hyracks/algebricks/core/utils/LogicalOperatorDotVisitor.java b/hyracks-fullstack/algebricks/algebricks-core/src/main/java/org/apache/hyracks/algebricks/core/utils/LogicalOperatorDotVisitor.java
index 5b21ed5..56eeccc 100644
--- a/hyracks-fullstack/algebricks/algebricks-core/src/main/java/org/apache/hyracks/algebricks/core/utils/LogicalOperatorDotVisitor.java
+++ b/hyracks-fullstack/algebricks/algebricks-core/src/main/java/org/apache/hyracks/algebricks/core/utils/LogicalOperatorDotVisitor.java
@@ -351,7 +351,7 @@
         stringBuilder.append("unnest ").append(op.getVariable());
         if (op.getTimeTravel() != null) {
             String keyword = op.getTimeTravel().getType().getKeyword();
-            String timeTravelValue = op.getTimeTravel().getSnapshotIdOrTimestamp();
+            String timeTravelValue = String.valueOf(op.getTimeTravel().getValueExpression());
             stringBuilder.append(" at ").append(keyword).append(" ").append(timeTravelValue);
         }
         if (op.getPositionalVariable() != null) {
@@ -374,7 +374,7 @@
         stringBuilder.append("outer-unnest ").append(op.getVariable());
         if (op.getTimeTravel() != null) {
             String keyword = op.getTimeTravel().getType().getKeyword();
-            String timeTravelValue = op.getTimeTravel().getSnapshotIdOrTimestamp();
+            String timeTravelValue = String.valueOf(op.getTimeTravel().getValueExpression());
             stringBuilder.append(" at ").append(keyword).append(" ").append(timeTravelValue);
         }
         if (op.getPositionalVariable() != null) {

-- 
To view, visit https://asterix-gerrit.ics.uci.edu/c/asterixdb/+/21606?usp=email
To unsubscribe, or for help writing mail filters, visit https://asterix-gerrit.ics.uci.edu/settings?usp=email

Gerrit-MessageType: newchange
Gerrit-Project: asterixdb
Gerrit-Branch: master
Gerrit-Change-Id: Ibe50c85b5defd86bed540d4c8fd14320f17cdad9
Gerrit-Change-Number: 21606
Gerrit-PatchSet: 1
Gerrit-Owner: Hussain Towaileb <hu...@gmail.com>