diff --git a/.gitignore b/.gitignore index d79ab5525c..9d4b2b3ce6 100644 --- a/.gitignore +++ b/.gitignore @@ -24,6 +24,7 @@ build doc/dependency_decisions.yml .serena/ .ralphex/progress/ +/eval-out/ # compiled cmd/ tool binaries, which land at the repo root when built with # `go build ./cmd/` and have been committed by accident more than once diff --git a/.mockery.yaml b/.mockery.yaml index 1fce9a75ba..9415649c10 100644 --- a/.mockery.yaml +++ b/.mockery.yaml @@ -275,6 +275,10 @@ packages: ClientCommands: ChatSubscriptionService: FileObjectService: + ObjectReader: + ObjectCreator: + ObjectMutator: + ObjectProvenance: github.com/anyproto/anytype-heart/core/api/filter: interfaces: ApiService: diff --git a/clientlibrary/service/service.pb.go b/clientlibrary/service/service.pb.go index 4cd0ccc3b5..689c47ab95 100644 --- a/clientlibrary/service/service.pb.go +++ b/clientlibrary/service/service.pb.go @@ -25,418 +25,419 @@ const _ = proto.GoGoProtoPackageIsVersion3 // please upgrade the proto package func init() { proto.RegisterFile("pb/protos/service/service.proto", fileDescriptor_93a29dc403579097) } var fileDescriptor_93a29dc403579097 = []byte{ - // 6572 bytes of a gzipped FileDescriptorProto - 0x1f, 0x8b, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x02, 0xff, 0xa4, 0x9d, 0xcd, 0x6f, 0x24, 0x49, - 0x56, 0xc0, 0xd7, 0x1c, 0x58, 0xa8, 0x65, 0x17, 0xa8, 0x81, 0x61, 0x77, 0xd8, 0xe9, 0xef, 0x6e, - 0x77, 0xb7, 0xed, 0xb4, 0xc7, 0x3d, 0x3d, 0x33, 0xec, 0x22, 0x41, 0xb5, 0xdd, 0xf6, 0xd4, 0x8e, - 0xdd, 0x6d, 0x5c, 0xb6, 0x5b, 0x8c, 0x84, 0x44, 0x76, 0x65, 0xb8, 0x9c, 0x38, 0x2b, 0x23, 0x37, - 0x33, 0xca, 0xdd, 0xb5, 0x08, 0xc4, 0x0a, 0x04, 0x62, 0x05, 0x62, 0xc5, 0x97, 0xe0, 0x84, 0xc4, - 0x85, 0x2b, 0x7f, 0x06, 0xc7, 0xe5, 0xc6, 0x11, 0xcd, 0xfc, 0x23, 0x28, 0x23, 0x22, 0xe3, 0xe3, - 0xe5, 0x7b, 0x91, 0xe9, 0xe1, 0xd4, 0x2d, 0xbf, 0xdf, 0x7b, 0x2f, 0x3e, 0x5e, 0x7c, 0x47, 0x46, - 0x0d, 0x6e, 0x16, 0xaf, 0x37, 0x8b, 0x92, 0x0b, 0x5e, 0x6d, 0x56, 0xac, 0xbc, 0x4a, 0xa7, 0xac, - 0xf9, 0x37, 0x92, 0x7f, 0x1e, 0x7e, 0x3d, 0xce, 0x97, 0x62, 0x59, 0xb0, 0xf7, 0xbe, 0x6d, 0xc9, - 0x29, 0x9f, 0xcf, 0xe3, 0x3c, 0xa9, 0x14, 0xf2, 0xde, 0xbb, 0x56, 0xc2, 0xae, 0x58, 0x2e, 0xf4, - 0xdf, 0xb7, 0xff, 0xfb, 0x3f, 0x7e, 0x6e, 0xf0, 0xad, 0x9d, 0x2c, 0x65, 0xb9, 0xd8, 0xd1, 0x1a, - 0xc3, 0xcf, 0x07, 0xdf, 0x1c, 0x15, 0xc5, 0x3e, 0x13, 0x67, 0xac, 0xac, 0x52, 0x9e, 0x0f, 0xef, - 0x46, 0xda, 0x41, 0x74, 0x5c, 0x4c, 0xa3, 0x51, 0x51, 0x44, 0x56, 0x18, 0x1d, 0xb3, 0x1f, 0x2e, - 0x58, 0x25, 0xde, 0xbb, 0x17, 0x86, 0xaa, 0x82, 0xe7, 0x15, 0x1b, 0x9e, 0x0f, 0x7e, 0x75, 0x54, - 0x14, 0x13, 0x26, 0x76, 0x59, 0x9d, 0x81, 0x89, 0x88, 0x05, 0x1b, 0xae, 0xb6, 0x54, 0x7d, 0xc0, - 0xf8, 0x78, 0xd8, 0x0d, 0x6a, 0x3f, 0x27, 0x83, 0x6f, 0xd4, 0x7e, 0x2e, 0x16, 0x22, 0xe1, 0x6f, - 0xf2, 0xe1, 0xed, 0xb6, 0xa2, 0x16, 0x19, 0xdb, 0x77, 0x42, 0x88, 0xb6, 0xfa, 0x6a, 0xf0, 0x4b, - 0xaf, 0xe2, 0x2c, 0x63, 0x62, 0xa7, 0x64, 0x75, 0xc2, 0x7d, 0x1d, 0x25, 0x8a, 0x94, 0xcc, 0xd8, - 0xbd, 0x1b, 0x64, 0xb4, 0xe1, 0xcf, 0x07, 0xdf, 0x54, 0x92, 0x63, 0x36, 0xe5, 0x57, 0xac, 0x1c, - 0xa2, 0x5a, 0x5a, 0x48, 0x14, 0x79, 0x0b, 0x82, 0xb6, 0x77, 0x78, 0x7e, 0xc5, 0x4a, 0x81, 0xdb, - 0xd6, 0xc2, 0xb0, 0x6d, 0x0b, 0x69, 0xdb, 0x7f, 0xbd, 0x32, 0xf8, 0xee, 0x68, 0x3a, 0xe5, 0x8b, - 0x5c, 0x1c, 0xf0, 0x69, 0x9c, 0x1d, 0xa4, 0xf9, 0xe5, 0x0b, 0xf6, 0x66, 0xe7, 0xa2, 0xe6, 0xf3, - 0x19, 0x1b, 0x3e, 0xf1, 0x4b, 0x55, 0xa1, 0x91, 0x61, 0x23, 0x17, 0x36, 0xbe, 0x3f, 0xbc, 0x9e, - 0x92, 0x4e, 0xcb, 0xdf, 0xad, 0x0c, 0x6e, 0xc0, 0xb4, 0x4c, 0x78, 0x76, 0xc5, 0x6c, 0x6a, 0x9e, - 0x76, 0x18, 0xf6, 0x71, 0x93, 0x9e, 0x8f, 0xae, 0xab, 0xa6, 0x53, 0xf4, 0x67, 0x2b, 0x83, 0xef, - 0xc0, 0x14, 0xa9, 0x9a, 0x1f, 0x15, 0xc5, 0x70, 0xab, 0xc3, 0xaa, 0x21, 0x4d, 0x3a, 0x3e, 0xb8, - 0x86, 0x86, 0x4e, 0xc2, 0x9f, 0x0c, 0xbe, 0x0d, 0x53, 0x70, 0x90, 0x56, 0x62, 0x54, 0x14, 0xd5, - 0x70, 0xb3, 0xc3, 0x5c, 0x03, 0x1a, 0xff, 0x5b, 0xfd, 0x15, 0x02, 0x25, 0x70, 0xcc, 0xae, 0xf8, - 0x65, 0xaf, 0x12, 0x30, 0x64, 0xef, 0x12, 0x70, 0x35, 0x74, 0x12, 0xb2, 0xc1, 0x3b, 0x6e, 0x9b, - 0x9d, 0xb0, 0x4a, 0xf6, 0x69, 0x8f, 0xe8, 0x66, 0xa9, 0x11, 0xe3, 0xf4, 0x71, 0x1f, 0x54, 0x7b, - 0x4b, 0x07, 0x43, 0xed, 0x2d, 0xe3, 0x95, 0x71, 0xf6, 0x10, 0xb5, 0xe0, 0x10, 0xc6, 0xd7, 0xa3, - 0x1e, 0xa4, 0x76, 0xf5, 0x87, 0x83, 0x5f, 0x7e, 0xc5, 0xcb, 0xcb, 0xaa, 0x88, 0xa7, 0x4c, 0xf7, - 0x47, 0xf7, 0x7d, 0xed, 0x46, 0x0a, 0xbb, 0xa4, 0x07, 0x5d, 0x98, 0xd3, 0x73, 0x34, 0xc2, 0x97, - 0x05, 0x83, 0x03, 0x81, 0x55, 0xac, 0x85, 0x54, 0xcf, 0x01, 0x21, 0x6d, 0xfb, 0x72, 0x30, 0xb4, - 0xb6, 0x5f, 0xff, 0x11, 0x9b, 0x8a, 0x51, 0x92, 0xc0, 0x5a, 0xb1, 0xba, 0x92, 0x88, 0x46, 0x49, - 0x42, 0xd5, 0x0a, 0x8e, 0x6a, 0x67, 0x6f, 0x06, 0xef, 0x02, 0x67, 0x32, 0x54, 0x93, 0x64, 0xb8, - 0x11, 0xb6, 0xa2, 0x31, 0xe3, 0x34, 0xea, 0x8b, 0x3b, 0xf1, 0x8f, 0x78, 0x3e, 0x66, 0x73, 0x7e, - 0xc5, 0x40, 0xfc, 0xa3, 0xd6, 0x14, 0x49, 0xc4, 0x7f, 0x58, 0x03, 0x09, 0x93, 0x09, 0xcb, 0xd8, - 0x54, 0x90, 0x61, 0xa2, 0xc4, 0x9d, 0x61, 0x62, 0x30, 0xa7, 0x85, 0x35, 0xc2, 0x7d, 0x26, 0x76, - 0x16, 0x65, 0xc9, 0x72, 0x41, 0xd6, 0xa5, 0x45, 0x3a, 0xeb, 0xd2, 0x43, 0x91, 0xfc, 0xec, 0x33, - 0x31, 0xca, 0x32, 0x32, 0x3f, 0x4a, 0xdc, 0x99, 0x1f, 0x83, 0x69, 0x0f, 0xd3, 0xc1, 0xaf, 0x38, - 0x25, 0x26, 0xc6, 0xf9, 0x39, 0x1f, 0xd2, 0x65, 0x21, 0xe5, 0xc6, 0xc7, 0x6a, 0x27, 0xa7, 0x9d, - 0xf0, 0xc1, 0xaf, 0xb9, 0x4e, 0x3e, 0xe5, 0x73, 0x56, 0xc4, 0x33, 0x36, 0x7c, 0x4c, 0x1b, 0x68, - 0x18, 0xe3, 0x6c, 0xad, 0x17, 0x8b, 0x94, 0xdb, 0xf3, 0xb7, 0x05, 0x2f, 0xe9, 0x38, 0x50, 0xe2, - 0xce, 0x72, 0x33, 0x98, 0xf6, 0xf0, 0x07, 0x83, 0x6f, 0xe9, 0x1e, 0xb9, 0x99, 0xc5, 0xdc, 0x43, - 0xbb, 0x6b, 0x38, 0x8d, 0xb9, 0xdf, 0x41, 0xb5, 0xcc, 0x1f, 0xa6, 0xb3, 0xb2, 0xee, 0xee, 0x70, - 0xf3, 0x5a, 0xda, 0x61, 0xde, 0x52, 0xb6, 0x42, 0x7c, 0xf3, 0x3b, 0x71, 0x3e, 0x65, 0x19, 0xa8, - 0x10, 0xa0, 0xae, 0x18, 0xa2, 0x42, 0x28, 0xd6, 0xf6, 0xae, 0x9a, 0xd0, 0xbd, 0xf7, 0x5d, 0x54, - 0x1b, 0xf4, 0xdd, 0xf7, 0xc2, 0x50, 0xcb, 0xf6, 0x2e, 0xcb, 0x18, 0x69, 0x5b, 0x09, 0x3b, 0x6c, - 0x1b, 0x48, 0xdb, 0xfe, 0xc9, 0xca, 0xe0, 0x7d, 0x2d, 0x3b, 0x2a, 0x59, 0xc6, 0xe3, 0xe4, 0x98, - 0xcd, 0xe3, 0x34, 0x4f, 0xf3, 0xd9, 0xa4, 0x8e, 0x8b, 0x8a, 0x98, 0xf4, 0xe1, 0x70, 0xc7, 0xa4, - 0x8f, 0x54, 0xd2, 0x89, 0x29, 0x07, 0xbf, 0x6e, 0x62, 0xae, 0x9e, 0x9a, 0xca, 0xc4, 0xd6, 0x43, - 0xee, 0x1a, 0x11, 0x54, 0x2e, 0x64, 0x7c, 0xaf, 0xf7, 0x83, 0x5b, 0x85, 0xab, 0xfb, 0x53, 0xbc, - 0x70, 0x41, 0x6f, 0x7a, 0x2f, 0x0c, 0xb5, 0x0b, 0xf7, 0x79, 0x1e, 0xbf, 0xce, 0x98, 0x9c, 0xdb, - 0xbc, 0x60, 0xe2, 0x0d, 0x2f, 0x2f, 0x27, 0xcb, 0x7c, 0x4a, 0x14, 0x2e, 0x0e, 0x77, 0x14, 0x2e, - 0xa9, 0xa4, 0x13, 0xf3, 0xc7, 0x66, 0xf2, 0xb8, 0x73, 0x11, 0xe7, 0x33, 0xf6, 0x83, 0x8a, 0xe7, - 0xa3, 0x22, 0x1d, 0x25, 0x49, 0x39, 0x8c, 0xf0, 0x38, 0x84, 0x9c, 0x49, 0xc1, 0x66, 0x6f, 0xde, - 0x59, 0xc1, 0xe9, 0x52, 0x16, 0xbc, 0x80, 0x2b, 0xb8, 0xa6, 0xf8, 0x04, 0x2f, 0xa8, 0x15, 0x9c, - 0x8f, 0xb4, 0xac, 0x1e, 0xd6, 0x23, 0x30, 0x6e, 0xf5, 0xd0, 0x1d, 0x72, 0xef, 0x84, 0x10, 0x3b, - 0x02, 0x36, 0x05, 0xc5, 0xf3, 0xf3, 0x74, 0x76, 0x5a, 0x24, 0x75, 0x83, 0x7e, 0x84, 0xe7, 0xd9, - 0x41, 0x88, 0x11, 0x90, 0x40, 0xb5, 0xb7, 0xbf, 0xb5, 0x0b, 0x1d, 0xdd, 0x49, 0xee, 0x95, 0x7c, - 0x7e, 0xc0, 0x66, 0xf1, 0x74, 0xa9, 0x7b, 0xf6, 0x0f, 0x43, 0x5d, 0x2a, 0xa4, 0x4d, 0x22, 0x9e, - 0x5e, 0x53, 0x4b, 0xa7, 0xe7, 0xdf, 0x56, 0x06, 0xf7, 0xbc, 0x38, 0xd1, 0xc1, 0xa4, 0x52, 0x3f, - 0xca, 0x93, 0x63, 0x56, 0x89, 0xb8, 0x14, 0xc3, 0xef, 0x05, 0x62, 0x80, 0xd0, 0x31, 0x69, 0xfb, - 0xfe, 0x57, 0xd2, 0xb5, 0xb5, 0x2e, 0x3b, 0x0e, 0xdd, 0x19, 0xfa, 0xb5, 0x2e, 0x25, 0xb0, 0x2b, - 0xbc, 0x13, 0x42, 0x6c, 0xad, 0x4b, 0xc1, 0x38, 0xbf, 0x4a, 0x05, 0xdb, 0x67, 0x39, 0x2b, 0xdb, - 0xb5, 0xae, 0x54, 0x7d, 0x84, 0xa8, 0x75, 0x02, 0xb5, 0x3b, 0x27, 0x8e, 0x37, 0x95, 0x71, 0xb0, - 0x73, 0xe2, 0x1a, 0x50, 0x00, 0xb1, 0x73, 0x82, 0x82, 0xb6, 0x47, 0xf5, 0x72, 0x65, 0xe6, 0x73, - 0x6b, 0x81, 0xc4, 0xb6, 0x66, 0x74, 0xeb, 0xfd, 0x60, 0xa2, 0x24, 0xc5, 0x7e, 0x6d, 0x24, 0x58, - 0x92, 0x0a, 0xe9, 0x55, 0x92, 0x06, 0x45, 0x4b, 0x52, 0x2d, 0x19, 0x03, 0x25, 0xa9, 0x80, 0x1e, - 0x25, 0x69, 0x40, 0x3b, 0xe3, 0x72, 0xfc, 0x9c, 0xa5, 0xec, 0x0d, 0x98, 0x71, 0xb9, 0xca, 0xb5, - 0x98, 0x98, 0x71, 0x21, 0x98, 0xf6, 0xf0, 0x62, 0xf0, 0x8b, 0x52, 0xf8, 0x03, 0x9e, 0xe6, 0xc3, - 0x9b, 0x88, 0x52, 0x2d, 0x30, 0x56, 0x6f, 0xd1, 0x00, 0x48, 0x71, 0xfd, 0x57, 0x3d, 0xfd, 0xb9, - 0x4f, 0x28, 0x81, 0x99, 0xcf, 0x83, 0x2e, 0xcc, 0xce, 0xad, 0xa5, 0xb0, 0xee, 0x95, 0x27, 0x17, - 0x71, 0x99, 0xe6, 0xb3, 0x21, 0xa6, 0xeb, 0xc8, 0x89, 0xb9, 0x35, 0xc6, 0x81, 0x70, 0xd2, 0x8a, - 0xa3, 0xa2, 0x28, 0xeb, 0xce, 0x1e, 0x0b, 0x27, 0x1f, 0x09, 0x86, 0x53, 0x0b, 0xc5, 0xbd, 0xed, - 0xb2, 0x69, 0x96, 0xe6, 0x41, 0x6f, 0x1a, 0xe9, 0xe3, 0xcd, 0xa2, 0x20, 0x78, 0x0f, 0x58, 0x7c, - 0xc5, 0x9a, 0x9c, 0x61, 0x25, 0xe3, 0x02, 0xc1, 0xe0, 0x05, 0xa0, 0xdd, 0xc8, 0x90, 0xe2, 0xc3, - 0xf8, 0x92, 0xd5, 0x05, 0xcc, 0xea, 0xa9, 0xc2, 0x10, 0xd3, 0xf7, 0x08, 0x62, 0x23, 0x03, 0x27, - 0xb5, 0xab, 0xc5, 0xe0, 0x5d, 0x29, 0x3f, 0x8a, 0x4b, 0x91, 0x4e, 0xd3, 0x22, 0xce, 0x9b, 0x05, - 0x32, 0xd6, 0x8b, 0xb4, 0x28, 0xe3, 0x72, 0xa3, 0x27, 0xad, 0xdd, 0xfe, 0xf3, 0xca, 0xe0, 0x36, - 0xf4, 0x7b, 0xc4, 0xca, 0x79, 0x2a, 0xf7, 0x59, 0x2a, 0xdd, 0xc3, 0x7e, 0x1c, 0x36, 0xda, 0x52, - 0x30, 0xa9, 0xf9, 0xe4, 0xfa, 0x8a, 0x76, 0x7e, 0x39, 0xd1, 0xcb, 0xc2, 0x97, 0x65, 0xd2, 0xda, - 0x0c, 0x9e, 0x34, 0x6b, 0x3c, 0x29, 0x24, 0xe6, 0x97, 0x2d, 0x08, 0xb4, 0xf0, 0xd3, 0xbc, 0x6a, - 0xac, 0x63, 0x2d, 0xdc, 0x8a, 0x83, 0x2d, 0xdc, 0xc3, 0xec, 0x3a, 0x4a, 0x0a, 0x55, 0xae, 0x5e, - 0xbe, 0xc9, 0x59, 0x59, 0x5d, 0xa4, 0xc5, 0x10, 0x0b, 0x72, 0xc0, 0x10, 0xeb, 0x28, 0x8a, 0xd5, - 0x0e, 0x7f, 0xbc, 0x32, 0x78, 0xcf, 0x19, 0xdd, 0x77, 0x78, 0x59, 0x2e, 0x0a, 0xc1, 0x92, 0x67, - 0xf1, 0xf4, 0x72, 0x01, 0x37, 0x19, 0xdd, 0x91, 0x1c, 0x90, 0xc4, 0x26, 0x4b, 0x58, 0xc3, 0xce, - 0x94, 0x61, 0x28, 0x55, 0xa3, 0x24, 0x39, 0x48, 0x2b, 0x01, 0x66, 0xca, 0xad, 0x40, 0x68, 0x38, - 0x62, 0xa6, 0x1c, 0xe2, 0x6d, 0x9f, 0x7a, 0xb4, 0x78, 0x9d, 0xa5, 0xd5, 0x45, 0x9a, 0xcf, 0xf4, - 0x5a, 0xd2, 0xaf, 0x2d, 0x2b, 0x86, 0xcb, 0xc9, 0xd5, 0x4e, 0x0e, 0x73, 0xa2, 0x9b, 0x27, 0xe9, - 0x04, 0x34, 0xcc, 0xd5, 0x4e, 0xce, 0x2e, 0xf1, 0xad, 0x54, 0x16, 0xde, 0x3d, 0x4a, 0xd5, 0x2b, - 0xb2, 0xfb, 0x1d, 0x94, 0x0d, 0x4d, 0x37, 0x0f, 0x15, 0xcf, 0xae, 0xd8, 0x69, 0x99, 0x82, 0xd0, - 0xf4, 0xd2, 0xd7, 0x30, 0x44, 0x68, 0x52, 0xac, 0x1d, 0x1a, 0x2c, 0xb1, 0xcf, 0xc4, 0x44, 0xc4, - 0x62, 0x51, 0x81, 0xa1, 0xc1, 0xb1, 0x61, 0x10, 0x62, 0x68, 0x20, 0x50, 0xed, 0xed, 0xf7, 0x06, - 0x03, 0xb5, 0x0f, 0x28, 0xf7, 0x6a, 0xfd, 0xd1, 0x5e, 0x6f, 0x10, 0x7a, 0x1b, 0xb5, 0xb7, 0x03, - 0x84, 0xed, 0x8a, 0xd4, 0xdf, 0x8f, 0xd9, 0x79, 0xc9, 0xaa, 0x0b, 0xd0, 0x15, 0x69, 0x1d, 0x2d, - 0x24, 0xba, 0xa2, 0x16, 0x64, 0x27, 0xe5, 0x4a, 0x24, 0xb7, 0xb7, 0x87, 0x68, 0x6a, 0xa4, 0x88, - 0x98, 0x94, 0x03, 0x04, 0x16, 0xc2, 0xe4, 0x82, 0xbf, 0xc1, 0x0b, 0xa1, 0x96, 0x84, 0x0b, 0x41, - 0x13, 0xf6, 0xd4, 0x4f, 0x27, 0x14, 0x3b, 0xf5, 0x6b, 0x92, 0x11, 0x3a, 0xf5, 0x83, 0x8c, 0x8d, - 0x47, 0xd7, 0xf0, 0x33, 0xce, 0x2f, 0xe7, 0x71, 0x79, 0x09, 0xe2, 0xd1, 0x53, 0x6e, 0x18, 0x22, - 0x1e, 0x29, 0xd6, 0xc6, 0xa3, 0xeb, 0xb0, 0x5e, 0xd2, 0x9d, 0x96, 0x19, 0x88, 0x47, 0xcf, 0x86, - 0x46, 0x88, 0x78, 0x24, 0x50, 0x3b, 0xd6, 0xb8, 0xde, 0x26, 0x0c, 0xee, 0x38, 0x7a, 0xea, 0x13, - 0x46, 0xed, 0x38, 0x22, 0x18, 0x0c, 0xa1, 0xfd, 0x32, 0x2e, 0x2e, 0xf0, 0x10, 0x92, 0xa2, 0x70, - 0x08, 0x35, 0x08, 0xac, 0xef, 0x09, 0x8b, 0xcb, 0xe9, 0x05, 0x5e, 0xdf, 0x4a, 0x16, 0xae, 0x6f, - 0xc3, 0xc0, 0xfa, 0x56, 0x82, 0x57, 0xa9, 0xb8, 0x38, 0x64, 0x22, 0xc6, 0xeb, 0xdb, 0x67, 0xc2, - 0xf5, 0xdd, 0x62, 0xed, 0x5a, 0xce, 0x75, 0x38, 0x59, 0xbc, 0xae, 0xa6, 0x65, 0xfa, 0x9a, 0x0d, - 0x03, 0x56, 0x0c, 0x44, 0xac, 0xe5, 0x48, 0x58, 0xfb, 0xfc, 0xe9, 0xca, 0xe0, 0x66, 0x53, 0xed, - 0xbc, 0xaa, 0xf4, 0x4c, 0xc6, 0x77, 0xff, 0x14, 0xaf, 0x5f, 0x02, 0x27, 0xce, 0x61, 0x7b, 0xa8, - 0x39, 0x33, 0x3d, 0x3c, 0x49, 0xa7, 0x79, 0x65, 0x12, 0xf5, 0x71, 0x1f, 0xeb, 0x8e, 0x02, 0x31, - 0xd3, 0xeb, 0xa5, 0x68, 0x27, 0xd9, 0xba, 0x7e, 0x1a, 0xd9, 0x38, 0xa9, 0xc0, 0x24, 0xbb, 0x29, - 0x6f, 0x87, 0x20, 0x26, 0xd9, 0x38, 0x09, 0x43, 0x61, 0xbf, 0xe4, 0x8b, 0xa2, 0xea, 0x08, 0x05, - 0x00, 0x85, 0x43, 0xa1, 0x0d, 0x6b, 0x9f, 0x6f, 0x07, 0xbf, 0xe1, 0x86, 0x9f, 0x5b, 0xd8, 0x1b, - 0x74, 0x4c, 0x61, 0x45, 0x1c, 0xf5, 0xc5, 0xed, 0x6c, 0xa5, 0xf1, 0x2c, 0x76, 0x99, 0x88, 0xd3, - 0xac, 0x1a, 0x3e, 0xc0, 0x6d, 0x34, 0x72, 0x62, 0xb6, 0x82, 0x71, 0xb0, 0x7f, 0xdb, 0x5d, 0x14, - 0x59, 0x3a, 0x6d, 0x1f, 0xc0, 0x6a, 0x5d, 0x23, 0x0e, 0xf7, 0x6f, 0x2e, 0x06, 0xfb, 0xeb, 0x7a, - 0x22, 0x2f, 0xff, 0x73, 0xb2, 0x2c, 0x18, 0xde, 0x5f, 0x7b, 0x48, 0xb8, 0xbf, 0x86, 0x28, 0xcc, - 0xcf, 0x84, 0x89, 0x83, 0x78, 0xc9, 0x17, 0x44, 0x7f, 0x6d, 0xc4, 0xe1, 0xfc, 0xb8, 0x98, 0x5d, - 0xe9, 0x19, 0x0f, 0xe3, 0x5c, 0xb0, 0x32, 0x8f, 0xb3, 0xbd, 0x2c, 0x9e, 0x55, 0x43, 0xa2, 0x8f, - 0xf1, 0x29, 0x62, 0xa5, 0x47, 0xd3, 0x48, 0x31, 0x8e, 0xab, 0xbd, 0xf8, 0x8a, 0x97, 0xa9, 0xa0, - 0x8b, 0xd1, 0x22, 0x9d, 0xc5, 0xe8, 0xa1, 0xa8, 0xb7, 0x51, 0x39, 0xbd, 0x48, 0xaf, 0x58, 0x12, - 0xf0, 0xd6, 0x20, 0x3d, 0xbc, 0x39, 0x28, 0x52, 0x69, 0x13, 0xbe, 0x28, 0xa7, 0x8c, 0xac, 0x34, - 0x25, 0xee, 0xac, 0x34, 0x83, 0xc1, 0xfc, 0xd4, 0x93, 0x69, 0x1b, 0xea, 0x68, 0x7e, 0x3c, 0x24, - 0x9c, 0x1f, 0x88, 0xc2, 0x96, 0x2b, 0xe5, 0x6a, 0xbf, 0xf6, 0x01, 0xa9, 0xef, 0x6f, 0xda, 0xae, - 0x76, 0x72, 0xb0, 0x63, 0xaa, 0x85, 0x7e, 0x35, 0x6d, 0x50, 0x36, 0xf0, 0xaa, 0x8a, 0xfa, 0xe2, - 0x76, 0xa1, 0xd8, 0x4c, 0x7a, 0x59, 0x9c, 0x2f, 0x8a, 0xc9, 0x62, 0x36, 0x63, 0x95, 0x48, 0x79, - 0x5e, 0x0d, 0x23, 0x7c, 0x7a, 0x0b, 0x39, 0x62, 0xa1, 0x18, 0xe2, 0x9d, 0xc3, 0x25, 0xc2, 0xfb, - 0x78, 0x96, 0xf3, 0x12, 0x5e, 0xd7, 0xa2, 0x4c, 0x2a, 0x98, 0x38, 0x5c, 0xea, 0x54, 0x22, 0xeb, - 0xc0, 0x34, 0xcc, 0x70, 0x1d, 0xb4, 0x1a, 0x67, 0xd4, 0x17, 0x27, 0x3c, 0x3b, 0x3d, 0x6b, 0xc8, - 0x33, 0xd2, 0xbb, 0x46, 0x7d, 0x71, 0x38, 0x01, 0xd4, 0x4c, 0x33, 0x34, 0x3d, 0x0e, 0xd8, 0x81, - 0xc3, 0xd3, 0x5a, 0x2f, 0x56, 0x3b, 0xfc, 0xab, 0x95, 0xc1, 0x77, 0xad, 0xc7, 0x43, 0x9e, 0xa4, - 0xe7, 0x4b, 0x05, 0x9d, 0xc5, 0xd9, 0x82, 0x55, 0xc3, 0x6d, 0xca, 0x5a, 0x9b, 0x35, 0x29, 0x78, - 0x72, 0x2d, 0x1d, 0xd8, 0x8b, 0x8c, 0x8a, 0x22, 0x5b, 0x9e, 0xb0, 0x79, 0x91, 0x91, 0xbd, 0x88, - 0x87, 0x84, 0x7b, 0x11, 0x88, 0xc2, 0x85, 0xc1, 0x09, 0xaf, 0x97, 0x1d, 0xe8, 0xc2, 0x40, 0x8a, - 0xc2, 0x0b, 0x83, 0x06, 0x81, 0xd3, 0xb5, 0x13, 0xbe, 0xc3, 0xb3, 0x8c, 0x4d, 0x45, 0xfb, 0x72, - 0x97, 0xd1, 0xb4, 0x44, 0x78, 0xba, 0x06, 0x48, 0xbb, 0xcd, 0xdb, 0x2c, 0x63, 0xe3, 0x92, 0x3d, - 0x5b, 0x1e, 0xa4, 0xf9, 0xe5, 0x10, 0x9f, 0x99, 0x58, 0x80, 0xd8, 0xe6, 0x45, 0x41, 0xb8, 0x5c, - 0x3e, 0xcd, 0x13, 0x8e, 0x2f, 0x97, 0x6b, 0x49, 0x78, 0xb9, 0xac, 0x09, 0x68, 0xf2, 0x98, 0x51, - 0x26, 0x6b, 0x49, 0xd8, 0xa4, 0x26, 0xb0, 0x41, 0x41, 0x1f, 0x71, 0x92, 0x83, 0x02, 0x38, 0xd4, - 0x5c, 0xed, 0xe4, 0xe0, 0xb2, 0x4f, 0x3b, 0x40, 0x23, 0x02, 0x18, 0xbf, 0x1b, 0x64, 0x60, 0xe8, - 0x37, 0x0b, 0xf2, 0x3d, 0x26, 0xa6, 0x17, 0x78, 0xe8, 0x7b, 0x48, 0x38, 0xf4, 0x21, 0x0a, 0xb3, - 0x31, 0x9e, 0xd3, 0xd9, 0x50, 0xb2, 0x70, 0x36, 0x0c, 0x03, 0x2b, 0x41, 0x09, 0xe4, 0xf6, 0xdc, - 0x03, 0x5a, 0xd1, 0xdb, 0xa0, 0x5b, 0xed, 0xe4, 0xb4, 0x93, 0x7f, 0x34, 0xab, 0x47, 0x25, 0x7d, - 0xc1, 0xeb, 0x76, 0x71, 0x16, 0x67, 0x69, 0x12, 0x0b, 0x76, 0xc2, 0x2f, 0x59, 0x8e, 0x2f, 0xd4, - 0x74, 0x6a, 0x15, 0x1f, 0x79, 0x0a, 0xe1, 0x85, 0x5a, 0x58, 0x11, 0x56, 0xa1, 0xa2, 0x4f, 0x2b, - 0xb6, 0x13, 0x57, 0x44, 0xef, 0xe5, 0x21, 0xe1, 0x2a, 0x84, 0x28, 0x9c, 0x26, 0x2b, 0xf9, 0xf3, - 0xb7, 0x05, 0x2b, 0x53, 0x96, 0x4f, 0x19, 0x3e, 0x4d, 0x86, 0x54, 0x78, 0x9a, 0x8c, 0xd0, 0x70, - 0x89, 0xb8, 0x1b, 0x0b, 0xf6, 0x6c, 0x79, 0x92, 0xce, 0x59, 0x25, 0xe2, 0x79, 0x81, 0x2f, 0x11, - 0x01, 0x14, 0x5e, 0x22, 0xb6, 0xe1, 0xd6, 0x8e, 0x94, 0xe9, 0x04, 0xdb, 0xf7, 0x40, 0x21, 0x11, - 0xb8, 0x07, 0x4a, 0xa0, 0xb0, 0x60, 0x2d, 0x80, 0x9e, 0x34, 0xb5, 0xac, 0x04, 0x4f, 0x9a, 0x68, - 0xba, 0xb5, 0xcf, 0x67, 0x98, 0x49, 0xdd, 0x34, 0x3b, 0x92, 0x3e, 0x71, 0x9b, 0xe8, 0x5a, 0x2f, - 0x16, 0xdf, 0x58, 0x3c, 0x66, 0x59, 0x2c, 0x87, 0xaa, 0xc0, 0xee, 0x5d, 0xc3, 0xf4, 0xd9, 0x58, - 0x74, 0x58, 0xe7, 0x0c, 0x06, 0xf3, 0xf8, 0xb2, 0x90, 0x7e, 0xb7, 0xba, 0x6d, 0x29, 0x92, 0x38, - 0x83, 0x09, 0x6b, 0xd8, 0xa9, 0x75, 0x23, 0xb2, 0xf7, 0x60, 0x75, 0x02, 0xfc, 0x89, 0x9a, 0x49, - 0x3f, 0xe4, 0x88, 0xa9, 0x75, 0x88, 0xb7, 0xcb, 0x30, 0x3f, 0x5d, 0x15, 0x58, 0x86, 0x19, 0x1b, - 0x5a, 0x4c, 0x2c, 0xc3, 0x10, 0xcc, 0xde, 0x61, 0xf6, 0x3d, 0x98, 0xe3, 0xc1, 0x8d, 0x90, 0x85, - 0xf6, 0x41, 0x61, 0xd4, 0x17, 0xb7, 0xdd, 0x82, 0x5b, 0xae, 0xaf, 0x52, 0x71, 0x21, 0x27, 0x77, - 0xa0, 0x5b, 0xf0, 0x0a, 0xc9, 0x40, 0x44, 0xb7, 0x40, 0xc2, 0x70, 0xfa, 0xd3, 0x80, 0x75, 0xa7, - 0x80, 0x0d, 0x22, 0xc6, 0x90, 0xdb, 0x25, 0x3c, 0xec, 0x06, 0x61, 0x43, 0x69, 0xc4, 0x7a, 0xc5, - 0xf9, 0x38, 0x64, 0x01, 0xac, 0x3a, 0xd7, 0x7a, 0xb1, 0xda, 0xe1, 0x9f, 0x0e, 0xbe, 0xd3, 0xca, - 0xd8, 0x1e, 0x8b, 0xc5, 0xa2, 0x64, 0xc9, 0x70, 0xb3, 0x23, 0xdd, 0x0d, 0x48, 0x7c, 0x90, 0x11, - 0x54, 0x68, 0x2d, 0x08, 0x1a, 0x4e, 0xc5, 0xb3, 0x49, 0xc3, 0x76, 0xc8, 0xa4, 0xcf, 0x06, 0x17, - 0x04, 0xb4, 0x8e, 0x4e, 0xc9, 0x5f, 0xac, 0x0c, 0x7e, 0xd3, 0x47, 0xe5, 0xed, 0xf9, 0xab, 0x38, - 0xcd, 0xe4, 0x55, 0x83, 0x0f, 0x42, 0x46, 0x3d, 0xd4, 0xa4, 0x63, 0xfb, 0x3a, 0x2a, 0xad, 0x21, - 0x41, 0x76, 0x2e, 0xce, 0x5a, 0x70, 0x9d, 0xee, 0x82, 0x90, 0xa5, 0xe0, 0x46, 0x4f, 0x5a, 0xbb, - 0x15, 0xcd, 0x58, 0x5b, 0xff, 0xd9, 0x0d, 0x72, 0xcc, 0xab, 0x56, 0x45, 0x22, 0x7d, 0xa3, 0x27, - 0x6d, 0xbf, 0x06, 0x6a, 0x7b, 0xd5, 0x23, 0xe0, 0x66, 0xa7, 0x29, 0x30, 0x08, 0x6e, 0xf5, 0x57, - 0xd0, 0xee, 0xff, 0xc5, 0xec, 0xc3, 0x2b, 0xff, 0x53, 0x3e, 0x9f, 0xb3, 0x3c, 0x61, 0x49, 0xa3, - 0x51, 0xd5, 0x8b, 0xb5, 0x4f, 0x68, 0xbb, 0x46, 0x21, 0x72, 0x35, 0x4c, 0x8a, 0x7e, 0xeb, 0x2b, - 0x68, 0xea, 0xa4, 0xfd, 0xe7, 0xca, 0xe0, 0x11, 0x9a, 0xb4, 0x26, 0x70, 0xbd, 0x24, 0xfe, 0x6e, - 0x1f, 0x47, 0x98, 0xa6, 0x49, 0xea, 0xe8, 0xff, 0x61, 0x41, 0x27, 0xf9, 0x5f, 0x57, 0x06, 0x77, - 0xac, 0x62, 0x1d, 0xde, 0x3b, 0x3c, 0x3f, 0xcf, 0xd2, 0xa9, 0x90, 0xa7, 0xdb, 0x5a, 0x85, 0x2e, - 0x4e, 0x4a, 0xa3, 0xbb, 0x38, 0x03, 0x9a, 0x3a, 0x6d, 0xff, 0xb0, 0x32, 0xb8, 0xe5, 0x16, 0xa7, - 0x3c, 0x1a, 0x57, 0xbb, 0xc1, 0x8d, 0x62, 0x35, 0xfc, 0x88, 0x2e, 0x03, 0x8c, 0x37, 0xe9, 0xfa, - 0xf8, 0xda, 0x7a, 0xad, 0xf5, 0xfb, 0xb2, 0xb0, 0xb7, 0x6b, 0x1e, 0x52, 0xe6, 0x5a, 0x23, 0xe7, - 0xa3, 0x1e, 0xa4, 0x75, 0xf5, 0x69, 0x5a, 0x09, 0x5e, 0x2e, 0x27, 0x17, 0xfc, 0x4d, 0xf3, 0x21, - 0xad, 0xef, 0x4a, 0x03, 0x91, 0x43, 0x10, 0xae, 0x70, 0xb2, 0xe5, 0xca, 0x7e, 0x70, 0x5b, 0x11, - 0xae, 0x1c, 0xa2, 0xc3, 0x95, 0x4f, 0xda, 0x61, 0xb9, 0xc9, 0x95, 0xfd, 0x3a, 0x78, 0x15, 0x4f, - 0x6a, 0xfb, 0x0b, 0xe1, 0x87, 0xdd, 0xa0, 0x5d, 0x15, 0x68, 0xf1, 0x6e, 0x7a, 0x7e, 0x6e, 0xf2, - 0x84, 0xa7, 0xd4, 0x45, 0x88, 0x55, 0x01, 0x81, 0xda, 0x85, 0xed, 0x5e, 0x9a, 0x31, 0x79, 0x58, - 0xf7, 0xf2, 0xfc, 0x3c, 0xe3, 0x71, 0x02, 0x16, 0xb6, 0xb5, 0x38, 0x72, 0xe5, 0xc4, 0xc2, 0x16, - 0xe3, 0xec, 0x4d, 0x8a, 0x5a, 0x5a, 0x37, 0xef, 0x7c, 0x9a, 0x66, 0xf0, 0x8b, 0x0c, 0xa9, 0x69, - 0x84, 0xc4, 0x4d, 0x8a, 0x16, 0x64, 0x27, 0x9f, 0xb5, 0xa8, 0x6e, 0x96, 0x4d, 0xfa, 0xef, 0xb7, - 0x15, 0x1d, 0x31, 0x31, 0xf9, 0x44, 0x30, 0xbb, 0xa7, 0x53, 0x0b, 0x4f, 0x0b, 0x69, 0xfc, 0x56, - 0x5b, 0x4b, 0x49, 0x88, 0x3d, 0x1d, 0x9f, 0xb0, 0xfb, 0x14, 0xf5, 0xdf, 0x77, 0xf9, 0x9b, 0x5c, - 0x1a, 0xbd, 0xd3, 0x56, 0x69, 0x64, 0xc4, 0x3e, 0x05, 0x64, 0x6c, 0x7b, 0x90, 0x86, 0xd3, 0x6a, - 0x1a, 0x97, 0x89, 0xfe, 0x80, 0x04, 0xb4, 0x07, 0xa5, 0xea, 0x11, 0x44, 0x7b, 0xc0, 0x49, 0xed, - 0xea, 0xb3, 0xc1, 0x2f, 0x48, 0x57, 0x25, 0x2f, 0x86, 0x37, 0x10, 0xb5, 0xd2, 0xf9, 0x3a, 0xe1, - 0x26, 0x29, 0xb7, 0x97, 0x9f, 0x4c, 0x18, 0x9e, 0x56, 0xf1, 0x0c, 0x7e, 0xdf, 0x64, 0x83, 0x4b, - 0x4a, 0x89, 0xcb, 0x4f, 0x6d, 0xca, 0x0f, 0xc0, 0x17, 0x3c, 0xd1, 0xd6, 0x91, 0xc2, 0x34, 0xc2, - 0x50, 0x00, 0xba, 0x90, 0x6d, 0xaf, 0x32, 0xe9, 0x4c, 0x8c, 0x16, 0x82, 0x9b, 0x2a, 0x45, 0x4a, - 0x12, 0x20, 0x44, 0x7b, 0x25, 0x50, 0xdb, 0x0b, 0xd5, 0xc0, 0x4e, 0x3c, 0xbd, 0xb0, 0xe1, 0x83, - 0x34, 0x44, 0x0f, 0x20, 0x7a, 0x21, 0x14, 0xb4, 0xe7, 0x04, 0xc6, 0x8f, 0xba, 0xc7, 0x6c, 0xbc, - 0x6d, 0x10, 0x46, 0x7c, 0x8c, 0x58, 0x72, 0x05, 0x70, 0xbb, 0x94, 0xad, 0x21, 0x37, 0xfb, 0x13, - 0x26, 0x0e, 0xd2, 0x79, 0x0a, 0xaf, 0x13, 0x4a, 0x5b, 0x18, 0x47, 0x2c, 0x65, 0x43, 0xbc, 0x5d, - 0xef, 0xbd, 0x88, 0xaf, 0xd2, 0x99, 0x99, 0x93, 0xab, 0x81, 0xae, 0x02, 0xeb, 0x3d, 0xcb, 0x44, - 0x0e, 0x44, 0xac, 0xf7, 0x48, 0xd8, 0x99, 0x2f, 0x58, 0x66, 0xbf, 0x39, 0x3d, 0x19, 0xe7, 0xe7, - 0xbc, 0x5e, 0x1d, 0x1e, 0xa4, 0xf9, 0x25, 0x9c, 0x2f, 0x38, 0x26, 0x71, 0x9e, 0x98, 0x2f, 0xf4, - 0xd1, 0xb3, 0xd5, 0xd0, 0x1c, 0x2d, 0xd8, 0x2b, 0x4e, 0x4a, 0x03, 0x54, 0x83, 0x39, 0x81, 0x80, - 0x1c, 0x51, 0x0d, 0x21, 0xde, 0xb6, 0x57, 0xe3, 0x3c, 0xe3, 0x39, 0x6c, 0xaf, 0xd6, 0x42, 0x2d, - 0x24, 0xda, 0x6b, 0x0b, 0xb2, 0x2d, 0xa8, 0x11, 0xa9, 0xcd, 0xea, 0x51, 0x96, 0x81, 0x16, 0x64, - 0x54, 0x0d, 0x40, 0xb4, 0x20, 0x14, 0xb4, 0x2d, 0xa8, 0x11, 0x4f, 0x98, 0x38, 0xca, 0xe2, 0x29, - 0xbb, 0xe0, 0x59, 0xc2, 0xca, 0x0a, 0xb4, 0x20, 0x63, 0x04, 0x60, 0x44, 0x0b, 0x0a, 0xe0, 0x6d, - 0xcf, 0xfb, 0xfd, 0x3c, 0xef, 0x5f, 0xcf, 0xf3, 0x3e, 0xe5, 0xf9, 0xc7, 0x2b, 0x83, 0xf7, 0x1a, - 0x4a, 0xad, 0xfe, 0x3d, 0xef, 0x5b, 0xb8, 0xb9, 0x36, 0x49, 0x6c, 0x85, 0x85, 0x35, 0x74, 0x1a, - 0x8e, 0x07, 0xdf, 0xa8, 0x43, 0xf9, 0xa8, 0x64, 0x57, 0x29, 0x83, 0xb7, 0x20, 0x1d, 0x09, 0x31, - 0x5e, 0xfb, 0x84, 0x1d, 0x9e, 0x4e, 0xf3, 0xaa, 0xc8, 0xe2, 0xea, 0x42, 0xdf, 0x8b, 0xf3, 0x63, - 0xad, 0x11, 0xc2, 0x9b, 0x71, 0xf7, 0x3b, 0x28, 0x3b, 0x09, 0x6b, 0x64, 0xa6, 0x97, 0x7d, 0x80, - 0xab, 0xb6, 0xba, 0xd7, 0xd5, 0x4e, 0xce, 0x46, 0xc5, 0x7e, 0x9c, 0x65, 0xac, 0x5c, 0x36, 0xb2, - 0xc3, 0x38, 0x4f, 0xcf, 0x59, 0x25, 0x40, 0x54, 0x68, 0x2a, 0x82, 0x18, 0x11, 0x15, 0x01, 0xdc, - 0x6e, 0x34, 0x01, 0xcf, 0xe3, 0x3c, 0x61, 0x6f, 0xc1, 0x46, 0x13, 0xb4, 0x23, 0x19, 0x62, 0xa3, - 0x89, 0x62, 0xed, 0x09, 0xe8, 0xb3, 0x8c, 0x4f, 0x2f, 0xf5, 0x94, 0xcd, 0xaf, 0x60, 0x29, 0x81, - 0x73, 0xb6, 0x3b, 0x21, 0xc4, 0x4e, 0xda, 0xa4, 0xe0, 0x98, 0x15, 0x75, 0xe0, 0x0d, 0x31, 0x1d, - 0x2d, 0x23, 0x26, 0x6d, 0x90, 0x01, 0xc9, 0xd5, 0x57, 0x6c, 0xb1, 0xe4, 0x82, 0x1b, 0xb6, 0x77, - 0x42, 0x88, 0x9d, 0xb6, 0x4a, 0xc1, 0xa4, 0xc8, 0x52, 0x01, 0x9a, 0x81, 0xd2, 0x90, 0x12, 0xa2, - 0x19, 0xf8, 0x04, 0x30, 0x79, 0xc8, 0xca, 0x19, 0x43, 0x4d, 0x4a, 0x49, 0xd0, 0x64, 0x43, 0xd8, - 0xaf, 0xb8, 0x54, 0xde, 0x79, 0xb1, 0x04, 0x5f, 0x71, 0xe9, 0x6c, 0xf1, 0x62, 0x49, 0x7c, 0xc5, - 0xe5, 0x01, 0x20, 0x89, 0x47, 0x71, 0x25, 0xf0, 0x24, 0x4a, 0x49, 0x30, 0x89, 0x0d, 0x61, 0x27, - 0xba, 0x2a, 0x89, 0x0b, 0x01, 0x26, 0xba, 0x3a, 0x01, 0xce, 0x65, 0xb0, 0x9b, 0xa4, 0xdc, 0xf6, - 0x24, 0xaa, 0x56, 0x98, 0xd8, 0x4b, 0x59, 0x96, 0x54, 0xa0, 0x27, 0xd1, 0xe5, 0xde, 0x48, 0x89, - 0x9e, 0xa4, 0x4d, 0x81, 0x50, 0xd2, 0xc7, 0xb8, 0x58, 0xee, 0xc0, 0x29, 0xee, 0x9d, 0x10, 0x62, - 0xfb, 0xa7, 0x26, 0xd1, 0x3b, 0x71, 0x59, 0xa6, 0xf5, 0x0c, 0xfa, 0x01, 0x9e, 0xa0, 0x46, 0x4e, - 0xf4, 0x4f, 0x18, 0x07, 0x9a, 0x57, 0xd3, 0x71, 0x63, 0x09, 0x83, 0x5d, 0xf7, 0xdd, 0x20, 0x63, - 0x57, 0x88, 0x52, 0xe2, 0x5c, 0xaa, 0xc2, 0x4a, 0x13, 0xb9, 0x53, 0xf5, 0xa0, 0x0b, 0x73, 0xee, - 0x16, 0x19, 0x17, 0x87, 0xfc, 0x8a, 0x9d, 0xf0, 0xe7, 0x6f, 0xd3, 0x4a, 0xa4, 0xf9, 0x4c, 0xcf, - 0x98, 0x9e, 0x10, 0x96, 0x30, 0x98, 0xb8, 0x5b, 0xd4, 0xa9, 0x64, 0x27, 0x6e, 0x20, 0x2d, 0x2f, - 0xd8, 0x1b, 0x74, 0xe2, 0x06, 0x2d, 0x1a, 0x8e, 0x98, 0xb8, 0x85, 0x78, 0xbb, 0xc5, 0x6f, 0x9c, - 0xeb, 0x07, 0xb3, 0x4e, 0x78, 0x33, 0x87, 0xa6, 0xac, 0x41, 0x90, 0xd8, 0x65, 0x0d, 0x2a, 0xd8, - 0xf5, 0xaf, 0xf1, 0x6f, 0x9b, 0xd8, 0x43, 0xc2, 0x4e, 0xbb, 0x99, 0x3d, 0xea, 0x41, 0x22, 0xae, - 0xec, 0xcd, 0x40, 0xca, 0x55, 0xfb, 0x62, 0xe0, 0xa3, 0x1e, 0xa4, 0x73, 0x5c, 0xe0, 0x66, 0xeb, - 0x59, 0x3c, 0xbd, 0x9c, 0x95, 0x7c, 0x91, 0x27, 0x3b, 0x3c, 0xe3, 0x25, 0x38, 0x2e, 0xf0, 0x52, - 0x0d, 0x50, 0xe2, 0xb8, 0xa0, 0x43, 0xc5, 0xce, 0x9c, 0xdd, 0x54, 0x8c, 0xb2, 0x74, 0x06, 0x77, - 0xc0, 0x3c, 0x43, 0x12, 0x20, 0x66, 0xce, 0x28, 0x88, 0x04, 0x91, 0xda, 0x21, 0x13, 0xe9, 0x34, - 0xce, 0x94, 0xbf, 0x4d, 0xda, 0x8c, 0x07, 0x76, 0x06, 0x11, 0xa2, 0x80, 0xe4, 0xf3, 0x64, 0x51, - 0xe6, 0xe3, 0x5c, 0x70, 0x32, 0x9f, 0x0d, 0xd0, 0x99, 0x4f, 0x07, 0x04, 0xdd, 0xea, 0x09, 0x7b, - 0x5b, 0xa7, 0xa6, 0xfe, 0x07, 0xeb, 0x56, 0xeb, 0xbf, 0x47, 0x5a, 0x1e, 0xea, 0x56, 0x01, 0x07, - 0x32, 0xa3, 0x9d, 0xa8, 0x80, 0x09, 0x68, 0xfb, 0x61, 0xf2, 0xb0, 0x1b, 0xc4, 0xfd, 0x4c, 0xc4, - 0x32, 0x63, 0x21, 0x3f, 0x12, 0xe8, 0xe3, 0xa7, 0x01, 0xed, 0x76, 0x8b, 0x97, 0x9f, 0x0b, 0x36, - 0xbd, 0x6c, 0xdd, 0x30, 0xf6, 0x13, 0xaa, 0x10, 0x62, 0xbb, 0x85, 0x40, 0xf1, 0x2a, 0x1a, 0x4f, - 0x79, 0x1e, 0xaa, 0xa2, 0x5a, 0xde, 0xa7, 0x8a, 0x34, 0x67, 0x37, 0x1d, 0x8c, 0x54, 0x47, 0xa6, - 0xaa, 0xa6, 0x35, 0xc2, 0x82, 0x0b, 0x11, 0x9b, 0x0e, 0x24, 0x6c, 0xe7, 0xe4, 0xd0, 0xe7, 0x61, - 0xfb, 0xf3, 0xab, 0x96, 0x95, 0x43, 0xfa, 0xf3, 0x2b, 0x8a, 0xa5, 0x33, 0xa9, 0x62, 0xa4, 0xc3, - 0x8a, 0x1f, 0x27, 0xeb, 0xfd, 0x60, 0xbb, 0xe4, 0xf1, 0x7c, 0xee, 0x64, 0x2c, 0x2e, 0x95, 0xd7, - 0x8d, 0x80, 0x21, 0x8b, 0x11, 0x4b, 0x9e, 0x00, 0x0e, 0xba, 0x30, 0xcf, 0xf3, 0x0e, 0xcf, 0x05, - 0xcb, 0x05, 0xd6, 0x85, 0xf9, 0xc6, 0x34, 0x18, 0xea, 0xc2, 0x28, 0x05, 0x10, 0xb7, 0x7a, 0x67, - 0xf2, 0x45, 0x3c, 0x47, 0x67, 0x6c, 0xcd, 0x5e, 0x63, 0x2d, 0x0f, 0xc5, 0x2d, 0xe0, 0x9c, 0xd5, - 0xbe, 0xeb, 0xe5, 0x24, 0x2e, 0x67, 0x66, 0x57, 0x29, 0x19, 0x6e, 0xd1, 0x76, 0x7c, 0x92, 0x58, - 0xed, 0x87, 0x35, 0x40, 0xb7, 0x33, 0x9e, 0xc7, 0x33, 0x93, 0x53, 0x24, 0x07, 0x52, 0xde, 0xca, - 0xea, 0xc3, 0x6e, 0x10, 0xf8, 0x39, 0x4b, 0x13, 0xc6, 0x03, 0x7e, 0xa4, 0xbc, 0x8f, 0x1f, 0x08, - 0x82, 0xd9, 0x9b, 0xdc, 0x7c, 0x55, 0x4f, 0x5a, 0xe6, 0x89, 0x5e, 0xc7, 0x46, 0x44, 0xf1, 0x00, - 0x2e, 0x34, 0x7b, 0x23, 0x78, 0xd0, 0x46, 0x9b, 0x03, 0x95, 0x50, 0x1b, 0x35, 0xe7, 0x25, 0x7d, - 0xda, 0x28, 0x06, 0x6b, 0x9f, 0x3f, 0xd2, 0x6d, 0x74, 0x37, 0x16, 0x71, 0x3d, 0x6f, 0x3f, 0x4b, - 0xd9, 0x1b, 0xbd, 0x10, 0x46, 0xf2, 0xdb, 0x50, 0x91, 0x7c, 0x0b, 0x04, 0xac, 0x8a, 0x37, 0x7b, - 0xf3, 0x01, 0xdf, 0x7a, 0x85, 0xd0, 0xe9, 0x1b, 0x2c, 0x15, 0x36, 0x7b, 0xf3, 0x01, 0xdf, 0xfa, - 0xe9, 0xa4, 0x4e, 0xdf, 0xe0, 0xfd, 0xa4, 0xcd, 0xde, 0xbc, 0xf6, 0xfd, 0xe7, 0x4d, 0xc3, 0x75, - 0x9d, 0xd7, 0xf3, 0xb0, 0xa9, 0x48, 0xaf, 0x18, 0x36, 0x9d, 0xf4, 0xed, 0x19, 0x34, 0x34, 0x9d, - 0xa4, 0x55, 0x9c, 0xf7, 0x73, 0xb1, 0x54, 0x1c, 0xf1, 0x2a, 0x95, 0x17, 0xd7, 0x9e, 0xf4, 0x30, - 0xda, 0xc0, 0xa1, 0x45, 0x53, 0x48, 0xc9, 0xde, 0x84, 0xf1, 0x50, 0xfb, 0x41, 0xd1, 0x7a, 0xc0, - 0x5e, 0xfb, 0xbb, 0xa2, 0x8d, 0x9e, 0xb4, 0xbd, 0x93, 0xe2, 0x31, 0xcd, 0x6d, 0x82, 0x09, 0x43, - 0x47, 0x09, 0x63, 0xca, 0xdc, 0x32, 0x71, 0xaf, 0x55, 0x6c, 0xf5, 0x57, 0xe8, 0x70, 0x3f, 0x4a, - 0x92, 0x7e, 0xee, 0xdd, 0xeb, 0x38, 0x5b, 0xfd, 0x15, 0xb4, 0xfb, 0xbf, 0x6c, 0x96, 0x35, 0xd0, - 0xbf, 0x6e, 0x83, 0xdb, 0x7d, 0x2c, 0x82, 0x76, 0xf8, 0xe4, 0x5a, 0x3a, 0x3a, 0x21, 0x7f, 0xd3, - 0xac, 0xdf, 0x1b, 0x54, 0x7e, 0xd5, 0x29, 0x6f, 0x35, 0xe8, 0x26, 0x19, 0x8a, 0x2a, 0x0b, 0xc3, - 0x86, 0xf9, 0xf4, 0x9a, 0x5a, 0xce, 0x63, 0xce, 0x1e, 0xac, 0x5f, 0x36, 0x70, 0xd2, 0x13, 0xb2, - 0xec, 0xd0, 0x30, 0x41, 0x1f, 0x5d, 0x57, 0x8d, 0x6a, 0xaa, 0x0e, 0x2c, 0xdf, 0x92, 0x7b, 0xd2, - 0xd3, 0xb0, 0xf7, 0xba, 0xdc, 0x87, 0xd7, 0x53, 0xd2, 0x69, 0xf9, 0xf7, 0x95, 0xc1, 0x7d, 0x8f, - 0xb5, 0xc7, 0x48, 0x60, 0xd3, 0xe5, 0xfb, 0x01, 0xfb, 0x94, 0x92, 0x49, 0xdc, 0x6f, 0x7f, 0x35, - 0x65, 0x7b, 0x61, 0xd5, 0x53, 0xd9, 0x4b, 0x33, 0xc1, 0xca, 0xf6, 0xa3, 0xbb, 0xbe, 0x5d, 0x45, - 0x45, 0xf4, 0xa3, 0xbb, 0x01, 0xdc, 0x79, 0x74, 0x17, 0xf1, 0x8c, 0x3e, 0xba, 0x8b, 0x5a, 0x0b, - 0x3e, 0xba, 0x1b, 0xd6, 0xa0, 0x46, 0x97, 0x26, 0x09, 0x6a, 0xdb, 0xbc, 0x97, 0x45, 0x7f, 0x17, - 0x7d, 0xfb, 0x3a, 0x2a, 0xc4, 0xf8, 0xaa, 0x38, 0x79, 0xf5, 0xbc, 0x47, 0x99, 0x7a, 0xd7, 0xcf, - 0x37, 0x7b, 0xf3, 0xda, 0xf7, 0x0f, 0xf5, 0xe2, 0xca, 0x8c, 0x26, 0xbc, 0x94, 0x0f, 0x2e, 0xaf, - 0x85, 0x46, 0x87, 0xda, 0x82, 0x5b, 0xf3, 0xeb, 0xfd, 0x60, 0x22, 0xbb, 0x35, 0xa1, 0x2b, 0x3d, - 0xea, 0x32, 0x04, 0xaa, 0x7c, 0xb3, 0x37, 0x4f, 0x0c, 0x23, 0xca, 0xb7, 0xaa, 0xed, 0x1e, 0xc6, - 0xfc, 0xba, 0xde, 0xea, 0xaf, 0xa0, 0xdd, 0x5f, 0xe9, 0x59, 0xab, 0xeb, 0x5e, 0xd6, 0xf3, 0x46, - 0x97, 0xa9, 0x89, 0x57, 0xcd, 0x51, 0x5f, 0x3c, 0x34, 0x7f, 0x71, 0x87, 0xd0, 0xae, 0xf9, 0x0b, - 0x3a, 0x8c, 0x7e, 0x78, 0x3d, 0x25, 0x9d, 0x96, 0xbf, 0x5f, 0x19, 0xdc, 0x24, 0xd3, 0xa2, 0xe3, - 0xe0, 0xa3, 0xbe, 0x96, 0x41, 0x3c, 0x7c, 0x7c, 0x6d, 0x3d, 0x9d, 0xa8, 0x7f, 0x5a, 0x19, 0xdc, - 0x0a, 0x24, 0x4a, 0x05, 0xc8, 0x35, 0xac, 0xfb, 0x81, 0xf2, 0xc9, 0xf5, 0x15, 0xa9, 0xe1, 0xde, - 0xc5, 0x27, 0xed, 0x27, 0x44, 0x03, 0xb6, 0x27, 0xf4, 0x13, 0xa2, 0xdd, 0x5a, 0x70, 0x8f, 0x29, - 0x7e, 0xdd, 0xac, 0xf9, 0xd0, 0x3d, 0x26, 0x79, 0x77, 0x3b, 0xf8, 0x84, 0x15, 0xc6, 0x61, 0x4e, - 0x9e, 0xbf, 0x2d, 0xe2, 0x3c, 0xa1, 0x9d, 0x28, 0x79, 0xb7, 0x13, 0xc3, 0xc1, 0xbd, 0xb9, 0x5a, - 0x7a, 0xcc, 0x9b, 0x75, 0xdc, 0x23, 0x4a, 0xdf, 0x20, 0xc1, 0xbd, 0xb9, 0x16, 0x4a, 0x78, 0xd3, - 0xb3, 0xc6, 0x90, 0x37, 0x30, 0x59, 0x7c, 0xdc, 0x07, 0x05, 0x2b, 0x04, 0xe3, 0xcd, 0x6c, 0xf9, - 0xaf, 0x87, 0xac, 0xb4, 0xb6, 0xfd, 0x37, 0x7a, 0xd2, 0x84, 0xdb, 0x09, 0x13, 0x9f, 0xb2, 0x38, - 0x61, 0x65, 0xd0, 0xad, 0xa1, 0x7a, 0xb9, 0x75, 0x69, 0xcc, 0xed, 0x0e, 0xcf, 0x16, 0xf3, 0x5c, - 0x57, 0x26, 0xe9, 0xd6, 0xa5, 0xba, 0xdd, 0x02, 0x1a, 0xee, 0x4a, 0x5a, 0xb7, 0x72, 0x7a, 0xf9, - 0x38, 0x6c, 0xc6, 0x9b, 0x55, 0xae, 0xf5, 0x62, 0xe9, 0x7c, 0xea, 0x30, 0xea, 0xc8, 0x27, 0x88, - 0xa4, 0x8d, 0x9e, 0x34, 0xdc, 0x1e, 0x74, 0xdc, 0x9a, 0x78, 0xda, 0xec, 0xb0, 0xd5, 0x0a, 0xa9, - 0xad, 0xfe, 0x0a, 0x70, 0x33, 0x56, 0x47, 0xd5, 0x41, 0x5a, 0x89, 0xbd, 0x34, 0xcb, 0x86, 0x6b, - 0x81, 0x30, 0x69, 0xa0, 0xe0, 0x66, 0x2c, 0x02, 0x13, 0x91, 0xdc, 0x6c, 0x5e, 0xe6, 0xc3, 0x2e, - 0x3b, 0x92, 0xea, 0x15, 0xc9, 0x2e, 0x0d, 0x36, 0xd4, 0x9c, 0xa2, 0x36, 0xb9, 0x8d, 0xc2, 0x05, - 0xd7, 0xca, 0xf0, 0x66, 0x6f, 0x1e, 0x9c, 0xf6, 0x4b, 0x4a, 0x8e, 0x2c, 0xf7, 0x28, 0x13, 0xde, - 0x48, 0x72, 0xbf, 0x83, 0x02, 0x9b, 0x92, 0xaa, 0x19, 0xbd, 0x4a, 0x93, 0x19, 0x13, 0xe8, 0x41, - 0x95, 0x0b, 0x04, 0x0f, 0xaa, 0x00, 0x08, 0xaa, 0x4e, 0xfd, 0xdd, 0xec, 0xc6, 0x8e, 0x13, 0xac, - 0xea, 0xb4, 0xb2, 0x43, 0x85, 0xaa, 0x0e, 0xa5, 0x41, 0x6f, 0x60, 0xdc, 0xea, 0x87, 0x79, 0x1e, - 0x87, 0xcc, 0x80, 0xd7, 0x79, 0xd6, 0x7a, 0xb1, 0x60, 0x44, 0xb1, 0x0e, 0xe5, 0xad, 0xd3, 0x47, - 0x41, 0x1b, 0xde, 0x85, 0xd3, 0xc7, 0x7d, 0x50, 0x2a, 0x7b, 0xf5, 0x1c, 0x61, 0x9c, 0x84, 0xb3, - 0xa7, 0x98, 0x7e, 0xd9, 0x33, 0x6c, 0xeb, 0x5c, 0x35, 0x37, 0x21, 0x23, 0x2e, 0xf4, 0x62, 0x19, - 0x89, 0x6d, 0xe7, 0x77, 0x95, 0x2c, 0x18, 0xea, 0x75, 0x28, 0x05, 0x78, 0x5e, 0xd0, 0xfc, 0x12, - 0xd3, 0x84, 0x89, 0x51, 0x51, 0xb0, 0xb8, 0x8c, 0xf3, 0x29, 0xba, 0x38, 0x35, 0xbf, 0xac, 0xe4, - 0x91, 0xa1, 0xc5, 0x29, 0xa9, 0x01, 0x4e, 0xed, 0xfd, 0xe7, 0x08, 0x90, 0xa6, 0x60, 0x1e, 0x10, - 0xf4, 0x5f, 0x23, 0x78, 0xd4, 0x83, 0x84, 0xa7, 0xf6, 0x0d, 0x60, 0xf6, 0xdd, 0x95, 0xd3, 0x0f, - 0x02, 0xa6, 0x7c, 0x34, 0xb4, 0x10, 0xa6, 0x55, 0x40, 0x50, 0x3b, 0x7b, 0x8b, 0x9f, 0xb1, 0x25, - 0x16, 0xd4, 0xee, 0x26, 0xe1, 0x67, 0x6c, 0x19, 0x0a, 0xea, 0x36, 0x0a, 0xe6, 0x99, 0xee, 0x3a, - 0xe8, 0x41, 0x40, 0xdf, 0x5d, 0xfa, 0xac, 0x76, 0x72, 0xa0, 0xe5, 0xec, 0xa6, 0x57, 0xde, 0x31, - 0x05, 0x92, 0xd0, 0xdd, 0xf4, 0x0a, 0x3f, 0xa5, 0x58, 0xeb, 0xc5, 0xc2, 0x1b, 0x01, 0xb1, 0x60, - 0x6f, 0x9b, 0xa3, 0x7a, 0x24, 0xb9, 0x52, 0xde, 0x3a, 0xab, 0x7f, 0xd8, 0x0d, 0xda, 0x7b, 0xcf, - 0x47, 0x25, 0x9f, 0xb2, 0xaa, 0xd2, 0x2f, 0x90, 0xfb, 0x17, 0x9c, 0xb4, 0x2c, 0x02, 0xef, 0x8f, - 0xdf, 0x0b, 0x43, 0xce, 0x23, 0xb6, 0x4a, 0x64, 0xdf, 0xbf, 0x7b, 0x80, 0x6a, 0xb6, 0x9f, 0xbe, - 0x5b, 0xed, 0xe4, 0x6c, 0xf3, 0xd2, 0x52, 0xf7, 0xc1, 0xbb, 0x87, 0xa8, 0x3a, 0xf6, 0xd6, 0xdd, - 0xa3, 0x1e, 0xa4, 0x76, 0xf5, 0xe9, 0xe0, 0xeb, 0x07, 0x7c, 0x36, 0x61, 0x79, 0x32, 0x7c, 0xdf, - 0xbf, 0xc1, 0xcb, 0x67, 0x51, 0xfd, 0x67, 0x63, 0xf4, 0x06, 0x25, 0xb6, 0x77, 0x10, 0x77, 0xd9, - 0xeb, 0xc5, 0x6c, 0x22, 0x62, 0x01, 0xee, 0x20, 0xca, 0xbf, 0x47, 0xb5, 0x80, 0xb8, 0x83, 0xe8, - 0x01, 0xc0, 0xde, 0x49, 0xc9, 0x18, 0x6a, 0xaf, 0x16, 0x04, 0xed, 0x69, 0xc0, 0xce, 0x22, 0x8c, - 0xbd, 0x7a, 0xa2, 0x0e, 0xef, 0x0c, 0x5a, 0x1d, 0x29, 0x25, 0x66, 0x11, 0x6d, 0xca, 0x06, 0xb7, - 0xca, 0xbe, 0x7c, 0xb0, 0x71, 0x31, 0x9f, 0xc7, 0xe5, 0x12, 0x04, 0xb7, 0xce, 0xa5, 0x03, 0x10, - 0xc1, 0x8d, 0x82, 0xb6, 0xd5, 0x36, 0xc5, 0x3c, 0xbd, 0xdc, 0xe7, 0x25, 0x5f, 0x88, 0x34, 0x67, - 0xf0, 0x01, 0x28, 0x53, 0xa0, 0x2e, 0x43, 0xb4, 0x5a, 0x8a, 0xb5, 0xb3, 0x5c, 0x49, 0xa8, 0xeb, - 0x8c, 0xf2, 0xa7, 0x5e, 0x2a, 0xc1, 0x4b, 0x78, 0x9c, 0xa9, 0xac, 0x40, 0x88, 0x98, 0xe5, 0x92, - 0x30, 0xa8, 0xfb, 0xa3, 0x34, 0x9f, 0xa1, 0x75, 0x7f, 0xe4, 0xbe, 0xea, 0x7f, 0x8b, 0x06, 0x6c, - 0x83, 0x52, 0x85, 0xa6, 0x1a, 0x80, 0x7e, 0x5e, 0x01, 0x2d, 0x74, 0x97, 0x20, 0x1a, 0x14, 0x4e, - 0x02, 0x57, 0x2f, 0x0b, 0x96, 0xb3, 0xa4, 0xb9, 0xb4, 0x87, 0xb9, 0xf2, 0x88, 0xa0, 0x2b, 0x48, - 0xda, 0xbe, 0x48, 0xca, 0x8f, 0x17, 0xf9, 0x51, 0xc9, 0xcf, 0xd3, 0x8c, 0x95, 0xa0, 0x2f, 0x52, - 0xea, 0x8e, 0x9c, 0xe8, 0x8b, 0x30, 0xce, 0xde, 0xfe, 0x90, 0x52, 0xef, 0xf7, 0x8a, 0x4e, 0xca, - 0x78, 0x0a, 0x6f, 0x7f, 0x28, 0x1b, 0x6d, 0x8c, 0xd8, 0x19, 0x0c, 0xe0, 0xce, 0x44, 0x47, 0xb9, - 0xce, 0x97, 0x32, 0x3e, 0xf4, 0x57, 0xf6, 0xf2, 0x15, 0x77, 0xf8, 0x19, 0x84, 0x36, 0x87, 0x91, - 0xc4, 0x44, 0x27, 0xac, 0x61, 0x87, 0x12, 0xc9, 0xbd, 0xd0, 0xb7, 0x9a, 0xc0, 0x50, 0xa2, 0x6c, - 0x34, 0x42, 0x62, 0x28, 0x69, 0x41, 0xa0, 0xc7, 0x50, 0xcd, 0xe0, 0x98, 0xc9, 0xbb, 0xc6, 0xab, - 0x64, 0x3b, 0x51, 0x40, 0xb0, 0xc7, 0x00, 0x20, 0x88, 0x48, 0xfd, 0x9e, 0x9e, 0x76, 0x84, 0xe9, - 0x7b, 0x44, 0x30, 0x22, 0x21, 0x69, 0x3b, 0xa7, 0x71, 0x9e, 0x8a, 0x34, 0xce, 0x26, 0x4c, 0x1c, - 0xc5, 0x65, 0x3c, 0x67, 0x82, 0x95, 0xb0, 0x73, 0xd2, 0x48, 0xe4, 0x31, 0x44, 0xe7, 0x44, 0xb1, - 0xda, 0xe1, 0xef, 0x0c, 0xde, 0xa9, 0xe7, 0x1a, 0x2c, 0xd7, 0xbf, 0x6d, 0xf9, 0x5c, 0xfe, 0x32, - 0xf1, 0xf0, 0x5d, 0x63, 0x63, 0x22, 0x4a, 0x16, 0xcf, 0x1b, 0xdb, 0xdf, 0x32, 0x7f, 0x97, 0xe0, - 0xd6, 0x4a, 0xdd, 0x86, 0x5e, 0x70, 0x91, 0x9e, 0xd7, 0x4b, 0x7b, 0xfd, 0xb1, 0x1a, 0x68, 0x43, - 0xae, 0x38, 0x0a, 0x3c, 0x49, 0x85, 0x71, 0xb6, 0xa6, 0x5d, 0xe9, 0x31, 0x2b, 0x32, 0x38, 0x36, - 0x78, 0xda, 0x12, 0x20, 0x6a, 0x1a, 0x05, 0x6d, 0x87, 0xe0, 0x8a, 0x4f, 0x58, 0x38, 0x33, 0x27, - 0xac, 0x5f, 0x66, 0x4e, 0xbc, 0xef, 0x50, 0xb2, 0xc1, 0x3b, 0x87, 0x6c, 0xfe, 0x5a, 0xfd, 0x86, - 0x02, 0xf5, 0x22, 0xbd, 0x25, 0x3a, 0x5f, 0xa4, 0x27, 0x50, 0x3b, 0xfa, 0x58, 0x60, 0x5c, 0xbd, - 0x88, 0xe7, 0x4c, 0x3e, 0xb0, 0x05, 0x46, 0x1f, 0xc7, 0x88, 0x03, 0x11, 0xa3, 0x0f, 0x09, 0x3b, - 0x9f, 0x12, 0x5a, 0xe6, 0x98, 0xcd, 0xea, 0x08, 0x2b, 0x8f, 0xe2, 0xe5, 0x9c, 0xe5, 0x42, 0x9b, - 0x04, 0xe7, 0x00, 0x8e, 0x49, 0x9c, 0x27, 0xce, 0x01, 0xfa, 0xe8, 0x39, 0xdd, 0xa1, 0x57, 0xf0, - 0x47, 0xbc, 0x14, 0xea, 0x47, 0x6b, 0x4f, 0xcb, 0x0c, 0x74, 0x87, 0x7e, 0xa1, 0x7a, 0x24, 0xd1, - 0x1d, 0x86, 0x35, 0x9c, 0xdf, 0xe9, 0xf2, 0xd2, 0x70, 0xc6, 0x4a, 0x13, 0x27, 0xcf, 0xe7, 0x71, - 0x9a, 0xe9, 0x68, 0xf8, 0x5e, 0xc0, 0x36, 0xa1, 0x43, 0xfc, 0x4e, 0x57, 0x5f, 0x5d, 0xe7, 0x97, - 0xcd, 0xc2, 0x29, 0x04, 0xc7, 0x12, 0x1d, 0xf6, 0x89, 0x63, 0x89, 0x6e, 0x2d, 0xbb, 0x5b, 0x60, - 0x59, 0xc9, 0x2d, 0x25, 0xb1, 0xc3, 0x13, 0xb8, 0x47, 0xe9, 0xd8, 0x04, 0x20, 0xb1, 0x5b, 0x10, - 0x54, 0xb0, 0x9d, 0xbf, 0xc5, 0xf6, 0xd2, 0x3c, 0xce, 0xd2, 0x1f, 0xc1, 0xa5, 0x84, 0x63, 0xa7, - 0x21, 0x88, 0xce, 0x1f, 0x27, 0x31, 0x57, 0xfb, 0x4c, 0x9c, 0xa4, 0x75, 0xd7, 0xff, 0x30, 0x50, - 0x6e, 0x92, 0xe8, 0x76, 0xe5, 0x90, 0xce, 0x0b, 0xf1, 0xb0, 0x58, 0x47, 0x45, 0x31, 0xa9, 0x47, - 0xf2, 0x63, 0x36, 0x65, 0x69, 0x21, 0x86, 0x4f, 0xc3, 0x65, 0x05, 0x70, 0xe2, 0x72, 0x47, 0x0f, - 0x35, 0xac, 0xa3, 0xaa, 0xeb, 0x60, 0x5f, 0xff, 0xee, 0x2b, 0xd9, 0x51, 0x39, 0x50, 0x77, 0x47, - 0xe5, 0xc3, 0x76, 0xb8, 0xf5, 0x7d, 0x1e, 0xb3, 0x84, 0xb1, 0xf9, 0xf0, 0x71, 0xc8, 0x8a, 0x62, - 0x88, 0xe1, 0x96, 0x62, 0xed, 0x64, 0xd0, 0x29, 0xf6, 0xed, 0xba, 0xa3, 0x28, 0x79, 0xb2, 0xa8, - 0x67, 0xb8, 0x1b, 0x84, 0x9d, 0xb3, 0xed, 0xc8, 0xc1, 0x88, 0xc9, 0x60, 0x00, 0xc7, 0x8a, 0x57, - 0x7a, 0xd6, 0x3d, 0xcd, 0x5a, 0xd0, 0x10, 0xe8, 0x5a, 0xd6, 0xfb, 0xc1, 0x68, 0xdb, 0xdd, 0xf6, - 0xba, 0x45, 0xb2, 0xed, 0xea, 0x0c, 0x18, 0xb0, 0xb3, 0xed, 0x22, 0x0a, 0x68, 0x8f, 0x7f, 0xb6, - 0x3d, 0xca, 0x97, 0xf5, 0x68, 0x35, 0xae, 0xd4, 0x08, 0x18, 0x30, 0xe8, 0x93, 0x9d, 0x3d, 0x3e, - 0xa6, 0xe1, 0x6c, 0xbf, 0x21, 0x69, 0x18, 0x65, 0x19, 0x97, 0xc7, 0x2c, 0xdd, 0x26, 0x1b, 0x94, - 0xd8, 0x7e, 0xeb, 0x50, 0xc1, 0x26, 0x1d, 0x67, 0xdb, 0x3b, 0x71, 0x29, 0xf6, 0x99, 0x20, 0x27, - 0x1d, 0x67, 0xdb, 0x91, 0x46, 0x3a, 0x27, 0x1d, 0x1e, 0x6a, 0x77, 0xea, 0xa1, 0x37, 0x7d, 0x63, - 0x6c, 0x3d, 0x6c, 0x05, 0x5c, 0x14, 0xdb, 0xe8, 0x49, 0xa3, 0x63, 0xd7, 0xd9, 0xb6, 0xd9, 0x19, - 0x3a, 0xe1, 0x0a, 0xad, 0xc8, 0xb1, 0xeb, 0x6c, 0x3b, 0x6a, 0xd3, 0x9d, 0x63, 0x17, 0xa5, 0xe5, - 0xdc, 0x82, 0xaa, 0xab, 0x63, 0xc2, 0xca, 0xab, 0x74, 0xca, 0x4e, 0x2b, 0x56, 0xea, 0xf5, 0x5a, - 0x5d, 0xf6, 0x5b, 0xe0, 0x4d, 0x04, 0xc3, 0x45, 0x0e, 0x18, 0xb9, 0x55, 0xf0, 0xc1, 0x35, 0x34, - 0x6c, 0x4d, 0x38, 0x9c, 0x7e, 0x9d, 0x49, 0xde, 0x1a, 0x5f, 0x27, 0x8d, 0x39, 0x14, 0x51, 0x13, - 0x34, 0x6d, 0xfb, 0xb9, 0xb6, 0xdb, 0x51, 0xbe, 0x1c, 0xc3, 0x9b, 0x67, 0x88, 0x25, 0x89, 0x11, - 0xfd, 0x5c, 0x00, 0x77, 0xce, 0x14, 0x4b, 0x1e, 0x27, 0xd3, 0xb8, 0x12, 0x47, 0xf1, 0x32, 0xe3, - 0x71, 0x22, 0x97, 0x2a, 0xf0, 0x4c, 0xb1, 0x61, 0x22, 0x17, 0xa2, 0xce, 0x14, 0x29, 0xd8, 0x5d, - 0xe4, 0xd6, 0x69, 0x6a, 0x6e, 0xe4, 0xc3, 0x45, 0xae, 0x4c, 0x2f, 0xbc, 0x8d, 0x7f, 0x2f, 0x0c, - 0xd9, 0x2f, 0x89, 0x95, 0x48, 0xae, 0xac, 0x6e, 0x61, 0x3a, 0xde, 0x9a, 0xea, 0x76, 0x80, 0xb0, - 0x0f, 0xdf, 0xa9, 0xbf, 0x37, 0xbf, 0x38, 0x2c, 0xf4, 0x4f, 0x03, 0xad, 0x63, 0xba, 0x2e, 0xe4, - 0x5d, 0xf4, 0xdd, 0xe8, 0x49, 0xdb, 0xed, 0xc3, 0x9d, 0x8b, 0x58, 0x8c, 0x92, 0xe4, 0x90, 0x55, - 0xc8, 0xdb, 0x3a, 0xb5, 0x30, 0xb2, 0x52, 0x62, 0xfb, 0xb0, 0x4d, 0xd9, 0x40, 0xaf, 0x65, 0xcf, - 0x93, 0x54, 0x68, 0x59, 0xf3, 0x9d, 0xcb, 0x7a, 0xdb, 0x40, 0x9b, 0x22, 0x72, 0x45, 0xd3, 0x76, - 0x88, 0xab, 0x99, 0x13, 0x3e, 0x9b, 0x65, 0x4c, 0x43, 0xc7, 0x2c, 0x56, 0xcf, 0x92, 0x6f, 0xb6, - 0x6d, 0xa1, 0x20, 0x31, 0xc4, 0x05, 0x15, 0xec, 0xca, 0xb8, 0xc6, 0xd4, 0xc9, 0x7e, 0x53, 0xb0, - 0xab, 0x6d, 0x33, 0x1e, 0x40, 0xac, 0x8c, 0x51, 0xd0, 0x7e, 0xbd, 0x5c, 0x8b, 0xf7, 0x59, 0x53, - 0x12, 0xf0, 0x71, 0x55, 0xa9, 0xec, 0x88, 0x89, 0xaf, 0x97, 0x11, 0xcc, 0xce, 0xc5, 0x80, 0x87, - 0x67, 0xcb, 0x71, 0x02, 0xb7, 0x3e, 0xa0, 0xbe, 0x64, 0x88, 0xb9, 0x18, 0xc5, 0xfa, 0x55, 0x67, - 0x3a, 0xf0, 0x83, 0xb8, 0xb2, 0x99, 0x43, 0xaa, 0x0e, 0x05, 0x43, 0x55, 0x47, 0x29, 0xf8, 0x45, - 0xea, 0x9e, 0x50, 0x20, 0x45, 0x8a, 0x1d, 0x4f, 0x3c, 0xe8, 0xc2, 0xec, 0x76, 0x46, 0x2d, 0x3c, - 0x66, 0x71, 0x62, 0x32, 0x86, 0xe8, 0xba, 0x72, 0x62, 0x3b, 0x03, 0xe3, 0xb4, 0x93, 0xdf, 0x1f, - 0x0c, 0x55, 0x36, 0x4a, 0xd7, 0xcd, 0x2d, 0x2c, 0x89, 0x35, 0x41, 0x74, 0x54, 0x3e, 0xe1, 0x8c, - 0xe7, 0x5e, 0x15, 0x9d, 0x70, 0xed, 0x40, 0x7f, 0x5d, 0x0f, 0xc7, 0x73, 0xbf, 0xd8, 0x5b, 0x34, - 0x31, 0x9e, 0x77, 0x6b, 0x39, 0xcf, 0x3d, 0x82, 0x2a, 0xdb, 0x2b, 0xf9, 0x1c, 0xa6, 0xe9, 0x93, - 0x60, 0xf5, 0x20, 0x1a, 0xc4, 0x73, 0x8f, 0xfd, 0x34, 0xe1, 0xcf, 0x04, 0xea, 0x4e, 0x16, 0xff, - 0x99, 0x40, 0x2d, 0x0c, 0xff, 0x4c, 0xa0, 0x85, 0x5a, 0x3f, 0x1c, 0x91, 0x24, 0xbb, 0x69, 0x35, - 0x5d, 0xc8, 0xad, 0x42, 0xfc, 0xe9, 0x75, 0x2b, 0xef, 0x7a, 0x21, 0xbc, 0x85, 0xda, 0xc7, 0x23, - 0x9a, 0xa8, 0x1d, 0x65, 0xd9, 0xf0, 0x36, 0x1e, 0x88, 0xee, 0x6b, 0x48, 0x77, 0x42, 0x88, 0xdf, - 0x51, 0xd6, 0x7f, 0x6f, 0x7a, 0xd1, 0x6a, 0x48, 0x04, 0xb9, 0x01, 0x42, 0x1d, 0x25, 0x04, 0xed, - 0x78, 0x2d, 0x43, 0x56, 0xbd, 0xcf, 0x83, 0x34, 0x03, 0xf0, 0x36, 0xcf, 0xed, 0x00, 0x61, 0xa7, - 0x34, 0xca, 0xa4, 0x38, 0x4a, 0xf3, 0x9c, 0xd9, 0x46, 0xb6, 0x86, 0xe9, 0x02, 0x88, 0x98, 0xd2, - 0x90, 0xb0, 0xef, 0x73, 0xbf, 0x8f, 0xcf, 0xfd, 0xeb, 0xf8, 0xdc, 0xa7, 0x7d, 0xfe, 0x64, 0x65, - 0xf0, 0xbe, 0x8e, 0x3d, 0x77, 0xfb, 0xd4, 0xb4, 0xcc, 0x12, 0x5c, 0x65, 0x6e, 0xe6, 0x02, 0x38, - 0x4c, 0x5c, 0x65, 0xee, 0x54, 0x72, 0xf6, 0x30, 0x55, 0xc0, 0xcc, 0xf9, 0x15, 0x23, 0xd2, 0xf3, - 0x11, 0x16, 0x16, 0x34, 0x4f, 0xec, 0x61, 0xf6, 0xd1, 0xb3, 0x93, 0xa8, 0xd1, 0xf8, 0x55, 0x99, - 0x8a, 0x34, 0x9f, 0x9d, 0x70, 0x9e, 0xc1, 0x33, 0xd8, 0xd1, 0x38, 0x72, 0xa5, 0xc4, 0x24, 0xaa, - 0x4d, 0xd9, 0xe0, 0x1d, 0x8d, 0x47, 0x0b, 0xc1, 0xcf, 0xd3, 0x2c, 0x03, 0xc1, 0x3b, 0x1a, 0x47, - 0x8d, 0x84, 0x08, 0x5e, 0x9f, 0xb0, 0xfd, 0xd2, 0x68, 0x2c, 0xaf, 0x33, 0xe8, 0x23, 0xdd, 0xbb, - 0x50, 0xc7, 0x11, 0x12, 0xfd, 0x52, 0x0b, 0xb2, 0x41, 0x3a, 0x1a, 0x63, 0xbf, 0xa6, 0xb9, 0x06, - 0xd5, 0x11, 0x88, 0x08, 0x52, 0x12, 0x76, 0x82, 0xf4, 0x68, 0x51, 0x5d, 0xf8, 0xe7, 0x11, 0x6a, - 0xe7, 0x59, 0xfd, 0x36, 0xc6, 0x13, 0xf0, 0x7b, 0xb1, 0x3e, 0x1b, 0x79, 0x30, 0x11, 0xa4, 0x9d, - 0x4a, 0xce, 0x53, 0xe2, 0x90, 0x9d, 0x30, 0xa1, 0x7e, 0x35, 0x9c, 0x27, 0xf0, 0xdb, 0xb5, 0x96, - 0x59, 0x97, 0x25, 0xbe, 0x5d, 0xeb, 0xd2, 0x71, 0x36, 0x14, 0x91, 0x94, 0xec, 0xf1, 0x52, 0x91, - 0xf5, 0x4c, 0xee, 0x69, 0xa7, 0x61, 0x17, 0x27, 0x36, 0x14, 0x7b, 0xa8, 0xd9, 0x2b, 0x97, 0xed, - 0x8a, 0xaa, 0x98, 0xa8, 0x93, 0x12, 0x75, 0x15, 0xb7, 0xe2, 0x88, 0x2b, 0x97, 0x21, 0x5e, 0x39, - 0x7f, 0x76, 0xfb, 0xbf, 0xbe, 0xb8, 0xb1, 0xf2, 0xb3, 0x2f, 0x6e, 0xac, 0xfc, 0xef, 0x17, 0x37, - 0x56, 0x7e, 0xfa, 0xe5, 0x8d, 0xaf, 0xfd, 0xec, 0xcb, 0x1b, 0x5f, 0xfb, 0x9f, 0x2f, 0x6f, 0x7c, - 0xed, 0xf3, 0xaf, 0x57, 0x6a, 0xfd, 0xfa, 0xfa, 0xe7, 0x8b, 0x92, 0x0b, 0xfe, 0xe4, 0xff, 0x02, - 0x00, 0x00, 0xff, 0xff, 0x1e, 0xf9, 0xbe, 0xab, 0x5c, 0x94, 0x00, 0x00, + // 6587 bytes of a gzipped FileDescriptorProto + 0x1f, 0x8b, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x02, 0xff, 0xa4, 0x9d, 0xdd, 0x73, 0x1c, 0xcb, + 0x55, 0xc0, 0x23, 0x1e, 0x08, 0x4c, 0x48, 0x80, 0x0d, 0x5c, 0x92, 0x4b, 0xe2, 0x6b, 0xfb, 0xda, + 0x96, 0x6d, 0x49, 0x23, 0x5d, 0xf9, 0x7e, 0x91, 0x50, 0x05, 0x6b, 0xc9, 0xd2, 0xdd, 0x44, 0xb2, + 0x85, 0x56, 0x96, 0x8b, 0x54, 0x51, 0xc5, 0x78, 0xa7, 0xb5, 0x1a, 0x34, 0x3b, 0x3d, 0x99, 0xe9, + 0x95, 0xbd, 0xa1, 0xa0, 0x48, 0x41, 0x41, 0x91, 0x82, 0x22, 0xc5, 0x57, 0xc1, 0x13, 0x55, 0x3c, + 0xf1, 0xc8, 0x9f, 0xc1, 0x63, 0x1e, 0x78, 0xe0, 0x91, 0x4a, 0xfe, 0x11, 0x6a, 0xba, 0x7b, 0xfa, + 0xe3, 0xcc, 0x39, 0x3d, 0xa3, 0xcb, 0x93, 0x5d, 0x3a, 0xbf, 0x73, 0x4e, 0x4f, 0xf7, 0xe9, 0xee, + 0xd3, 0x3d, 0x3d, 0xbd, 0xd1, 0x7b, 0xe5, 0xeb, 0xed, 0xb2, 0xe2, 0x82, 0xd7, 0xdb, 0x35, 0xab, + 0xae, 0xb3, 0x19, 0x6b, 0xff, 0x8d, 0xe5, 0x9f, 0x47, 0x5f, 0x4c, 0x8a, 0x95, 0x58, 0x95, 0xec, + 0xdd, 0xaf, 0x59, 0x72, 0xc6, 0x17, 0x8b, 0xa4, 0x48, 0x6b, 0x85, 0xbc, 0xfb, 0x8e, 0x95, 0xb0, + 0x6b, 0x56, 0x08, 0xfd, 0xf7, 0xdd, 0xff, 0xfe, 0x8f, 0x9f, 0x8b, 0xbe, 0xb2, 0x97, 0x67, 0xac, + 0x10, 0x7b, 0x5a, 0x63, 0xf4, 0xbd, 0xe8, 0xcb, 0xe3, 0xb2, 0x3c, 0x64, 0xe2, 0x9c, 0x55, 0x75, + 0xc6, 0x8b, 0xd1, 0xfb, 0xb1, 0x76, 0x10, 0x9f, 0x96, 0xb3, 0x78, 0x5c, 0x96, 0xb1, 0x15, 0xc6, + 0xa7, 0xec, 0xfb, 0x4b, 0x56, 0x8b, 0x77, 0xef, 0x85, 0xa1, 0xba, 0xe4, 0x45, 0xcd, 0x46, 0x17, + 0xd1, 0xaf, 0x8e, 0xcb, 0x72, 0xca, 0xc4, 0x3e, 0x6b, 0x1e, 0x60, 0x2a, 0x12, 0xc1, 0x46, 0xeb, + 0x1d, 0x55, 0x1f, 0x30, 0x3e, 0x1e, 0xf6, 0x83, 0xda, 0xcf, 0x59, 0xf4, 0xa5, 0xc6, 0xcf, 0xe5, + 0x52, 0xa4, 0xfc, 0x4d, 0x31, 0xba, 0xd3, 0x55, 0xd4, 0x22, 0x63, 0xfb, 0x6e, 0x08, 0xd1, 0x56, + 0x5f, 0x45, 0xbf, 0xf4, 0x2a, 0xc9, 0x73, 0x26, 0xf6, 0x2a, 0xd6, 0x14, 0xdc, 0xd7, 0x51, 0xa2, + 0x58, 0xc9, 0x8c, 0xdd, 0xf7, 0x83, 0x8c, 0x36, 0xfc, 0xbd, 0xe8, 0xcb, 0x4a, 0x72, 0xca, 0x66, + 0xfc, 0x9a, 0x55, 0x23, 0x54, 0x4b, 0x0b, 0x89, 0x2a, 0xef, 0x40, 0xd0, 0xf6, 0x1e, 0x2f, 0xae, + 0x59, 0x25, 0x70, 0xdb, 0x5a, 0x18, 0xb6, 0x6d, 0x21, 0x6d, 0xfb, 0xaf, 0xd7, 0xa2, 0x6f, 0x8c, + 0x67, 0x33, 0xbe, 0x2c, 0xc4, 0x11, 0x9f, 0x25, 0xf9, 0x51, 0x56, 0x5c, 0x3d, 0x67, 0x6f, 0xf6, + 0x2e, 0x1b, 0xbe, 0x98, 0xb3, 0xd1, 0x13, 0xbf, 0x56, 0x15, 0x1a, 0x1b, 0x36, 0x76, 0x61, 0xe3, + 0xfb, 0xc3, 0x9b, 0x29, 0xe9, 0xb2, 0xfc, 0xdd, 0x5a, 0x74, 0x0b, 0x96, 0x65, 0xca, 0xf3, 0x6b, + 0x66, 0x4b, 0xf3, 0x51, 0x8f, 0x61, 0x1f, 0x37, 0xe5, 0xf9, 0xf8, 0xa6, 0x6a, 0xba, 0x44, 0x7f, + 0xb6, 0x16, 0x7d, 0x1d, 0x96, 0x48, 0xb5, 0xfc, 0xb8, 0x2c, 0x47, 0x3b, 0x3d, 0x56, 0x0d, 0x69, + 0xca, 0xf1, 0xc1, 0x0d, 0x34, 0x02, 0x45, 0x78, 0x59, 0xa6, 0x03, 0x8b, 0x60, 0xc8, 0xc1, 0x45, + 0x70, 0x35, 0x74, 0x11, 0xfe, 0x24, 0xfa, 0x1a, 0x2c, 0xc1, 0x51, 0x56, 0x8b, 0x71, 0x59, 0xd6, + 0xa3, 0xed, 0x1e, 0x73, 0x2d, 0x68, 0xfc, 0xef, 0x0c, 0x57, 0x08, 0xd4, 0xc0, 0x29, 0xbb, 0xe6, + 0x57, 0x83, 0x6a, 0xc0, 0x90, 0x83, 0x6b, 0xc0, 0xd5, 0xd0, 0x45, 0xc8, 0xa3, 0xaf, 0xba, 0xc3, + 0xc6, 0x94, 0xd5, 0x72, 0x58, 0x7d, 0x44, 0x8f, 0x0c, 0x1a, 0x31, 0x4e, 0x1f, 0x0f, 0x41, 0xb5, + 0xb7, 0x2c, 0x1a, 0x69, 0x6f, 0x39, 0xaf, 0x8d, 0xb3, 0x87, 0xa8, 0x05, 0x87, 0x30, 0xbe, 0x1e, + 0x0d, 0x20, 0xb5, 0xab, 0x3f, 0x8c, 0x7e, 0xf9, 0x15, 0xaf, 0xae, 0xea, 0x32, 0x99, 0x31, 0x3d, + 0x24, 0xde, 0xf7, 0xb5, 0x5b, 0x29, 0x1c, 0x15, 0x1f, 0xf4, 0x61, 0xce, 0xe0, 0xd5, 0x0a, 0x5f, + 0x94, 0x0c, 0xce, 0x45, 0x56, 0xb1, 0x11, 0x52, 0x83, 0x17, 0x84, 0xb4, 0xed, 0xab, 0x68, 0x64, + 0x6d, 0xbf, 0xfe, 0x23, 0x36, 0x13, 0xe3, 0x34, 0x85, 0xad, 0x62, 0x75, 0x25, 0x11, 0x8f, 0xd3, + 0x94, 0x6a, 0x15, 0x1c, 0xd5, 0xce, 0xde, 0x44, 0xef, 0x00, 0x67, 0x32, 0x54, 0xd3, 0x74, 0xb4, + 0x15, 0xb6, 0xa2, 0x31, 0xe3, 0x34, 0x1e, 0x8a, 0x3b, 0xf1, 0x8f, 0x78, 0x3e, 0x65, 0x0b, 0x7e, + 0xcd, 0x40, 0xfc, 0xa3, 0xd6, 0x14, 0x49, 0xc4, 0x7f, 0x58, 0x03, 0x09, 0x93, 0x29, 0xcb, 0xd9, + 0x4c, 0x90, 0x61, 0xa2, 0xc4, 0xbd, 0x61, 0x62, 0x30, 0xa7, 0x87, 0xb5, 0xc2, 0x43, 0x26, 0xf6, + 0x96, 0x55, 0xc5, 0x0a, 0x41, 0xb6, 0xa5, 0x45, 0x7a, 0xdb, 0xd2, 0x43, 0x91, 0xe7, 0x39, 0x64, + 0x62, 0x9c, 0xe7, 0xe4, 0xf3, 0x28, 0x71, 0xef, 0xf3, 0x18, 0x4c, 0x7b, 0x98, 0x45, 0xbf, 0xe2, + 0xd4, 0x98, 0x98, 0x14, 0x17, 0x7c, 0x44, 0xd7, 0x85, 0x94, 0x1b, 0x1f, 0xeb, 0xbd, 0x9c, 0x76, + 0xc2, 0xa3, 0x5f, 0x73, 0x9d, 0x7c, 0xc6, 0x17, 0xac, 0x4c, 0xe6, 0x6c, 0xf4, 0x98, 0x36, 0xd0, + 0x32, 0xc6, 0xd9, 0xc6, 0x20, 0x16, 0xa9, 0xb7, 0x67, 0x6f, 0x4b, 0x5e, 0xd1, 0x71, 0xa0, 0xc4, + 0xbd, 0xf5, 0x66, 0x30, 0xed, 0xe1, 0x0f, 0xa2, 0xaf, 0xe8, 0x11, 0xb9, 0x4d, 0xa4, 0xee, 0xa1, + 0xc3, 0x35, 0xcc, 0xa4, 0xee, 0xf7, 0x50, 0x1d, 0xf3, 0xc7, 0xd9, 0xbc, 0x6a, 0x86, 0x3b, 0xdc, + 0xbc, 0x96, 0xf6, 0x98, 0xb7, 0x94, 0x6d, 0x10, 0xdf, 0xfc, 0x5e, 0x52, 0xcc, 0x58, 0x0e, 0x1a, + 0x04, 0xa8, 0x2b, 0x86, 0x68, 0x10, 0x8a, 0xb5, 0xa3, 0xab, 0x26, 0xf4, 0xe8, 0xfd, 0x3e, 0xaa, + 0x0d, 0xc6, 0xee, 0x7b, 0x61, 0xa8, 0x63, 0x7b, 0x9f, 0xe5, 0x8c, 0xb4, 0xad, 0x84, 0x3d, 0xb6, + 0x0d, 0xa4, 0x6d, 0xff, 0x68, 0x2d, 0xfa, 0xa6, 0x96, 0x9d, 0x54, 0x2c, 0xe7, 0x49, 0x7a, 0xca, + 0x16, 0x49, 0x56, 0x64, 0xc5, 0x7c, 0xda, 0xc4, 0x45, 0x4d, 0xe4, 0x9d, 0x38, 0xdc, 0x93, 0x77, + 0x92, 0x4a, 0xba, 0x30, 0x55, 0xf4, 0xeb, 0x26, 0xe6, 0x9a, 0xec, 0x58, 0x16, 0xb6, 0x99, 0x72, + 0x37, 0x88, 0xa0, 0x72, 0x21, 0xe3, 0x7b, 0x73, 0x18, 0xdc, 0xa9, 0x5c, 0x3d, 0x9e, 0xe2, 0x95, + 0x0b, 0x46, 0xd3, 0x7b, 0x61, 0xa8, 0x5b, 0xb9, 0xcf, 0x8a, 0xe4, 0x75, 0xce, 0x64, 0x6e, 0xf3, + 0x9c, 0x89, 0x37, 0xbc, 0xba, 0x9a, 0xae, 0x8a, 0x19, 0x51, 0xb9, 0x38, 0xdc, 0x53, 0xb9, 0xa4, + 0x92, 0x2e, 0xcc, 0x1f, 0x9b, 0xe4, 0x71, 0xef, 0x32, 0x29, 0xe6, 0xec, 0x3b, 0x35, 0x2f, 0xc6, + 0x65, 0x36, 0x4e, 0xd3, 0x6a, 0x14, 0xe3, 0x71, 0x08, 0x39, 0x53, 0x82, 0xed, 0xc1, 0xbc, 0xb3, + 0x88, 0xd4, 0xb5, 0x2c, 0x78, 0x09, 0x17, 0x91, 0x6d, 0xf5, 0x09, 0x5e, 0x52, 0x8b, 0x48, 0x1f, + 0xe9, 0x58, 0x3d, 0x6e, 0x66, 0x60, 0xdc, 0xea, 0xb1, 0x3b, 0xe5, 0xde, 0x0d, 0x21, 0x76, 0x06, + 0x6c, 0x2b, 0x8a, 0x17, 0x17, 0xd9, 0x5c, 0x25, 0xe2, 0x60, 0x06, 0x34, 0xcf, 0xec, 0x20, 0xc4, + 0x0c, 0x48, 0xa0, 0xda, 0xdb, 0xdf, 0xda, 0xb5, 0x96, 0x1e, 0x24, 0x0f, 0x2a, 0xbe, 0x38, 0x62, + 0xf3, 0x64, 0xb6, 0xd2, 0x23, 0xfb, 0x87, 0xa1, 0x21, 0x15, 0xd2, 0xa6, 0x10, 0x1f, 0xdd, 0x50, + 0x4b, 0x97, 0xe7, 0xdf, 0xd6, 0xa2, 0x7b, 0x5e, 0x9c, 0xe8, 0x60, 0x52, 0xa5, 0x1f, 0x17, 0xe9, + 0x29, 0xab, 0x45, 0x52, 0x89, 0xd1, 0xb7, 0x02, 0x31, 0x40, 0xe8, 0x98, 0xb2, 0x7d, 0xfb, 0x73, + 0xe9, 0xda, 0x56, 0x97, 0x03, 0x87, 0x1e, 0x0c, 0xfd, 0x56, 0x97, 0x12, 0x38, 0x14, 0xde, 0x0d, + 0x21, 0xb6, 0xd5, 0xa5, 0x60, 0x52, 0x5c, 0x67, 0x82, 0x1d, 0xb2, 0x82, 0x55, 0xdd, 0x56, 0x57, + 0xaa, 0x3e, 0x42, 0xb4, 0x3a, 0x81, 0xda, 0xcd, 0x1b, 0xc7, 0x9b, 0x7a, 0x70, 0xb0, 0x79, 0xe3, + 0x1a, 0x50, 0x00, 0xb1, 0x79, 0x83, 0x82, 0x76, 0x44, 0xf5, 0x9e, 0xca, 0xe4, 0x73, 0x1b, 0x81, + 0xc2, 0x76, 0x32, 0xba, 0xcd, 0x61, 0x30, 0x51, 0x93, 0xe2, 0xb0, 0x31, 0x12, 0xac, 0x49, 0x85, + 0x0c, 0xaa, 0x49, 0x83, 0xa2, 0x35, 0xa9, 0x96, 0x8c, 0x81, 0x9a, 0x54, 0xc0, 0x80, 0x9a, 0x34, + 0xa0, 0xcd, 0xb8, 0x1c, 0x3f, 0xe7, 0x19, 0x7b, 0x03, 0x32, 0x2e, 0x57, 0xb9, 0x11, 0x13, 0x19, + 0x17, 0x82, 0x69, 0x0f, 0xcf, 0xa3, 0x5f, 0x94, 0xc2, 0xef, 0xf0, 0xac, 0x18, 0xbd, 0x87, 0x28, + 0x35, 0x02, 0x63, 0xf5, 0x36, 0x0d, 0x80, 0x12, 0x37, 0x7f, 0xd5, 0xe9, 0xcf, 0x7d, 0x42, 0x09, + 0x64, 0x3e, 0x0f, 0xfa, 0x30, 0x9b, 0x5b, 0x4b, 0x61, 0x33, 0x2a, 0x4f, 0x2f, 0x93, 0x2a, 0x2b, + 0xe6, 0x23, 0x4c, 0xd7, 0x91, 0x13, 0xb9, 0x35, 0xc6, 0x81, 0x70, 0xd2, 0x8a, 0xe3, 0xb2, 0xac, + 0x9a, 0xc1, 0x1e, 0x0b, 0x27, 0x1f, 0x09, 0x86, 0x53, 0x07, 0xc5, 0xbd, 0xed, 0xb3, 0x59, 0x9e, + 0x15, 0x41, 0x6f, 0x1a, 0x19, 0xe2, 0xcd, 0xa2, 0x20, 0x78, 0x8f, 0x58, 0x72, 0xcd, 0xda, 0x27, + 0xc3, 0x6a, 0xc6, 0x05, 0x82, 0xc1, 0x0b, 0x40, 0xbb, 0x91, 0x21, 0xc5, 0xc7, 0xc9, 0x15, 0x6b, + 0x2a, 0x98, 0x35, 0xa9, 0xc2, 0x08, 0xd3, 0xf7, 0x08, 0x62, 0x23, 0x03, 0x27, 0xb5, 0xab, 0x65, + 0xf4, 0x8e, 0x94, 0x9f, 0x24, 0x95, 0xc8, 0x66, 0x59, 0x99, 0x14, 0xed, 0x02, 0x19, 0x1b, 0x45, + 0x3a, 0x94, 0x71, 0xb9, 0x35, 0x90, 0xd6, 0x6e, 0xff, 0x79, 0x2d, 0xba, 0x03, 0xfd, 0x9e, 0xb0, + 0x6a, 0x91, 0xc9, 0x7d, 0x96, 0x5a, 0x8f, 0xb0, 0x9f, 0x84, 0x8d, 0x76, 0x14, 0x4c, 0x69, 0x3e, + 0xbd, 0xb9, 0xa2, 0xcd, 0x2f, 0xa7, 0x7a, 0x59, 0xf8, 0xa2, 0x4a, 0x3b, 0xfb, 0xd1, 0xd3, 0x76, + 0x8d, 0x27, 0x85, 0x44, 0x7e, 0xd9, 0x81, 0x40, 0x0f, 0x7f, 0x59, 0xd4, 0xad, 0x75, 0xac, 0x87, + 0x5b, 0x71, 0xb0, 0x87, 0x7b, 0x98, 0x5d, 0x47, 0x49, 0xa1, 0x7a, 0xaa, 0x17, 0x6f, 0x0a, 0x56, + 0xd5, 0x97, 0x59, 0x39, 0xc2, 0x82, 0x1c, 0x30, 0xc4, 0x3a, 0x8a, 0x62, 0xb5, 0xc3, 0x1f, 0xae, + 0x45, 0xef, 0x3a, 0xb3, 0xfb, 0x1e, 0xaf, 0xaa, 0x65, 0x29, 0x58, 0xfa, 0x34, 0x99, 0x5d, 0x2d, + 0xe1, 0x26, 0xa3, 0x3b, 0x93, 0x03, 0x92, 0xd8, 0x64, 0x09, 0x6b, 0xd8, 0x4c, 0x19, 0x86, 0x52, + 0x3d, 0x4e, 0xd3, 0xa3, 0xac, 0x16, 0x20, 0x53, 0xee, 0x04, 0x42, 0xcb, 0x11, 0x99, 0x72, 0x88, + 0xb7, 0x63, 0xea, 0xc9, 0xf2, 0x75, 0x9e, 0xd5, 0x97, 0x59, 0x31, 0xd7, 0x6b, 0x49, 0xbf, 0xb5, + 0xac, 0x18, 0x2e, 0x27, 0xd7, 0x7b, 0x39, 0xcc, 0x89, 0xee, 0x9e, 0xa4, 0x13, 0xd0, 0x31, 0xd7, + 0x7b, 0x39, 0xbb, 0xc4, 0xb7, 0x52, 0x59, 0x79, 0xf7, 0x28, 0x55, 0xaf, 0xca, 0xee, 0xf7, 0x50, + 0x36, 0x34, 0xdd, 0x67, 0xa8, 0x79, 0x7e, 0xcd, 0x5e, 0x56, 0x19, 0x08, 0x4d, 0xaf, 0x7c, 0x2d, + 0x43, 0x84, 0x26, 0xc5, 0xda, 0xa9, 0xc1, 0x12, 0x87, 0x4c, 0x4c, 0x45, 0x22, 0x96, 0x35, 0x98, + 0x1a, 0x1c, 0x1b, 0x06, 0x21, 0xa6, 0x06, 0x02, 0xd5, 0xde, 0x7e, 0x2f, 0x8a, 0xd4, 0x3e, 0xa0, + 0xdc, 0xab, 0xf5, 0x67, 0x7b, 0xbd, 0x41, 0xe8, 0x6d, 0xd4, 0xde, 0x09, 0x10, 0x76, 0x28, 0x52, + 0x7f, 0x3f, 0x65, 0x17, 0x15, 0xab, 0x2f, 0xc1, 0x50, 0xa4, 0x75, 0xb4, 0x90, 0x18, 0x8a, 0x3a, + 0x90, 0x4d, 0xca, 0x95, 0x48, 0x6e, 0x6f, 0x8f, 0xd0, 0xd2, 0x48, 0x11, 0x91, 0x94, 0x03, 0x04, + 0x56, 0xc2, 0xf4, 0x92, 0xbf, 0xc1, 0x2b, 0xa1, 0x91, 0x84, 0x2b, 0x41, 0x13, 0xf6, 0xc5, 0xa3, + 0x2e, 0x28, 0xf6, 0xe2, 0xb1, 0x2d, 0x46, 0xe8, 0xc5, 0x23, 0x64, 0x6c, 0x3c, 0xba, 0x86, 0x9f, + 0x72, 0x7e, 0xb5, 0x48, 0xaa, 0x2b, 0x10, 0x8f, 0x9e, 0x72, 0xcb, 0x10, 0xf1, 0x48, 0xb1, 0x36, + 0x1e, 0x5d, 0x87, 0xcd, 0x92, 0xee, 0x65, 0x95, 0x83, 0x78, 0xf4, 0x6c, 0x68, 0x84, 0x88, 0x47, + 0x02, 0xb5, 0x73, 0x8d, 0xeb, 0x6d, 0xca, 0xe0, 0x8e, 0xa3, 0xa7, 0x3e, 0x65, 0xd4, 0x8e, 0x23, + 0x82, 0xc1, 0x10, 0x3a, 0xac, 0x92, 0xf2, 0x12, 0x0f, 0x21, 0x29, 0x0a, 0x87, 0x50, 0x8b, 0xc0, + 0xf6, 0x9e, 0xb2, 0xa4, 0x9a, 0x5d, 0xe2, 0xed, 0xad, 0x64, 0xe1, 0xf6, 0x36, 0x0c, 0x6c, 0x6f, + 0x25, 0x78, 0x95, 0x89, 0xcb, 0x63, 0x26, 0x12, 0xbc, 0xbd, 0x7d, 0x26, 0xdc, 0xde, 0x1d, 0xd6, + 0xae, 0xe5, 0x5c, 0x87, 0xd3, 0xe5, 0xeb, 0x7a, 0x56, 0x65, 0xaf, 0xd9, 0x28, 0x60, 0xc5, 0x40, + 0xc4, 0x5a, 0x8e, 0x84, 0xb5, 0xcf, 0x1f, 0xaf, 0x45, 0xef, 0xb5, 0xcd, 0xce, 0xeb, 0x5a, 0x67, + 0x32, 0xbe, 0xfb, 0x8f, 0xf0, 0xf6, 0x25, 0x70, 0xe2, 0x55, 0xf0, 0x00, 0x35, 0x27, 0xd3, 0xc3, + 0x8b, 0xf4, 0xb2, 0xa8, 0x4d, 0xa1, 0x3e, 0x19, 0x62, 0xdd, 0x51, 0x20, 0x32, 0xbd, 0x41, 0x8a, + 0x36, 0xc9, 0xd6, 0xed, 0xd3, 0xca, 0x26, 0x69, 0x0d, 0x92, 0xec, 0xb6, 0xbe, 0x1d, 0x82, 0x48, + 0xb2, 0x71, 0x12, 0x86, 0xc2, 0x61, 0xc5, 0x97, 0x65, 0xdd, 0x13, 0x0a, 0x00, 0x0a, 0x87, 0x42, + 0x17, 0xd6, 0x3e, 0xdf, 0x46, 0xbf, 0xe1, 0x86, 0x9f, 0x5b, 0xd9, 0x5b, 0x74, 0x4c, 0x61, 0x55, + 0x1c, 0x0f, 0xc5, 0x6d, 0xb6, 0xd2, 0x7a, 0x16, 0xfb, 0x4c, 0x24, 0x59, 0x5e, 0x8f, 0x1e, 0xe0, + 0x36, 0x5a, 0x39, 0x91, 0xad, 0x60, 0x1c, 0x1c, 0xdf, 0xf6, 0x97, 0x65, 0x9e, 0xcd, 0xba, 0x2f, + 0x60, 0xb5, 0xae, 0x11, 0x87, 0xc7, 0x37, 0x17, 0x83, 0xe3, 0x75, 0x93, 0xc8, 0xcb, 0xff, 0x9c, + 0xad, 0x4a, 0x86, 0x8f, 0xd7, 0x1e, 0x12, 0x1e, 0xaf, 0x21, 0x0a, 0x9f, 0x67, 0xca, 0xc4, 0x51, + 0xb2, 0xe2, 0x4b, 0x62, 0xbc, 0x36, 0xe2, 0xf0, 0xf3, 0xb8, 0x98, 0x5d, 0xe9, 0x19, 0x0f, 0x93, + 0x42, 0xb0, 0xaa, 0x48, 0xf2, 0x83, 0x3c, 0x99, 0xd7, 0x23, 0x62, 0x8c, 0xf1, 0x29, 0x62, 0xa5, + 0x47, 0xd3, 0x48, 0x35, 0x4e, 0xea, 0x83, 0xe4, 0x9a, 0x57, 0x99, 0xa0, 0xab, 0xd1, 0x22, 0xbd, + 0xd5, 0xe8, 0xa1, 0xa8, 0xb7, 0x71, 0x35, 0xbb, 0xcc, 0xae, 0x59, 0x1a, 0xf0, 0xd6, 0x22, 0x03, + 0xbc, 0x39, 0x28, 0xd2, 0x68, 0x53, 0xbe, 0xac, 0x66, 0x8c, 0x6c, 0x34, 0x25, 0xee, 0x6d, 0x34, + 0x83, 0xc1, 0xe7, 0x69, 0x92, 0x69, 0x1b, 0xea, 0xe8, 0xf3, 0x78, 0x48, 0xf8, 0x79, 0x20, 0x0a, + 0x7b, 0xae, 0x94, 0xab, 0xfd, 0xda, 0x07, 0xa4, 0xbe, 0xbf, 0x69, 0xbb, 0xde, 0xcb, 0xc1, 0x81, + 0xa9, 0x11, 0xfa, 0xcd, 0xb4, 0x45, 0xd9, 0xc0, 0x9b, 0x2a, 0x1e, 0x8a, 0xdb, 0x85, 0x62, 0x9b, + 0xf4, 0xb2, 0xa4, 0x58, 0x96, 0xd3, 0xe5, 0x7c, 0xce, 0x6a, 0x91, 0xf1, 0xa2, 0x1e, 0xc5, 0x78, + 0x7a, 0x0b, 0x39, 0x62, 0xa1, 0x18, 0xe2, 0x9d, 0x97, 0x4b, 0x84, 0xf7, 0xc9, 0xbc, 0xe0, 0x15, + 0x3c, 0x31, 0x46, 0x99, 0x54, 0x30, 0xf1, 0x72, 0xa9, 0x57, 0x89, 0x6c, 0x03, 0xd3, 0x31, 0xc3, + 0x6d, 0xd0, 0xe9, 0x9c, 0xf1, 0x50, 0x9c, 0xf0, 0xec, 0x8c, 0xac, 0x21, 0xcf, 0xc8, 0xe8, 0x1a, + 0x0f, 0xc5, 0x61, 0x02, 0xa8, 0x99, 0x76, 0x6a, 0x7a, 0x1c, 0xb0, 0x03, 0xa7, 0xa7, 0x8d, 0x41, + 0xac, 0x76, 0xf8, 0x57, 0x6b, 0xd1, 0x37, 0xac, 0xc7, 0x63, 0x9e, 0x66, 0x17, 0x2b, 0x05, 0x9d, + 0x27, 0xf9, 0x92, 0xd5, 0xa3, 0x5d, 0xca, 0x5a, 0x97, 0x35, 0x25, 0x78, 0x72, 0x23, 0x1d, 0x38, + 0x8a, 0x8c, 0xcb, 0x32, 0x5f, 0x9d, 0xb1, 0x45, 0x99, 0x93, 0xa3, 0x88, 0x87, 0x84, 0x47, 0x11, + 0x88, 0xc2, 0x85, 0xc1, 0x19, 0x6f, 0x96, 0x1d, 0xe8, 0xc2, 0x40, 0x8a, 0xc2, 0x0b, 0x83, 0x16, + 0x81, 0xe9, 0xda, 0x19, 0xdf, 0xe3, 0x79, 0xce, 0x66, 0xa2, 0x7b, 0xb8, 0xcb, 0x68, 0x5a, 0x22, + 0x9c, 0xae, 0x01, 0xd2, 0x6e, 0xf3, 0xb6, 0xcb, 0xd8, 0xa4, 0x62, 0x4f, 0x57, 0x47, 0x59, 0x71, + 0x35, 0xc2, 0x33, 0x13, 0x0b, 0x10, 0xdb, 0xbc, 0x28, 0x08, 0x97, 0xcb, 0x2f, 0x8b, 0x94, 0xe3, + 0xcb, 0xe5, 0x46, 0x12, 0x5e, 0x2e, 0x6b, 0x02, 0x9a, 0x3c, 0x65, 0x94, 0xc9, 0x46, 0x12, 0x36, + 0xa9, 0x09, 0x6c, 0x52, 0xd0, 0xaf, 0x38, 0xc9, 0x49, 0x01, 0xbc, 0xd4, 0x5c, 0xef, 0xe5, 0xe0, + 0xb2, 0x4f, 0x3b, 0x40, 0x23, 0x02, 0x18, 0x7f, 0x3f, 0xc8, 0xc0, 0xd0, 0x6f, 0x17, 0xe4, 0x07, + 0x4c, 0xcc, 0x2e, 0xf1, 0xd0, 0xf7, 0x90, 0x70, 0xe8, 0x43, 0x14, 0x3e, 0xc6, 0x64, 0x41, 0x3f, + 0x86, 0x92, 0x85, 0x1f, 0xc3, 0x30, 0xb0, 0x11, 0x94, 0x40, 0x6e, 0xcf, 0x3d, 0xa0, 0x15, 0xbd, + 0x0d, 0xba, 0xf5, 0x5e, 0x4e, 0x3b, 0xf9, 0x47, 0xb3, 0x7a, 0x54, 0xd2, 0xe7, 0xbc, 0xe9, 0x17, + 0xe7, 0x49, 0x9e, 0xa5, 0x89, 0x60, 0x67, 0xfc, 0x8a, 0x15, 0xf8, 0x42, 0x4d, 0x97, 0x56, 0xf1, + 0xb1, 0xa7, 0x10, 0x5e, 0xa8, 0x85, 0x15, 0x61, 0x13, 0x2a, 0xfa, 0x65, 0xcd, 0xf6, 0x92, 0x9a, + 0x18, 0xbd, 0x3c, 0x24, 0xdc, 0x84, 0x10, 0x85, 0x69, 0xb2, 0x92, 0x3f, 0x7b, 0x5b, 0xb2, 0x2a, + 0x63, 0xc5, 0x8c, 0xe1, 0x69, 0x32, 0xa4, 0xc2, 0x69, 0x32, 0x42, 0xc3, 0x25, 0xe2, 0x7e, 0x22, + 0xd8, 0xd3, 0xd5, 0x59, 0xb6, 0x60, 0xb5, 0x48, 0x16, 0x25, 0xbe, 0x44, 0x04, 0x50, 0x78, 0x89, + 0xd8, 0x85, 0x3b, 0x3b, 0x52, 0x66, 0x10, 0xec, 0x9e, 0x03, 0x85, 0x44, 0xe0, 0x1c, 0x28, 0x81, + 0xc2, 0x8a, 0xb5, 0x00, 0xfa, 0xa6, 0xa9, 0x63, 0x25, 0xf8, 0xa6, 0x89, 0xa6, 0x3b, 0xfb, 0x7c, + 0x86, 0x99, 0x36, 0x5d, 0xb3, 0xa7, 0xe8, 0x53, 0xb7, 0x8b, 0x6e, 0x0c, 0x62, 0xf1, 0x8d, 0xc5, + 0x53, 0x96, 0x27, 0x72, 0xaa, 0x0a, 0xec, 0xde, 0xb5, 0xcc, 0x90, 0x8d, 0x45, 0x87, 0x75, 0xde, + 0xc1, 0x60, 0x1e, 0x5f, 0x94, 0xd2, 0xef, 0x4e, 0xbf, 0x2d, 0x45, 0x12, 0xef, 0x60, 0xc2, 0x1a, + 0x36, 0xb5, 0x6e, 0x45, 0xf6, 0x1c, 0xac, 0x2e, 0x80, 0x9f, 0xa8, 0x99, 0xf2, 0x43, 0x8e, 0x48, + 0xad, 0x43, 0xbc, 0x5d, 0x86, 0xf9, 0xe5, 0xaa, 0xc1, 0x32, 0xcc, 0xd8, 0xd0, 0x62, 0x62, 0x19, + 0x86, 0x60, 0xf6, 0x0c, 0xb3, 0xef, 0xc1, 0xbc, 0x1e, 0xdc, 0x0a, 0x59, 0xe8, 0xbe, 0x28, 0x8c, + 0x87, 0xe2, 0x76, 0x58, 0x70, 0xeb, 0xf5, 0x55, 0x26, 0x2e, 0x65, 0x72, 0x07, 0x86, 0x05, 0xaf, + 0x92, 0x0c, 0x44, 0x0c, 0x0b, 0x24, 0x0c, 0xd3, 0x9f, 0x16, 0x6c, 0x06, 0x05, 0x6c, 0x12, 0x31, + 0x86, 0xdc, 0x21, 0xe1, 0x61, 0x3f, 0x08, 0x3b, 0x4a, 0x2b, 0xd6, 0x2b, 0xce, 0xc7, 0x21, 0x0b, + 0x60, 0xd5, 0xb9, 0x31, 0x88, 0xd5, 0x0e, 0xff, 0x34, 0xfa, 0x7a, 0xe7, 0xc1, 0x0e, 0x58, 0x22, + 0x96, 0x15, 0x4b, 0x47, 0xdb, 0x3d, 0xe5, 0x6e, 0x41, 0xe2, 0x83, 0x8c, 0xa0, 0x42, 0x67, 0x41, + 0xd0, 0x72, 0x2a, 0x9e, 0x4d, 0x19, 0x76, 0x43, 0x26, 0x7d, 0x36, 0xb8, 0x20, 0xa0, 0x75, 0x74, + 0x49, 0xfe, 0x62, 0x2d, 0xfa, 0x4d, 0x1f, 0x95, 0xa7, 0xe7, 0xaf, 0x93, 0x2c, 0x97, 0x47, 0x0d, + 0x3e, 0x08, 0x19, 0xf5, 0x50, 0x53, 0x8e, 0xdd, 0x9b, 0xa8, 0x74, 0xa6, 0x04, 0x39, 0xb8, 0x38, + 0x6b, 0xc1, 0x4d, 0x7a, 0x08, 0x42, 0x96, 0x82, 0x5b, 0x03, 0x69, 0xed, 0x56, 0xb4, 0x73, 0x6d, + 0xf3, 0x67, 0x37, 0xc8, 0x31, 0xaf, 0x5a, 0x15, 0x89, 0xf4, 0xad, 0x81, 0xb4, 0xfd, 0x1a, 0xa8, + 0xeb, 0x55, 0xcf, 0x80, 0xdb, 0xbd, 0xa6, 0xc0, 0x24, 0xb8, 0x33, 0x5c, 0x41, 0xbb, 0xff, 0x17, + 0xb3, 0x0f, 0xaf, 0xfc, 0xcf, 0xf8, 0x62, 0xc1, 0x8a, 0x94, 0xa5, 0xad, 0x46, 0xdd, 0x2c, 0xd6, + 0x3e, 0xa5, 0xed, 0x1a, 0x85, 0xd8, 0xd5, 0x30, 0x25, 0xfa, 0xad, 0xcf, 0xa1, 0xa9, 0x8b, 0xf6, + 0x9f, 0x6b, 0xd1, 0x23, 0xb4, 0x68, 0x6d, 0xe0, 0x7a, 0x45, 0xfc, 0xdd, 0x21, 0x8e, 0x30, 0x4d, + 0x53, 0xd4, 0xf1, 0xff, 0xc3, 0x82, 0x2e, 0xf2, 0xbf, 0xae, 0x45, 0x77, 0xad, 0x62, 0x13, 0xde, + 0x7b, 0xbc, 0xb8, 0xc8, 0xb3, 0x99, 0x90, 0x6f, 0xb7, 0xb5, 0x0a, 0x5d, 0x9d, 0x94, 0x46, 0x7f, + 0x75, 0x06, 0x34, 0x75, 0xd9, 0xfe, 0x61, 0x2d, 0xba, 0xed, 0x56, 0xa7, 0x7c, 0x35, 0xae, 0x76, + 0x83, 0x5b, 0xc5, 0x7a, 0xf4, 0x31, 0x5d, 0x07, 0x18, 0x6f, 0xca, 0xf5, 0xc9, 0x8d, 0xf5, 0x3a, + 0xeb, 0xf7, 0x55, 0x69, 0x4f, 0xd7, 0x3c, 0xa4, 0xcc, 0x75, 0x66, 0xce, 0x47, 0x03, 0x48, 0xeb, + 0xea, 0xb3, 0xac, 0x16, 0xbc, 0x5a, 0x4d, 0x2f, 0xf9, 0x9b, 0xf6, 0x5b, 0x5e, 0xdf, 0x95, 0x06, + 0x62, 0x87, 0x20, 0x5c, 0xe1, 0x64, 0xc7, 0x95, 0xfd, 0xe6, 0xb7, 0x26, 0x5c, 0x39, 0x44, 0x8f, + 0x2b, 0x9f, 0xb4, 0xd3, 0x72, 0xfb, 0x54, 0xf6, 0x03, 0xe5, 0x75, 0xbc, 0xa8, 0xdd, 0x8f, 0x94, + 0x1f, 0xf6, 0x83, 0x76, 0x55, 0xa0, 0xc5, 0xfb, 0xd9, 0xc5, 0x85, 0x79, 0x26, 0xbc, 0xa4, 0x2e, + 0x42, 0xac, 0x0a, 0x08, 0xd4, 0x2e, 0x6c, 0x0f, 0xb2, 0x9c, 0xc9, 0x97, 0x75, 0x2f, 0x2e, 0x2e, + 0x72, 0x9e, 0xa4, 0x60, 0x61, 0xdb, 0x88, 0x63, 0x57, 0x4e, 0x2c, 0x6c, 0x31, 0xce, 0x9e, 0xa4, + 0x68, 0xa4, 0x4d, 0xf7, 0x2e, 0x66, 0x59, 0x0e, 0xbf, 0xc8, 0x90, 0x9a, 0x46, 0x48, 0x9c, 0xa4, + 0xe8, 0x40, 0x36, 0xf9, 0x6c, 0x44, 0x4d, 0xb7, 0x6c, 0xcb, 0x7f, 0xbf, 0xab, 0xe8, 0x88, 0x89, + 0xe4, 0x13, 0xc1, 0xec, 0x9e, 0x4e, 0x23, 0x7c, 0x59, 0x4a, 0xe3, 0xb7, 0xbb, 0x5a, 0x4a, 0x42, + 0xec, 0xe9, 0xf8, 0x84, 0xdd, 0xa7, 0x68, 0xfe, 0xbe, 0xcf, 0xdf, 0x14, 0xd2, 0xe8, 0xdd, 0xae, + 0x4a, 0x2b, 0x23, 0xf6, 0x29, 0x20, 0x63, 0xfb, 0x83, 0x34, 0x9c, 0xd5, 0xb3, 0xa4, 0x4a, 0xf5, + 0x07, 0x24, 0xa0, 0x3f, 0x28, 0x55, 0x8f, 0x20, 0xfa, 0x03, 0x4e, 0x6a, 0x57, 0xdf, 0x8d, 0x7e, + 0x41, 0xba, 0xaa, 0x78, 0x39, 0xba, 0x85, 0xa8, 0x55, 0xce, 0xd7, 0x09, 0xef, 0x91, 0x72, 0x7b, + 0xf8, 0xc9, 0x84, 0xe1, 0xcb, 0x3a, 0x99, 0xc3, 0xef, 0x9b, 0x6c, 0x70, 0x49, 0x29, 0x71, 0xf8, + 0xa9, 0x4b, 0xf9, 0x01, 0xf8, 0x9c, 0xa7, 0xda, 0x3a, 0x52, 0x99, 0x46, 0x18, 0x0a, 0x40, 0x17, + 0xb2, 0xfd, 0x55, 0x16, 0x9d, 0x89, 0xf1, 0x52, 0x70, 0xd3, 0xa4, 0x48, 0x4d, 0x02, 0x84, 0xe8, + 0xaf, 0x04, 0x6a, 0x47, 0xa1, 0x06, 0xd8, 0x4b, 0x66, 0x97, 0x36, 0x7c, 0x90, 0x8e, 0xe8, 0x01, + 0xc4, 0x28, 0x84, 0x82, 0xf6, 0x3d, 0x81, 0xf1, 0xa3, 0xce, 0x31, 0x1b, 0x6f, 0x5b, 0x84, 0x11, + 0x1f, 0x23, 0x96, 0x5c, 0x01, 0xdc, 0x2e, 0x65, 0x1b, 0xc8, 0x7d, 0xfc, 0x29, 0x13, 0x47, 0xd9, + 0x22, 0x83, 0xc7, 0x09, 0xa5, 0x2d, 0x8c, 0x23, 0x96, 0xb2, 0x21, 0xde, 0xae, 0xf7, 0x9e, 0x27, + 0xd7, 0xd9, 0xdc, 0xe4, 0xe4, 0x6a, 0xa2, 0xab, 0xc1, 0x7a, 0xcf, 0x32, 0xb1, 0x03, 0x11, 0xeb, + 0x3d, 0x12, 0x76, 0xf2, 0x05, 0xcb, 0x1c, 0xb6, 0x6f, 0x4f, 0x26, 0xc5, 0x05, 0x6f, 0x56, 0x87, + 0x47, 0x59, 0x71, 0x05, 0xf3, 0x05, 0xc7, 0x24, 0xce, 0x13, 0xf9, 0xc2, 0x10, 0x3d, 0xdb, 0x0c, + 0xed, 0xab, 0x05, 0x7b, 0xc4, 0x49, 0x69, 0x80, 0x66, 0x30, 0x6f, 0x20, 0x20, 0x47, 0x34, 0x43, + 0x88, 0xb7, 0xfd, 0xd5, 0x38, 0xcf, 0x79, 0x01, 0xfb, 0xab, 0xb5, 0xd0, 0x08, 0x89, 0xfe, 0xda, + 0x81, 0x6c, 0x0f, 0x6a, 0x45, 0x6a, 0xb3, 0x7a, 0x9c, 0xe7, 0xa0, 0x07, 0x19, 0x55, 0x03, 0x10, + 0x3d, 0x08, 0x05, 0x6d, 0x0f, 0x6a, 0xc5, 0x53, 0x26, 0x4e, 0xf2, 0x64, 0xc6, 0x2e, 0x79, 0x9e, + 0xb2, 0xaa, 0x06, 0x3d, 0xc8, 0x18, 0x01, 0x18, 0xd1, 0x83, 0x02, 0x78, 0xd7, 0xf3, 0xe1, 0x30, + 0xcf, 0x87, 0x37, 0xf3, 0x7c, 0x48, 0x79, 0xfe, 0xe1, 0x5a, 0xf4, 0x6e, 0x4b, 0xa9, 0xd5, 0xbf, + 0xe7, 0x7d, 0x07, 0x37, 0xd7, 0x25, 0x89, 0xad, 0xb0, 0xb0, 0x86, 0x2e, 0xc3, 0x69, 0xf4, 0xa5, + 0x26, 0x94, 0x4f, 0x2a, 0x76, 0x9d, 0x31, 0x78, 0x0a, 0xd2, 0x91, 0x10, 0xf3, 0xb5, 0x4f, 0xd8, + 0xe9, 0xe9, 0x65, 0x51, 0x97, 0x79, 0x52, 0x5f, 0xea, 0x73, 0x71, 0x7e, 0xac, 0xb5, 0x42, 0x78, + 0x32, 0xee, 0x7e, 0x0f, 0x65, 0x93, 0xb0, 0x56, 0x66, 0x46, 0xd9, 0x07, 0xb8, 0x6a, 0x67, 0x78, + 0x5d, 0xef, 0xe5, 0x6c, 0x54, 0x1c, 0x26, 0x79, 0xce, 0xaa, 0x55, 0x2b, 0x3b, 0x4e, 0x8a, 0xec, + 0x82, 0xd5, 0x02, 0x44, 0x85, 0xa6, 0x62, 0x88, 0x11, 0x51, 0x11, 0xc0, 0xed, 0x46, 0x13, 0xf0, + 0x3c, 0x29, 0x52, 0xf6, 0x16, 0x6c, 0x34, 0x41, 0x3b, 0x92, 0x21, 0x36, 0x9a, 0x28, 0xd6, 0xbe, + 0x01, 0x7d, 0x9a, 0xf3, 0xd9, 0x95, 0x4e, 0xd9, 0xfc, 0x06, 0x96, 0x12, 0x98, 0xb3, 0xdd, 0x0d, + 0x21, 0x36, 0x69, 0x93, 0x82, 0x53, 0x56, 0x36, 0x81, 0x37, 0xc2, 0x74, 0xb4, 0x8c, 0x48, 0xda, + 0x20, 0x03, 0x8a, 0xab, 0x8f, 0xd8, 0x62, 0xc5, 0x05, 0x27, 0x6c, 0xef, 0x86, 0x10, 0x9b, 0xb6, + 0x4a, 0xc1, 0xb4, 0xcc, 0x33, 0x01, 0xba, 0x81, 0xd2, 0x90, 0x12, 0xa2, 0x1b, 0xf8, 0x04, 0x30, + 0x79, 0xcc, 0xaa, 0x39, 0x43, 0x4d, 0x4a, 0x49, 0xd0, 0x64, 0x4b, 0xd8, 0xaf, 0xb8, 0xd4, 0xb3, + 0xf3, 0x72, 0x05, 0xbe, 0xe2, 0xd2, 0x8f, 0xc5, 0xcb, 0x15, 0xf1, 0x15, 0x97, 0x07, 0x80, 0x22, + 0x9e, 0x24, 0xb5, 0xc0, 0x8b, 0x28, 0x25, 0xc1, 0x22, 0xb6, 0x84, 0x4d, 0x74, 0x55, 0x11, 0x97, + 0x02, 0x24, 0xba, 0xba, 0x00, 0xce, 0x61, 0xb0, 0xf7, 0x48, 0xb9, 0x1d, 0x49, 0x54, 0xab, 0x30, + 0x71, 0x90, 0xb1, 0x3c, 0xad, 0xc1, 0x48, 0xa2, 0xeb, 0xbd, 0x95, 0x12, 0x23, 0x49, 0x97, 0x02, + 0xa1, 0xa4, 0x5f, 0xe3, 0x62, 0x4f, 0x07, 0xde, 0xe2, 0xde, 0x0d, 0x21, 0x76, 0x7c, 0x6a, 0x0b, + 0xbd, 0x97, 0x54, 0x55, 0xd6, 0x64, 0xd0, 0x0f, 0xf0, 0x02, 0xb5, 0x72, 0x62, 0x7c, 0xc2, 0x38, + 0xd0, 0xbd, 0xda, 0x81, 0x1b, 0x2b, 0x18, 0x1c, 0xba, 0xdf, 0x0f, 0x32, 0x76, 0x85, 0x28, 0x25, + 0xce, 0xa1, 0x2a, 0xac, 0x36, 0x91, 0x33, 0x55, 0x0f, 0xfa, 0x30, 0xe7, 0x6c, 0x91, 0x71, 0x71, + 0xcc, 0xaf, 0xd9, 0x19, 0x7f, 0xf6, 0x36, 0xab, 0x45, 0x56, 0xcc, 0x75, 0xc6, 0xf4, 0x84, 0xb0, + 0x84, 0xc1, 0xc4, 0xd9, 0xa2, 0x5e, 0x25, 0x9b, 0xb8, 0x81, 0xb2, 0x3c, 0x67, 0x6f, 0xd0, 0xc4, + 0x0d, 0x5a, 0x34, 0x1c, 0x91, 0xb8, 0x85, 0x78, 0xbb, 0xc5, 0x6f, 0x9c, 0xeb, 0x3b, 0xbb, 0xce, + 0x78, 0x9b, 0x43, 0x53, 0xd6, 0x20, 0x48, 0xec, 0xb2, 0x06, 0x15, 0xec, 0xfa, 0xd7, 0xf8, 0xb7, + 0x5d, 0xec, 0x21, 0x61, 0xa7, 0xdb, 0xcd, 0x1e, 0x0d, 0x20, 0x11, 0x57, 0xf6, 0x64, 0x20, 0xe5, + 0xaa, 0x7b, 0x30, 0xf0, 0xd1, 0x00, 0xd2, 0x79, 0x5d, 0xe0, 0x3e, 0xd6, 0xd3, 0x64, 0x76, 0x35, + 0xaf, 0xf8, 0xb2, 0x48, 0xf7, 0x78, 0xce, 0x2b, 0xf0, 0xba, 0xc0, 0x2b, 0x35, 0x40, 0x89, 0xd7, + 0x05, 0x3d, 0x2a, 0x36, 0x73, 0x76, 0x4b, 0x31, 0xce, 0xb3, 0x39, 0xdc, 0x01, 0xf3, 0x0c, 0x49, + 0x80, 0xc8, 0x9c, 0x51, 0x10, 0x09, 0x22, 0xb5, 0x43, 0x26, 0xb2, 0x59, 0x92, 0x2b, 0x7f, 0xdb, + 0xb4, 0x19, 0x0f, 0xec, 0x0d, 0x22, 0x44, 0x01, 0x79, 0xce, 0xb3, 0x65, 0x55, 0x4c, 0x0a, 0xc1, + 0xc9, 0xe7, 0x6c, 0x81, 0xde, 0xe7, 0x74, 0x40, 0x30, 0xac, 0x9e, 0xb1, 0xb7, 0x4d, 0x69, 0x9a, + 0x7f, 0xb0, 0x61, 0xb5, 0xf9, 0x7b, 0xac, 0xe5, 0xa1, 0x61, 0x15, 0x70, 0xe0, 0x61, 0xb4, 0x13, + 0x15, 0x30, 0x01, 0x6d, 0x3f, 0x4c, 0x1e, 0xf6, 0x83, 0xb8, 0x9f, 0xa9, 0x58, 0xe5, 0x2c, 0xe4, + 0x47, 0x02, 0x43, 0xfc, 0xb4, 0xa0, 0xdd, 0x6e, 0xf1, 0x9e, 0xe7, 0x92, 0xcd, 0xae, 0x3a, 0x27, + 0x8c, 0xfd, 0x82, 0x2a, 0x84, 0xd8, 0x6e, 0x21, 0x50, 0xbc, 0x89, 0x26, 0x33, 0x5e, 0x84, 0x9a, + 0xa8, 0x91, 0x0f, 0x69, 0x22, 0xcd, 0xd9, 0x4d, 0x07, 0x23, 0xd5, 0x91, 0xa9, 0x9a, 0x69, 0x83, + 0xb0, 0xe0, 0x42, 0xc4, 0xa6, 0x03, 0x09, 0xdb, 0x9c, 0x1c, 0xfa, 0x3c, 0xee, 0x7e, 0x7e, 0xd5, + 0xb1, 0x72, 0x4c, 0x7f, 0x7e, 0x45, 0xb1, 0xf4, 0x43, 0xaa, 0x18, 0xe9, 0xb1, 0xe2, 0xc7, 0xc9, + 0xe6, 0x30, 0xd8, 0x2e, 0x79, 0x3c, 0x9f, 0x7b, 0x39, 0x4b, 0x2a, 0xe5, 0x75, 0x2b, 0x60, 0xc8, + 0x62, 0xc4, 0x92, 0x27, 0x80, 0x83, 0x21, 0xcc, 0xf3, 0xbc, 0xc7, 0x0b, 0xc1, 0x0a, 0x81, 0x0d, + 0x61, 0xbe, 0x31, 0x0d, 0x86, 0x86, 0x30, 0x4a, 0x01, 0xc4, 0xad, 0xde, 0x99, 0x7c, 0x9e, 0x2c, + 0xd0, 0x8c, 0xad, 0xdd, 0x6b, 0x6c, 0xe4, 0xa1, 0xb8, 0x05, 0x9c, 0xb3, 0xda, 0x77, 0xbd, 0x9c, + 0x25, 0xd5, 0xdc, 0xec, 0x2a, 0xa5, 0xa3, 0x1d, 0xda, 0x8e, 0x4f, 0x12, 0xab, 0xfd, 0xb0, 0x06, + 0x18, 0x76, 0x26, 0x8b, 0x64, 0x6e, 0x9e, 0x14, 0x79, 0x02, 0x29, 0xef, 0x3c, 0xea, 0xc3, 0x7e, + 0x10, 0xf8, 0x39, 0xcf, 0x52, 0xc6, 0x03, 0x7e, 0xa4, 0x7c, 0x88, 0x1f, 0x08, 0x82, 0xec, 0x4d, + 0x6e, 0xbe, 0xaa, 0x5b, 0x35, 0x8b, 0x54, 0xaf, 0x63, 0x63, 0xa2, 0x7a, 0x00, 0x17, 0xca, 0xde, + 0x08, 0x1e, 0xf4, 0xd1, 0xf6, 0x85, 0x4a, 0xa8, 0x8f, 0x9a, 0xf7, 0x25, 0x43, 0xfa, 0x28, 0x06, + 0x6b, 0x9f, 0x3f, 0xd0, 0x7d, 0x74, 0x3f, 0x11, 0x49, 0x93, 0xb7, 0x9f, 0x67, 0xec, 0x8d, 0x5e, + 0x08, 0x23, 0xcf, 0xdb, 0x52, 0xb1, 0xbc, 0x0b, 0x04, 0xac, 0x8a, 0xb7, 0x07, 0xf3, 0x01, 0xdf, + 0x7a, 0x85, 0xd0, 0xeb, 0x1b, 0x2c, 0x15, 0xb6, 0x07, 0xf3, 0x01, 0xdf, 0xfa, 0xea, 0xa4, 0x5e, + 0xdf, 0xe0, 0xfe, 0xa4, 0xed, 0xc1, 0xbc, 0xf6, 0xfd, 0xe7, 0x6d, 0xc7, 0x75, 0x9d, 0x37, 0x79, + 0xd8, 0x4c, 0x64, 0xd7, 0x0c, 0x4b, 0x27, 0x7d, 0x7b, 0x06, 0x0d, 0xa5, 0x93, 0xb4, 0x8a, 0x73, + 0x85, 0x2f, 0x56, 0x8a, 0x13, 0x5e, 0x67, 0xf2, 0xe0, 0xda, 0x93, 0x01, 0x46, 0x5b, 0x38, 0xb4, + 0x68, 0x0a, 0x29, 0xd9, 0x93, 0x30, 0x1e, 0x6a, 0x3f, 0x28, 0xda, 0x0c, 0xd8, 0xeb, 0x7e, 0x57, + 0xb4, 0x35, 0x90, 0xb6, 0x67, 0x52, 0x3c, 0xa6, 0x3d, 0x4d, 0x30, 0x65, 0xe8, 0x2c, 0x61, 0x4c, + 0x99, 0x53, 0x26, 0xee, 0xb1, 0x8a, 0x9d, 0xe1, 0x0a, 0x3d, 0xee, 0xc7, 0x69, 0x3a, 0xcc, 0xbd, + 0x7b, 0x1c, 0x67, 0x67, 0xb8, 0x82, 0x76, 0xff, 0x97, 0xed, 0xb2, 0x06, 0xfa, 0xd7, 0x7d, 0x70, + 0x77, 0x88, 0x45, 0xd0, 0x0f, 0x9f, 0xdc, 0x48, 0x47, 0x17, 0xe4, 0x6f, 0xda, 0xf5, 0x7b, 0x8b, + 0xca, 0xaf, 0x3a, 0xe5, 0xa9, 0x06, 0xdd, 0x25, 0x43, 0x51, 0x65, 0x61, 0xd8, 0x31, 0x3f, 0xba, + 0xa1, 0x96, 0x73, 0x9f, 0xb4, 0x07, 0xeb, 0x9b, 0x0d, 0x9c, 0xf2, 0x84, 0x2c, 0x3b, 0x34, 0x2c, + 0xd0, 0xc7, 0x37, 0x55, 0xa3, 0xba, 0xaa, 0x03, 0xcb, 0xbb, 0xe4, 0x9e, 0x0c, 0x34, 0xec, 0xdd, + 0x2e, 0xf7, 0xe1, 0xcd, 0x94, 0x74, 0x59, 0xfe, 0x7d, 0x2d, 0xba, 0xef, 0xb1, 0xf6, 0x35, 0x12, + 0xd8, 0x74, 0xf9, 0x76, 0xc0, 0x3e, 0xa5, 0x64, 0x0a, 0xf7, 0xdb, 0x9f, 0x4f, 0xd9, 0x1e, 0x58, + 0xf5, 0x54, 0x0e, 0xb2, 0x5c, 0xb0, 0xaa, 0x7b, 0xe9, 0xae, 0x6f, 0x57, 0x51, 0x31, 0x7d, 0xe9, + 0x6e, 0x00, 0x77, 0x2e, 0xdd, 0x45, 0x3c, 0xa3, 0x97, 0xee, 0xa2, 0xd6, 0x82, 0x97, 0xee, 0x86, + 0x35, 0xa8, 0xd9, 0xa5, 0x2d, 0x82, 0xda, 0x36, 0x1f, 0x64, 0xd1, 0xdf, 0x45, 0xdf, 0xbd, 0x89, + 0x0a, 0x31, 0xbf, 0x2a, 0x4e, 0x1e, 0x3d, 0x1f, 0x50, 0xa7, 0xde, 0xf1, 0xf3, 0xed, 0xc1, 0xbc, + 0xf6, 0xfd, 0x7d, 0xbd, 0xb8, 0x32, 0xb3, 0x09, 0xaf, 0xe4, 0x85, 0xcb, 0x1b, 0xa1, 0xd9, 0xa1, + 0xb1, 0xe0, 0xb6, 0xfc, 0xe6, 0x30, 0x98, 0x78, 0xdc, 0x86, 0xd0, 0x8d, 0x1e, 0xf7, 0x19, 0x02, + 0x4d, 0xbe, 0x3d, 0x98, 0x27, 0xa6, 0x11, 0xe5, 0x5b, 0xb5, 0xf6, 0x00, 0x63, 0x7e, 0x5b, 0xef, + 0x0c, 0x57, 0xd0, 0xee, 0xaf, 0x75, 0xd6, 0xea, 0xba, 0x97, 0xed, 0xbc, 0xd5, 0x67, 0x6a, 0xea, + 0x35, 0x73, 0x3c, 0x14, 0x0f, 0xe5, 0x2f, 0xee, 0x14, 0xda, 0x97, 0xbf, 0xa0, 0xd3, 0xe8, 0x87, + 0x37, 0x53, 0xd2, 0x65, 0xf9, 0xfb, 0xb5, 0xe8, 0x3d, 0xb2, 0x2c, 0x3a, 0x0e, 0x3e, 0x1e, 0x6a, + 0x19, 0xc4, 0xc3, 0x27, 0x37, 0xd6, 0xd3, 0x85, 0xfa, 0xa7, 0xb5, 0xe8, 0x76, 0xa0, 0x50, 0x2a, + 0x40, 0x6e, 0x60, 0xdd, 0x0f, 0x94, 0x4f, 0x6f, 0xae, 0x48, 0x4d, 0xf7, 0x2e, 0x3e, 0xed, 0x5e, + 0x21, 0x1a, 0xb0, 0x3d, 0xa5, 0xaf, 0x10, 0xed, 0xd7, 0x82, 0x7b, 0x4c, 0xc9, 0xeb, 0x76, 0xcd, + 0x87, 0xee, 0x31, 0xc9, 0xb3, 0xdb, 0xc1, 0x2b, 0xac, 0x30, 0x0e, 0x73, 0xf2, 0xec, 0x6d, 0x99, + 0x14, 0x29, 0xed, 0x44, 0xc9, 0xfb, 0x9d, 0x18, 0x0e, 0xee, 0xcd, 0x35, 0xd2, 0x53, 0xde, 0xae, + 0xe3, 0x1e, 0x51, 0xfa, 0x06, 0x09, 0xee, 0xcd, 0x75, 0x50, 0xc2, 0x9b, 0xce, 0x1a, 0x43, 0xde, + 0x40, 0xb2, 0xf8, 0x78, 0x08, 0x0a, 0x56, 0x08, 0xc6, 0x9b, 0xd9, 0xf2, 0xdf, 0x0c, 0x59, 0xe9, + 0x6c, 0xfb, 0x6f, 0x0d, 0xa4, 0x09, 0xb7, 0x53, 0x26, 0x3e, 0x63, 0x49, 0xca, 0xaa, 0xa0, 0x5b, + 0x43, 0x0d, 0x72, 0xeb, 0xd2, 0x98, 0xdb, 0x3d, 0x9e, 0x2f, 0x17, 0x85, 0x6e, 0x4c, 0xd2, 0xad, + 0x4b, 0xf5, 0xbb, 0x05, 0x34, 0xdc, 0x95, 0xb4, 0x6e, 0x65, 0x7a, 0xf9, 0x38, 0x6c, 0xc6, 0xcb, + 0x2a, 0x37, 0x06, 0xb1, 0xf4, 0x73, 0xea, 0x30, 0xea, 0x79, 0x4e, 0x10, 0x49, 0x5b, 0x03, 0x69, + 0xb8, 0x3d, 0xe8, 0xb8, 0x35, 0xf1, 0xb4, 0xdd, 0x63, 0xab, 0x13, 0x52, 0x3b, 0xc3, 0x15, 0xe0, + 0x66, 0xac, 0x8e, 0xaa, 0xa3, 0xac, 0x16, 0x07, 0x59, 0x9e, 0x8f, 0x36, 0x02, 0x61, 0xd2, 0x42, + 0xc1, 0xcd, 0x58, 0x04, 0x26, 0x22, 0xb9, 0xdd, 0xbc, 0x2c, 0x46, 0x7d, 0x76, 0x24, 0x35, 0x28, + 0x92, 0x5d, 0x1a, 0x6c, 0xa8, 0x39, 0x55, 0x6d, 0x9e, 0x36, 0x0e, 0x57, 0x5c, 0xe7, 0x81, 0xb7, + 0x07, 0xf3, 0xe0, 0x6d, 0xbf, 0xa4, 0xe4, 0xcc, 0x72, 0x8f, 0x32, 0xe1, 0xcd, 0x24, 0xf7, 0x7b, + 0x28, 0xb0, 0x29, 0xa9, 0xba, 0xd1, 0xab, 0x2c, 0x9d, 0x33, 0x81, 0xbe, 0xa8, 0x72, 0x81, 0xe0, + 0x8b, 0x2a, 0x00, 0x82, 0xa6, 0x53, 0x7f, 0x37, 0xbb, 0xb1, 0x93, 0x14, 0x6b, 0x3a, 0xad, 0xec, + 0x50, 0xa1, 0xa6, 0x43, 0x69, 0x30, 0x1a, 0x18, 0xb7, 0xfa, 0x62, 0x9e, 0xc7, 0x21, 0x33, 0xe0, + 0x76, 0x9e, 0x8d, 0x41, 0x2c, 0x98, 0x51, 0xac, 0x43, 0x79, 0xea, 0xf4, 0x51, 0xd0, 0x86, 0x77, + 0xe0, 0xf4, 0xf1, 0x10, 0x94, 0x7a, 0xbc, 0x26, 0x47, 0x98, 0xa4, 0xe1, 0xc7, 0x53, 0xcc, 0xb0, + 0xc7, 0x33, 0x6c, 0xe7, 0xbd, 0x6a, 0x61, 0x42, 0x46, 0x5c, 0xea, 0xc5, 0x32, 0x12, 0xdb, 0xce, + 0x4f, 0x3b, 0x59, 0x30, 0x34, 0xea, 0x50, 0x0a, 0xf0, 0x7d, 0x41, 0xfb, 0x4b, 0x4c, 0x53, 0x26, + 0xc6, 0x65, 0xc9, 0x92, 0x2a, 0x29, 0x66, 0xe8, 0xe2, 0xd4, 0xfc, 0xb2, 0x92, 0x47, 0x86, 0x16, + 0xa7, 0xa4, 0x06, 0x78, 0x6b, 0xef, 0x5f, 0x47, 0x80, 0x74, 0x05, 0x73, 0x81, 0xa0, 0x7f, 0x1b, + 0xc1, 0xa3, 0x01, 0x24, 0x7c, 0x6b, 0xdf, 0x02, 0x66, 0xdf, 0x5d, 0x39, 0xfd, 0x20, 0x60, 0xca, + 0x47, 0x43, 0x0b, 0x61, 0x5a, 0x05, 0x04, 0xb5, 0xb3, 0xb7, 0xf8, 0x5d, 0xb6, 0xc2, 0x82, 0xda, + 0xdd, 0x24, 0xfc, 0x2e, 0x5b, 0x85, 0x82, 0xba, 0x8b, 0x82, 0x3c, 0xd3, 0x5d, 0x07, 0x3d, 0x08, + 0xe8, 0xbb, 0x4b, 0x9f, 0xf5, 0x5e, 0x0e, 0xf4, 0x9c, 0xfd, 0xec, 0xda, 0x7b, 0x4d, 0x81, 0x14, + 0x74, 0x3f, 0xbb, 0xc6, 0xdf, 0x52, 0x6c, 0x0c, 0x62, 0xe1, 0x89, 0x80, 0x44, 0xb0, 0xb7, 0xed, + 0xab, 0x7a, 0xa4, 0xb8, 0x52, 0xde, 0x79, 0x57, 0xff, 0xb0, 0x1f, 0xb4, 0xe7, 0x9e, 0x4f, 0x2a, + 0x3e, 0x63, 0x75, 0xad, 0x6f, 0x20, 0xf7, 0x0f, 0x38, 0x69, 0x59, 0x0c, 0xee, 0x1f, 0xbf, 0x17, + 0x86, 0x9c, 0x4b, 0x6c, 0x95, 0xc8, 0xde, 0x7f, 0xf7, 0x00, 0xd5, 0xec, 0x5e, 0x7d, 0xb7, 0xde, + 0xcb, 0xd9, 0xee, 0xa5, 0xa5, 0xee, 0x85, 0x77, 0x0f, 0x51, 0x75, 0xec, 0xae, 0xbb, 0x47, 0x03, + 0x48, 0xed, 0xea, 0xb3, 0xe8, 0x8b, 0x47, 0x7c, 0x3e, 0x65, 0x45, 0x3a, 0xfa, 0xa6, 0x7f, 0x82, + 0x97, 0xcf, 0xe3, 0xe6, 0xcf, 0xc6, 0xe8, 0x2d, 0x4a, 0x6c, 0xcf, 0x20, 0xee, 0xb3, 0xd7, 0xcb, + 0xf9, 0x54, 0x24, 0x02, 0x9c, 0x41, 0x94, 0x7f, 0x8f, 0x1b, 0x01, 0x71, 0x06, 0xd1, 0x03, 0x80, + 0xbd, 0xb3, 0x8a, 0x31, 0xd4, 0x5e, 0x23, 0x08, 0xda, 0xd3, 0x80, 0xcd, 0x22, 0x8c, 0xbd, 0x26, + 0x51, 0x87, 0x67, 0x06, 0xad, 0x8e, 0x94, 0x12, 0x59, 0x44, 0x97, 0xb2, 0xc1, 0xad, 0x1e, 0x5f, + 0x5e, 0xd8, 0xb8, 0x5c, 0x2c, 0x92, 0x6a, 0x05, 0x82, 0x5b, 0x3f, 0xa5, 0x03, 0x10, 0xc1, 0x8d, + 0x82, 0xb6, 0xd7, 0xb6, 0xd5, 0x3c, 0xbb, 0x3a, 0xe4, 0x15, 0x5f, 0x8a, 0xac, 0x60, 0xf0, 0x02, + 0x28, 0x53, 0xa1, 0x2e, 0x43, 0xf4, 0x5a, 0x8a, 0xb5, 0x59, 0xae, 0x24, 0xd4, 0x71, 0x46, 0xf9, + 0x53, 0x2f, 0xb5, 0xe0, 0x15, 0x7c, 0x9d, 0xa9, 0xac, 0x40, 0x88, 0xc8, 0x72, 0x49, 0x18, 0xb4, + 0xfd, 0x49, 0x56, 0xcc, 0xd1, 0xb6, 0x3f, 0x71, 0x6f, 0xf5, 0xbf, 0x4d, 0x03, 0xb6, 0x43, 0xa9, + 0x4a, 0x53, 0x1d, 0x40, 0x5f, 0xaf, 0x80, 0x56, 0xba, 0x4b, 0x10, 0x1d, 0x0a, 0x27, 0x81, 0xab, + 0x17, 0x25, 0x2b, 0x58, 0xda, 0x1e, 0xda, 0xc3, 0x5c, 0x79, 0x44, 0xd0, 0x15, 0x24, 0xed, 0x58, + 0x24, 0xe5, 0xa7, 0xcb, 0xe2, 0xa4, 0xe2, 0x17, 0x59, 0xce, 0x2a, 0x30, 0x16, 0x29, 0x75, 0x47, + 0x4e, 0x8c, 0x45, 0x18, 0x67, 0x4f, 0x7f, 0x48, 0xa9, 0xf7, 0x7b, 0x45, 0x67, 0x55, 0x32, 0x83, + 0xa7, 0x3f, 0x94, 0x8d, 0x2e, 0x46, 0xec, 0x0c, 0x06, 0x70, 0x27, 0xd1, 0x51, 0xae, 0x8b, 0x95, + 0x8c, 0x0f, 0xfd, 0x95, 0xbd, 0xbc, 0xc5, 0x1d, 0x7e, 0x06, 0xa1, 0xcd, 0x61, 0x24, 0x91, 0xe8, + 0x84, 0x35, 0xec, 0x54, 0x22, 0xb9, 0xe7, 0xfa, 0x54, 0x13, 0x98, 0x4a, 0x94, 0x8d, 0x56, 0x48, + 0x4c, 0x25, 0x1d, 0x08, 0x8c, 0x18, 0xaa, 0x1b, 0x9c, 0x32, 0x79, 0xd6, 0x78, 0x9d, 0xec, 0x27, + 0x0a, 0x08, 0x8e, 0x18, 0x00, 0x04, 0x11, 0xa9, 0xef, 0xd3, 0xd3, 0x8e, 0x30, 0x7d, 0x8f, 0x08, + 0x46, 0x24, 0x24, 0xed, 0xe0, 0x34, 0x29, 0x32, 0x91, 0x25, 0xf9, 0x94, 0x89, 0x93, 0xa4, 0x4a, + 0x16, 0x4c, 0xb0, 0x0a, 0x0e, 0x4e, 0x1a, 0x89, 0x3d, 0x86, 0x18, 0x9c, 0x28, 0x56, 0x3b, 0xfc, + 0x9d, 0xe8, 0xab, 0x4d, 0xae, 0xc1, 0x0a, 0xfd, 0xdb, 0x96, 0xcf, 0xe4, 0x8f, 0x23, 0x8f, 0xde, + 0x31, 0x36, 0xa6, 0xa2, 0x62, 0xc9, 0xa2, 0xb5, 0xfd, 0x15, 0xf3, 0x77, 0x09, 0xee, 0xac, 0x35, + 0x7d, 0xe8, 0x39, 0x17, 0xd9, 0x45, 0xb3, 0xb4, 0xd7, 0x1f, 0xab, 0x81, 0x3e, 0xe4, 0x8a, 0xe3, + 0xc0, 0x95, 0x54, 0x18, 0x67, 0x5b, 0xda, 0x95, 0x9e, 0xb2, 0x32, 0x87, 0x73, 0x83, 0xa7, 0x2d, + 0x01, 0xa2, 0xa5, 0x51, 0xd0, 0x0e, 0x08, 0xae, 0xf8, 0x8c, 0x85, 0x1f, 0xe6, 0x8c, 0x0d, 0x7b, + 0x98, 0x33, 0xef, 0x3b, 0x94, 0x3c, 0xfa, 0xea, 0x31, 0x5b, 0xbc, 0x56, 0xbf, 0xa1, 0x40, 0xdd, + 0x48, 0x6f, 0x89, 0xde, 0x1b, 0xe9, 0x09, 0xd4, 0xce, 0x3e, 0x16, 0x98, 0xd4, 0xcf, 0x93, 0x05, + 0x93, 0x17, 0x6c, 0x81, 0xd9, 0xc7, 0x31, 0xe2, 0x40, 0xc4, 0xec, 0x43, 0xc2, 0xce, 0xa7, 0x84, + 0x96, 0x39, 0x65, 0xf3, 0x26, 0xc2, 0xaa, 0x93, 0x64, 0xb5, 0x60, 0x85, 0xd0, 0x26, 0xc1, 0x7b, + 0x00, 0xc7, 0x24, 0xce, 0x13, 0xef, 0x01, 0x86, 0xe8, 0x39, 0xc3, 0xa1, 0x57, 0xf1, 0x27, 0xbc, + 0x12, 0xfa, 0x17, 0x81, 0xab, 0x1c, 0x0c, 0x87, 0x7e, 0xa5, 0x7a, 0x24, 0x31, 0x1c, 0x86, 0x35, + 0x9c, 0xdf, 0xe9, 0xf2, 0xca, 0x70, 0xce, 0x2a, 0x13, 0x27, 0xcf, 0x16, 0x49, 0x96, 0xeb, 0x68, + 0xf8, 0x56, 0xc0, 0x36, 0xa1, 0x43, 0xfc, 0x4e, 0xd7, 0x50, 0x5d, 0xe7, 0x97, 0xcd, 0xc2, 0x25, + 0x04, 0xaf, 0x25, 0x7a, 0xec, 0x13, 0xaf, 0x25, 0xfa, 0xb5, 0xec, 0x6e, 0x81, 0x65, 0x25, 0xb7, + 0x92, 0xc4, 0x1e, 0x4f, 0xe1, 0x1e, 0xa5, 0x63, 0x13, 0x80, 0xc4, 0x6e, 0x41, 0x50, 0xc1, 0x0e, + 0xfe, 0x16, 0x3b, 0xc8, 0x8a, 0x24, 0xcf, 0x7e, 0x00, 0x97, 0x12, 0x8e, 0x9d, 0x96, 0x20, 0x06, + 0x7f, 0x9c, 0xc4, 0x5c, 0x1d, 0x32, 0x71, 0x96, 0x35, 0x43, 0xff, 0xc3, 0x40, 0xbd, 0x49, 0xa2, + 0xdf, 0x95, 0x43, 0x3a, 0x37, 0xc4, 0xc3, 0x6a, 0x1d, 0x97, 0xe5, 0xb4, 0x99, 0xc9, 0x4f, 0xd9, + 0x8c, 0x65, 0xa5, 0x18, 0x7d, 0x14, 0xae, 0x2b, 0x80, 0x13, 0x87, 0x3b, 0x06, 0xa8, 0x61, 0x03, + 0x55, 0xd3, 0x06, 0x87, 0xfa, 0x77, 0x5f, 0xc9, 0x81, 0xca, 0x81, 0xfa, 0x07, 0x2a, 0x1f, 0xb6, + 0xd3, 0xad, 0xef, 0xf3, 0x94, 0xa5, 0x8c, 0x2d, 0x46, 0x8f, 0x43, 0x56, 0x14, 0x43, 0x4c, 0xb7, + 0x14, 0x6b, 0x93, 0x41, 0xa7, 0xda, 0x77, 0x9b, 0x81, 0xa2, 0xe2, 0xe9, 0xb2, 0xc9, 0x70, 0xb7, + 0x08, 0x3b, 0xe7, 0xbb, 0xb1, 0x83, 0x11, 0xc9, 0x60, 0x00, 0xc7, 0xaa, 0x57, 0x7a, 0xd6, 0x23, + 0xcd, 0x46, 0xd0, 0x10, 0x18, 0x5a, 0x36, 0x87, 0xc1, 0x68, 0xdf, 0xdd, 0xf5, 0x86, 0x45, 0xb2, + 0xef, 0xea, 0x07, 0x30, 0x60, 0x6f, 0xdf, 0x45, 0x14, 0xd0, 0x11, 0xff, 0x7c, 0x77, 0x5c, 0xac, + 0x9a, 0xd9, 0x6a, 0x52, 0xab, 0x19, 0x30, 0x60, 0xd0, 0x27, 0x7b, 0x47, 0x7c, 0x4c, 0xc3, 0xd9, + 0x7e, 0x43, 0xca, 0x30, 0xce, 0x73, 0x2e, 0x5f, 0xb3, 0xf4, 0x9b, 0x6c, 0x51, 0x62, 0xfb, 0xad, + 0x47, 0x05, 0x4b, 0x3a, 0xce, 0x77, 0xf7, 0x92, 0x4a, 0x1c, 0x32, 0x41, 0x26, 0x1d, 0xe7, 0xbb, + 0xb1, 0x46, 0x7a, 0x93, 0x0e, 0x0f, 0xb5, 0x3b, 0xf5, 0xd0, 0x9b, 0x3e, 0x31, 0xb6, 0x19, 0xb6, + 0x02, 0x0e, 0x8a, 0x6d, 0x0d, 0xa4, 0xd1, 0xb9, 0xeb, 0x7c, 0xd7, 0xec, 0x0c, 0x9d, 0x71, 0x85, + 0xd6, 0xe4, 0xdc, 0x75, 0xbe, 0x1b, 0x77, 0xe9, 0xde, 0xb9, 0x8b, 0xd2, 0x72, 0x4e, 0x41, 0x35, + 0xcd, 0x31, 0x65, 0xd5, 0x75, 0x36, 0x63, 0x2f, 0x6b, 0x56, 0xe9, 0xf5, 0x5a, 0x53, 0xf7, 0x3b, + 0xe0, 0x4e, 0x04, 0xc3, 0xc5, 0x0e, 0x18, 0xbb, 0x4d, 0xf0, 0xc1, 0x0d, 0x34, 0x6c, 0x4b, 0x38, + 0x9c, 0xbe, 0x9d, 0x49, 0x9e, 0x1a, 0xdf, 0x24, 0x8d, 0x39, 0x14, 0xd1, 0x12, 0x34, 0x6d, 0xc7, + 0xb9, 0xae, 0xdb, 0x71, 0xb1, 0x9a, 0xc0, 0x93, 0x67, 0x88, 0x25, 0x89, 0x11, 0xe3, 0x5c, 0x00, + 0x77, 0xde, 0x29, 0x56, 0x3c, 0x49, 0x67, 0x49, 0x2d, 0x4e, 0x92, 0x55, 0xce, 0x93, 0x54, 0x2e, + 0x55, 0xe0, 0x3b, 0xc5, 0x96, 0x89, 0x5d, 0x88, 0x7a, 0xa7, 0x48, 0xc1, 0xee, 0x22, 0xb7, 0x29, + 0x53, 0x7b, 0x22, 0x1f, 0x2e, 0x72, 0x65, 0x79, 0xe1, 0x69, 0xfc, 0x7b, 0x61, 0xc8, 0x7e, 0x49, + 0xac, 0x44, 0x72, 0x65, 0x75, 0x1b, 0xd3, 0xf1, 0xd6, 0x54, 0x77, 0x02, 0x84, 0xbd, 0xf8, 0x4e, + 0xfd, 0xbd, 0xfd, 0xc5, 0x61, 0xa1, 0x7f, 0x1a, 0x68, 0x13, 0xd3, 0x75, 0x21, 0xef, 0xa0, 0xef, + 0xd6, 0x40, 0xda, 0x6e, 0x1f, 0xee, 0x5d, 0x26, 0x62, 0x9c, 0xa6, 0xc7, 0xac, 0x46, 0xee, 0xd6, + 0x69, 0x84, 0xb1, 0x95, 0x12, 0xdb, 0x87, 0x5d, 0xca, 0x06, 0x7a, 0x23, 0x7b, 0x96, 0x66, 0x42, + 0xcb, 0xda, 0xef, 0x5c, 0x36, 0xbb, 0x06, 0xba, 0x14, 0xf1, 0x54, 0x34, 0x6d, 0xa7, 0xb8, 0x86, + 0x39, 0xe3, 0xf3, 0x79, 0xce, 0x34, 0x74, 0xca, 0x12, 0x75, 0x2d, 0xf9, 0x76, 0xd7, 0x16, 0x0a, + 0x12, 0x53, 0x5c, 0x50, 0xc1, 0xae, 0x8c, 0x1b, 0x4c, 0xbd, 0xd9, 0x6f, 0x2b, 0x76, 0xbd, 0x6b, + 0xc6, 0x03, 0x88, 0x95, 0x31, 0x0a, 0xda, 0xaf, 0x97, 0x1b, 0xf1, 0x21, 0x6b, 0x6b, 0x02, 0x5e, + 0xae, 0x2a, 0x95, 0x1d, 0x31, 0xf1, 0xf5, 0x32, 0x82, 0xd9, 0x5c, 0x0c, 0x78, 0x78, 0xba, 0x9a, + 0xa4, 0x70, 0xeb, 0x03, 0xea, 0x4b, 0x86, 0xc8, 0xc5, 0x28, 0xd6, 0x6f, 0x3a, 0x33, 0x80, 0x1f, + 0x25, 0xb5, 0x7d, 0x38, 0xa4, 0xe9, 0x50, 0x30, 0xd4, 0x74, 0x94, 0x82, 0x5f, 0xa5, 0xee, 0x1b, + 0x0a, 0xa4, 0x4a, 0xb1, 0xd7, 0x13, 0x0f, 0xfa, 0x30, 0xbb, 0x9d, 0xd1, 0x08, 0x4f, 0x59, 0x92, + 0x9a, 0x07, 0x43, 0x74, 0x5d, 0x39, 0xb1, 0x9d, 0x81, 0x71, 0xda, 0xc9, 0xef, 0x47, 0x23, 0xf5, + 0x18, 0x95, 0xeb, 0xe6, 0x36, 0x56, 0xc4, 0x86, 0x20, 0x06, 0x2a, 0x9f, 0x70, 0xe6, 0x73, 0xaf, + 0x89, 0xce, 0xb8, 0x76, 0xa0, 0xbf, 0xae, 0x87, 0xf3, 0xb9, 0x5f, 0xed, 0x1d, 0x9a, 0x98, 0xcf, + 0xfb, 0xb5, 0x9c, 0xeb, 0x1e, 0x41, 0x93, 0x1d, 0x54, 0x7c, 0x01, 0xcb, 0xf4, 0x69, 0xb0, 0x79, + 0x10, 0x0d, 0xe2, 0xba, 0xc7, 0x61, 0x9a, 0xf0, 0x67, 0x02, 0xf5, 0x20, 0x8b, 0xff, 0x4c, 0xa0, + 0x16, 0x86, 0x7f, 0x26, 0xd0, 0x42, 0x9d, 0x1f, 0x8e, 0x48, 0xd3, 0xfd, 0xac, 0x9e, 0x2d, 0xe5, + 0x56, 0x21, 0x7e, 0xf5, 0xba, 0x95, 0xf7, 0xdd, 0x10, 0xde, 0x41, 0xed, 0xe5, 0x11, 0x6d, 0xd4, + 0x8e, 0xf3, 0x7c, 0x74, 0x07, 0x0f, 0x44, 0xf7, 0x36, 0xa4, 0xbb, 0x21, 0xc4, 0x1f, 0x28, 0x9b, + 0xbf, 0xb7, 0xa3, 0x68, 0x3d, 0x22, 0x82, 0xdc, 0x00, 0xa1, 0x81, 0x12, 0x82, 0x76, 0xbe, 0x96, + 0x21, 0xab, 0xee, 0xe7, 0x41, 0xba, 0x01, 0xb8, 0x9b, 0xe7, 0x4e, 0x80, 0xb0, 0x29, 0x8d, 0x32, + 0x29, 0x4e, 0xb2, 0xa2, 0x60, 0xb6, 0x93, 0x6d, 0x60, 0xba, 0x00, 0x22, 0x52, 0x1a, 0x12, 0xf6, + 0x7d, 0x1e, 0x0e, 0xf1, 0x79, 0x78, 0x13, 0x9f, 0x87, 0xb4, 0xcf, 0x1f, 0xad, 0x45, 0xdf, 0xd4, + 0xb1, 0xe7, 0x6e, 0x9f, 0x9a, 0x9e, 0x59, 0x81, 0xa3, 0xcc, 0x6d, 0x2e, 0x80, 0xc3, 0xc4, 0x51, + 0xe6, 0x5e, 0x25, 0x67, 0x0f, 0x53, 0x05, 0xcc, 0x82, 0x5f, 0x33, 0xa2, 0x3c, 0x1f, 0x63, 0x61, + 0x41, 0xf3, 0xc4, 0x1e, 0xe6, 0x10, 0x3d, 0x9b, 0x44, 0x8d, 0x27, 0xaf, 0xaa, 0x4c, 0x64, 0xc5, + 0xfc, 0x8c, 0xf3, 0x1c, 0xbe, 0x83, 0x1d, 0x4f, 0x62, 0x57, 0x4a, 0x24, 0x51, 0x5d, 0xca, 0x06, + 0xef, 0x78, 0x32, 0x5e, 0x0a, 0x7e, 0x91, 0xe5, 0x39, 0x08, 0xde, 0xf1, 0x24, 0x6e, 0x25, 0x44, + 0xf0, 0xfa, 0x84, 0x1d, 0x97, 0xc6, 0x13, 0x79, 0x9c, 0x41, 0xbf, 0xd2, 0x7d, 0x1f, 0xea, 0x38, + 0x42, 0x62, 0x5c, 0xea, 0x40, 0x36, 0x48, 0xc7, 0x13, 0xec, 0xd7, 0x34, 0x37, 0xa0, 0x3a, 0x02, + 0x11, 0x41, 0x4a, 0xc2, 0x4e, 0x90, 0x9e, 0x2c, 0xeb, 0x4b, 0xff, 0x7d, 0x84, 0xda, 0x79, 0x56, + 0xbf, 0x8d, 0xf1, 0x04, 0xfc, 0x5e, 0xac, 0xcf, 0xc6, 0x1e, 0x4c, 0x04, 0x69, 0xaf, 0x92, 0x73, + 0x95, 0x38, 0x64, 0xa7, 0x4c, 0xa8, 0x5f, 0x0d, 0xe7, 0x29, 0xfc, 0x76, 0xad, 0x63, 0xd6, 0x65, + 0x89, 0x6f, 0xd7, 0xfa, 0x74, 0x9c, 0x0d, 0x45, 0xa4, 0x24, 0x07, 0xbc, 0x52, 0x64, 0x93, 0xc9, + 0x7d, 0xd4, 0x6b, 0xd8, 0xc5, 0x89, 0x0d, 0xc5, 0x01, 0x6a, 0xf6, 0xc8, 0x65, 0xb7, 0xa1, 0x6a, + 0x26, 0x9a, 0xa2, 0xc4, 0x7d, 0xd5, 0xad, 0x38, 0xe2, 0xc8, 0x65, 0x88, 0x57, 0xce, 0x9f, 0xde, + 0xf9, 0xaf, 0x9f, 0xde, 0x5a, 0xfb, 0xc9, 0x4f, 0x6f, 0xad, 0xfd, 0xef, 0x4f, 0x6f, 0xad, 0xfd, + 0xf8, 0x67, 0xb7, 0xbe, 0xf0, 0x93, 0x9f, 0xdd, 0xfa, 0xc2, 0xff, 0xfc, 0xec, 0xd6, 0x17, 0xbe, + 0xf7, 0xc5, 0x5a, 0xad, 0x5f, 0x5f, 0xff, 0x7c, 0x59, 0x71, 0xc1, 0x9f, 0xfc, 0x5f, 0x00, 0x00, + 0x00, 0xff, 0xff, 0x5e, 0x60, 0x22, 0x5b, 0xdf, 0x94, 0x00, 0x00, } // This is a compile-time assertion to ensure that this generated file @@ -457,6 +458,7 @@ type ClientCommandsHandler interface { AccountLocalLinkNewChallenge(context.Context, *pb.RpcAccountLocalLinkNewChallengeRequest) *pb.RpcAccountLocalLinkNewChallengeResponse AccountLocalLinkSolveChallenge(context.Context, *pb.RpcAccountLocalLinkSolveChallengeRequest) *pb.RpcAccountLocalLinkSolveChallengeResponse AccountLocalLinkCreateApp(context.Context, *pb.RpcAccountLocalLinkCreateAppRequest) *pb.RpcAccountLocalLinkCreateAppResponse + AccountLocalLinkUpdateApp(context.Context, *pb.RpcAccountLocalLinkUpdateAppRequest) *pb.RpcAccountLocalLinkUpdateAppResponse AccountLocalLinkListApps(context.Context, *pb.RpcAccountLocalLinkListAppsRequest) *pb.RpcAccountLocalLinkListAppsResponse AccountLocalLinkRevokeApp(context.Context, *pb.RpcAccountLocalLinkRevokeAppRequest) *pb.RpcAccountLocalLinkRevokeAppResponse WalletCreateSession(context.Context, *pb.RpcWalletCreateSessionRequest) *pb.RpcWalletCreateSessionResponse @@ -1036,6 +1038,26 @@ func AccountLocalLinkCreateApp(b []byte) (resp []byte) { return resp } +func AccountLocalLinkUpdateApp(b []byte) (resp []byte) { + defer func() { + if PanicHandler != nil { + if r := recover(); r != nil { + resp, _ = (&pb.RpcAccountLocalLinkUpdateAppResponse{Error: &pb.RpcAccountLocalLinkUpdateAppResponseError{Code: pb.RpcAccountLocalLinkUpdateAppResponseError_UNKNOWN_ERROR, Description: "panic recovered"}}).Marshal() + PanicHandler(r) + } + } + }() + + in := new(pb.RpcAccountLocalLinkUpdateAppRequest) + if err := in.Unmarshal(b); err != nil { + resp, _ = (&pb.RpcAccountLocalLinkUpdateAppResponse{Error: &pb.RpcAccountLocalLinkUpdateAppResponseError{Code: pb.RpcAccountLocalLinkUpdateAppResponseError_BAD_INPUT, Description: err.Error()}}).Marshal() + return resp + } + + resp, _ = clientCommandsHandler.AccountLocalLinkUpdateApp(context.Background(), in).Marshal() + return resp +} + func AccountLocalLinkListApps(b []byte) (resp []byte) { defer func() { if PanicHandler != nil { @@ -7580,6 +7602,8 @@ func CommandAsync(cmd string, data []byte, callback func(data []byte)) { cd = AccountLocalLinkSolveChallenge(data) case "AccountLocalLinkCreateApp": cd = AccountLocalLinkCreateApp(data) + case "AccountLocalLinkUpdateApp": + cd = AccountLocalLinkUpdateApp(data) case "AccountLocalLinkListApps": cd = AccountLocalLinkListApps(data) case "AccountLocalLinkRevokeApp": @@ -8380,6 +8404,20 @@ func (h *ClientCommandsHandlerProxy) AccountLocalLinkCreateApp(ctx context.Conte call, _ := actualCall(ctx, req) return call.(*pb.RpcAccountLocalLinkCreateAppResponse) } +func (h *ClientCommandsHandlerProxy) AccountLocalLinkUpdateApp(ctx context.Context, req *pb.RpcAccountLocalLinkUpdateAppRequest) *pb.RpcAccountLocalLinkUpdateAppResponse { + actualCall := func(ctx context.Context, req any) (any, error) { + return h.client.AccountLocalLinkUpdateApp(ctx, req.(*pb.RpcAccountLocalLinkUpdateAppRequest)), nil + } + for _, interceptor := range h.interceptors { + toCall := actualCall + currentInterceptor := interceptor + actualCall = func(ctx context.Context, req any) (any, error) { + return currentInterceptor(ctx, req, "AccountLocalLinkUpdateApp", toCall) + } + } + call, _ := actualCall(ctx, req) + return call.(*pb.RpcAccountLocalLinkUpdateAppResponse) +} func (h *ClientCommandsHandlerProxy) AccountLocalLinkListApps(ctx context.Context, req *pb.RpcAccountLocalLinkListAppsRequest) *pb.RpcAccountLocalLinkListAppsResponse { actualCall := func(ctx context.Context, req any) (any, error) { return h.client.AccountLocalLinkListApps(ctx, req.(*pb.RpcAccountLocalLinkListAppsRequest)), nil diff --git a/cmd/anytype/SKILL.md b/cmd/anytype/SKILL.md new file mode 100644 index 0000000000..0655d4b096 --- /dev/null +++ b/cmd/anytype/SKILL.md @@ -0,0 +1,97 @@ +--- +name: anytype +description: Read, search, create and edit Anytype objects through the anytype CLI. Use when the user asks to find notes/tasks/pages in Anytype, create objects, tick checkboxes, edit document text, fill tables, or reorganize content. Task-shaped verbs over the local API; results are numbered handles you pass back — never copy long ids. +--- + +# Anytype task tools + +Twelve verbs over the local Anytype API. Everything composes through a +session: `find` numbers its results (1, 2, …) and sets the working space; +every other verb takes `--object `. + +Setup: the local Anytype app must be running; `ANYTYPE_API_KEY` holds an +API key from the app's settings (`ANYTYPE_API_URL` defaults to +`http://127.0.0.1:31009`). + +## The loop + +```sh +anytype spaces # space ids, when none is known +# Work — bafyspace1 +anytype find --space bafyspace1 --type task --filter 'done = false' +# 1. Prepare the Q3 report (task) +# 2. Ship the beta (task) +anytype edit-text --object 1 --find "Q3" --replace "Q4" +# --block is optional: the snippet locates the block when it matches +# exactly one; an ambiguous snippet refuses and lists the candidates +anytype read --object 1 --mode outline # block ids + structure +``` + +1. **spaces** when no space id is known — it lists `name — id`. +2. **find** next — it creates the handles and the working space. +3. **describe** before you create or set properties — property keys and + select option names must match exactly; describe lists the live ones. +4. **read** before you edit blocks — block ids come from read + (`--mode outline` for structure, full mode for text; table row and + column ids come from full mode too). + +## Intent → verb recipes + +| Intent | Verb — not that other thing | +|---|---| +| complete/close a task object | `set-properties --object 1 --set '{"done":true}'` (or the status option describe shows, e.g. `{"status":"Done"}`) — NOT check-item, which is for checkbox blocks inside a document | +| tick a checklist line in a note | `check-item --object 1 --block ab3f2 --checked` | +| change one word/phrase | `edit-text` with a short unique snippet — never retype the block; `--block` only when the snippet alone is ambiguous (the error lists the candidates) | +| delete a word/phrase | `edit-text --find "the phrase" --replace ""` — an empty replacement deletes | +| add notes/sections/checklists | `add-blocks --markdown '…'` — write markdown, the server parses it | +| fill one table cell | `set-cell` — never rewrite the table; row/col ids come from full read | +| clear one table cell | `set-cell … --value ""` — an empty value clears | +| assign to the current user | value `"@me"` — e.g. `--set '{"assignee":"@me"}'` | +| due dates | `today`, `tomorrow`, weekday names, `+3d`, or `2026-08-01` | +| find "my open tasks" | `--filter 'assignee = "@me" AND done = false'` | + +## Filter strings + +`--filter` is a compact expression, not JSON: +`done = false AND (due_date < currentWeek() OR due_date IS EMPTY)` · +`status IN ("In progress", "Blocked")` · `name CONTAINS "report"` · +`last_modified_date > daysAgo(7)`. String values take double quotes; date +presets are functions (`today()`, `currentWeek()`, `daysAgo(n)`). + +## Caveats + +- **Text is markdown source.** `edit-text` find/replace operates on the + block's markup: `**`, `[`, `~~` in a replacement become real formatting. + Escape with `\` when you mean the literal character. +- **Select options are never created by these verbs.** An unknown option + name is an error listing the existing names — fix the spelling (option + names are case-sensitive). `--create-missing` is the deliberate escape. + Type and property KEYS are more forgiving: a wrongly-cased key + (`Status` for `status`) resolves when exactly one key matches; if two + keys differ only by case the error names both. +- **Handles expire on the next find.** Re-run `find` and use the new + numbers. Block ids come from `read` — use them as served, and re-read + after a structural edit rather than reusing remembered ones. A block id + always names an EXISTING block: new content is authored without ids + (`add-blocks` takes none). Every edit receipt names the object it changed + (`ok — "Groceries": …`) — check it matches your intent. +- **One verb, one intent.** There is no batch; run verbs in sequence. + Retries are safe: an identical re-run within a minute is deduplicated, + including after a failed or timed-out attempt. +- Errors are self-describing and name valid alternatives — read them, fix + the named field, retry once. Do not loop blindly. + +## References + +- `anytype tools` — the machine-readable manifest: per-tool JSON schema, + worked example, GBNF grammar (constrained decoding), and the filter + grammar (EBNF + GBNF). `--tier small` narrows it to the 8-tool set for + ~8B models (`large`, the default, is all twelve). +- `anytype mcp --tier small|large` — serve the same tools over MCP stdio + for LOCAL models (Ollama/LM Studio-class hosts). Coding agents reading + this skill should keep using the verbs directly — the CLI is the + intended delivery for them; `mcp` exists for hosts that cannot run + commands. Session state is in-memory for the server's lifetime. +- `anytype help` — the verb list; `anytype --help` — its flags. +- Spec: `core/api/APIV2.md` §7 (the wrapper contract), `pkg/lib/anyblockjson/SPEC.md` + (the document format the full-read mode returns). diff --git a/cmd/anytype/main.go b/cmd/anytype/main.go new file mode 100644 index 0000000000..2aebdde0cb --- /dev/null +++ b/cmd/anytype/main.go @@ -0,0 +1,283 @@ +// Command anytype is the task-tool CLI (APIV2.md §2 Phase 5 / §7): the same +// ~11 task tools the on-device manifest serves, delivered as verbs for +// coding-agent harnesses. The verb set is GENERATED from the wrapper +// package's tool table — one definition, two deliveries; this file only +// parses flags and prints results. +// +// anytype find --space s1 --type task --filter 'done = false' +// anytype read --object 1 --mode outline +// anytype add-blocks --object 1 --markdown '- [ ] follow up' +// anytype tools # the machine-readable manifest (JSON) +// anytype mcp --tier small # serve the tools over MCP stdio (§8.20) +// +// Configuration: ANYTYPE_API_URL (default http://127.0.0.1:31009) and +// ANYTYPE_API_KEY (bearer key from the app's API settings). Handle state +// (find's numbered results, block labels) persists in a session file +// (ANYTYPE_CLI_SESSION overrides the location) so verbs compose across +// invocations. +package main + +import ( + "context" + "errors" + "flag" + "fmt" + "io" + "os" + "sort" + "strings" + + "github.com/anyproto/anytype-heart/core/api/wrapper" +) + +func main() { + os.Exit(run(os.Args[1:], os.Stdin, os.Stdout, os.Stderr)) +} + +// run is the whole CLI: io streams as parameters so the exit-code matrix, +// both output channels and the MCP loop are testable. +func run(argv []string, stdin io.Reader, stdout, stderr io.Writer) int { + if len(argv) == 0 || argv[0] == "help" || argv[0] == "--help" || argv[0] == "-h" { + printUsage(stdout) + if len(argv) == 0 { + return 2 + } + return 0 + } + verb := argv[0] + + if verb == "tools" { + tier, code := parseTierFlag(verb, argv[1:], stderr) + if code >= 0 { + return code + } + manifest, err := wrapper.ManifestJSONForTier(tier) + if err != nil { + fmt.Fprintln(stderr, "error:", err) + return 1 + } + fmt.Fprintln(stdout, string(manifest)) + return 0 + } + + if verb == "mcp" { + tier, code := parseTierFlag(verb, argv[1:], stderr) + if code >= 0 { + return code + } + // the long-lived delivery (§7.4): handle state lives in memory for + // the process lifetime, not in the CLI session file — concurrent MCP + // servers must not fight over one file, and a host restart starting + // from a clean session is the predictable behavior + client := wrapper.NewClient(os.Getenv("ANYTYPE_API_URL"), os.Getenv("ANYTYPE_API_KEY")) + runner := wrapper.NewRunner(client, wrapper.NewMemoryStore()) + server := wrapper.NewMCPServer(runner, tier) + if err := server.Serve(context.Background(), stdin, stdout); err != nil { + fmt.Fprintln(stderr, "error:", err) + return 1 + } + return 0 + } + + tool, ok := wrapper.ToolByVerb(verb) + if !ok { + fmt.Fprintf(stderr, "unknown verb %q — verbs: %s, tools, mcp\n", verb, strings.Join(verbs(), ", ")) + return 2 + } + + args, opts, err := parseVerbFlags(tool, argv[1:], stderr) + if err != nil { + if errors.Is(err, flag.ErrHelp) { + // --help is a request, not a mistake: the FlagSet already printed + // the flag listing; exit clean with no "error:" line + return 0 + } + fmt.Fprintln(stderr, "error:", err) + return 2 + } + + runner, err := buildRunner(opts) + if err != nil { + fmt.Fprintln(stderr, "error:", err) + return 1 + } + result, err := runner.Run(context.Background(), tool.Name, args) + if err != nil { + fmt.Fprintln(stderr, "error:", err) + return 1 + } + if opts.jsonOut && result.JSON != nil { + data, err := wrapper.EncodeJSON(result.JSON) + if err != nil { + fmt.Fprintln(stderr, "error:", err) + return 1 + } + fmt.Fprintln(stdout, string(data)) + return 0 + } + fmt.Fprintln(stdout, result.Text) + return 0 +} + +// cliOptions are the cross-verb flags. +type cliOptions struct { + jsonOut bool + dryRun bool + ifMatch string + createMissing bool +} + +// parseVerbFlags registers one flag per tool argument (from the same table +// the manifest serves) plus the cross-verb flags, and builds the args map. +func parseVerbFlags(tool wrapper.Tool, argv []string, errW io.Writer) (map[string]any, *cliOptions, error) { + fs := flag.NewFlagSet(tool.Verb(), flag.ContinueOnError) + fs.SetOutput(errW) + + strFlags := map[string]*string{} + boolFlags := map[string]*bool{} + intFlags := map[string]*int{} + for _, a := range tool.Args { + switch a.Type { + case wrapper.ArgBoolean: + boolFlags[a.Name] = fs.Bool(a.Name, false, a.Description) + case wrapper.ArgInteger: + intFlags[a.Name] = fs.Int(a.Name, 0, a.Description) + default: + // object args are passed as JSON strings on the CLI + desc := a.Description + if a.Type == wrapper.ArgObject { + desc += ` (JSON, e.g. '{"status":"Done"}')` + } + strFlags[a.Name] = fs.String(a.Name, "", desc) + } + } + opts := &cliOptions{} + fs.BoolVar(&opts.jsonOut, "json", false, "print the machine-readable result") + fs.BoolVar(&opts.dryRun, "dry-run", false, "validate without committing (?dry_run=true)") + fs.StringVar(&opts.ifMatch, "if-match", "", "advanced: require this etag (C7); the task tools omit it by default") + fs.BoolVar(&opts.createMissing, "create-missing", false, "consent to CREATE select options for names a property does not hold yet; without it an unmatched name is refused") + + if err := fs.Parse(argv); err != nil { + return nil, nil, err + } + if fs.NArg() > 0 { + return nil, nil, fmt.Errorf("unexpected argument %q — %s takes flags only (--%s …)", fs.Arg(0), tool.Verb(), tool.Args[0].Name) + } + + args := map[string]any{} + for name, v := range strFlags { + // presence = the flag was SET, not "the value is non-empty": + // --replace "" and --value "" are meaningful calls (delete the + // phrase, clear the cell) + if !flagWasSet(fs, name) { + continue + } + if a, _ := toolArg(tool, name); a.Type == wrapper.ArgObject { + obj, err := wrapper.ParseObjectFlag(*v) + if err != nil { + return nil, nil, fmt.Errorf("--%s: %w", name, err) + } + args[name] = obj + continue + } + args[name] = *v + } + for name, v := range boolFlags { + if flagWasSet(fs, name) { + args[name] = *v + } + } + for name, v := range intFlags { + if flagWasSet(fs, name) { + args[name] = *v + } + } + return args, opts, nil +} + +// parseTierFlag parses the shared --tier flag of the tools and mcp verbs. +// code is -1 to proceed, else the exit code (0 for --help, 2 for misuse). +func parseTierFlag(verb string, argv []string, errW io.Writer) (wrapper.Tier, int) { + fs := flag.NewFlagSet(verb, flag.ContinueOnError) + fs.SetOutput(errW) + tierFlag := fs.String("tier", string(wrapper.TierLarge), + "tool tier served: small (~8B models, minimal set) or large (default, the full set)") + if err := fs.Parse(argv); err != nil { + if errors.Is(err, flag.ErrHelp) { + return "", 0 + } + return "", 2 + } + if fs.NArg() > 0 { + fmt.Fprintf(errW, "error: unexpected argument %q — %s takes flags only (--tier small|large)\n", fs.Arg(0), verb) + return "", 2 + } + tier, err := wrapper.ParseTier(*tierFlag) + if err != nil { + fmt.Fprintln(errW, "error:", err) + return "", 2 + } + return tier, -1 +} + +func toolArg(tool wrapper.Tool, name string) (wrapper.Arg, bool) { + for _, a := range tool.Args { + if a.Name == name { + return a, true + } + } + return wrapper.Arg{}, false +} + +func flagWasSet(fs *flag.FlagSet, name string) bool { + set := false + fs.Visit(func(f *flag.Flag) { + if f.Name == name { + set = true + } + }) + return set +} + +func buildRunner(opts *cliOptions) (*wrapper.Runner, error) { + sessionPath, err := wrapper.DefaultSessionPath() + if err != nil { + return nil, err + } + client := wrapper.NewClient(os.Getenv("ANYTYPE_API_URL"), os.Getenv("ANYTYPE_API_KEY")) + runner := wrapper.NewRunner(client, &wrapper.FileStore{Path: sessionPath}) + runner.DryRun = opts.dryRun + runner.IfMatch = opts.ifMatch + runner.AllowNewOptions = opts.createMissing + return runner, nil +} + +func verbs() []string { + var out []string + for _, t := range wrapper.Tools() { + out = append(out, t.Verb()) + } + sort.Strings(out) + return out +} + +func printUsage(w io.Writer) { + fmt.Fprintln(w, "anytype — task tools over the local Anytype API (v2)") + fmt.Fprintln(w, "\nusage: anytype [--flag value …]") + fmt.Fprintln(w, "\nverbs:") + for _, t := range wrapper.Tools() { + fmt.Fprintf(w, " %-15s %s\n", t.Verb(), firstSentence(t.Description)) + } + fmt.Fprintf(w, " %-15s %s\n", "tools", "print the machine-readable tool manifest (JSON); --tier small|large") + fmt.Fprintf(w, " %-15s %s\n", "mcp", "serve the tools over MCP stdio for local models; --tier small|large") + fmt.Fprintln(w, "\ncross-verb flags: --json, --dry-run, --if-match , --create-missing") + fmt.Fprintln(w, "environment: ANYTYPE_API_URL (default "+wrapper.DefaultBaseURL+"), ANYTYPE_API_KEY, ANYTYPE_CLI_SESSION") + fmt.Fprintln(w, "\nstart with: anytype spaces (lists space ids), then anytype find --space --query … ; find's results are numbered handles the other verbs take as --object") +} + +func firstSentence(s string) string { + if idx := strings.IndexAny(s, "."); idx > 0 { + return s[:idx+1] + } + return s +} diff --git a/cmd/anytype/main_test.go b/cmd/anytype/main_test.go new file mode 100644 index 0000000000..6cc1f67f7f --- /dev/null +++ b/cmd/anytype/main_test.go @@ -0,0 +1,313 @@ +package main + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/anyproto/anytype-heart/core/api/wrapper" +) + +// TestVerbFlagsFromToolTable proves the CLI registers every tool argument +// as a flag and converts values to the tool-args shape — the one-definition +// contract on the CLI side. +func TestVerbFlagsFromToolTable(t *testing.T) { + t.Run("every tool's flags parse its own example", func(t *testing.T) { + for _, tool := range wrapper.Tools() { + t.Run(tool.Name, func(t *testing.T) { + var argv []string + for name, value := range tool.Example { + switch v := value.(type) { + case string: + argv = append(argv, "--"+name, v) + case bool: + argv = append(argv, "--"+name+"=true") + case int: + argv = append(argv, "--"+name, "1") + case map[string]any: + data, err := wrapper.EncodeJSON(v) + require.NoError(t, err) + argv = append(argv, "--"+name, string(data)) + } + } + args, _, err := parseVerbFlags(tool, argv, io.Discard) + require.NoError(t, err) + for name := range tool.Example { + assert.Contains(t, args, name) + } + }) + } + }) + + t.Run("string and object flags convert", func(t *testing.T) { + tool, ok := wrapper.ToolByVerb("set-properties") + require.True(t, ok) + args, opts, err := parseVerbFlags(tool, []string{ + "--object", "1", "--set", `{"status":"Done","priority":3}`, "--json", + }, io.Discard) + require.NoError(t, err) + assert.Equal(t, "1", args["object"]) + assert.Equal(t, map[string]any{"status": "Done", "priority": float64(3)}, args["set"]) + assert.True(t, opts.jsonOut) + }) + + t.Run("integer flags land as ints, not strings", func(t *testing.T) { + tool, ok := wrapper.ToolByVerb("find") + require.True(t, ok) + args, _, err := parseVerbFlags(tool, []string{"--space", "s1", "--limit", "25"}, io.Discard) + require.NoError(t, err) + assert.Equal(t, 25, args["limit"], "the ArgInteger branch must produce an int") + }) + + t.Run("boolean flags land as bools", func(t *testing.T) { + tool, ok := wrapper.ToolByVerb("delete-block") + require.True(t, ok) + args, _, err := parseVerbFlags(tool, []string{"--object", "1", "--block", "ab123", "--recursive"}, io.Discard) + require.NoError(t, err) + assert.Equal(t, true, args["recursive"]) + }) + + t.Run("an explicitly empty string flag is passed through, an unset one is not", func(t *testing.T) { + tool, ok := wrapper.ToolByVerb("edit-text") + require.True(t, ok) + args, _, err := parseVerbFlags(tool, []string{ + "--object", "1", "--block", "ab3f2", "--find", " (draft)", "--replace", "", + }, io.Discard) + require.NoError(t, err) + val, present := args["replace"] + assert.True(t, present, `--replace "" means "delete the phrase" and must reach the tool`) + assert.Equal(t, "", val) + assert.NotContains(t, args, "after", "flags never given stay off the args map") + }) + + t.Run("cross-verb flags", func(t *testing.T) { + tool, _ := wrapper.ToolByVerb("create") + _, opts, err := parseVerbFlags(tool, []string{ + "--space", "s1", "--type", "task", "--name", "X", + "--dry-run", "--create-missing", "--if-match", "abcd1234", + }, io.Discard) + require.NoError(t, err) + assert.True(t, opts.dryRun) + assert.True(t, opts.createMissing) + assert.Equal(t, "abcd1234", opts.ifMatch) + }) + + t.Run("bad object JSON steers", func(t *testing.T) { + tool, _ := wrapper.ToolByVerb("set-properties") + _, _, err := parseVerbFlags(tool, []string{"--object", "1", "--set", "status=Done"}, io.Discard) + require.Error(t, err) + assert.Contains(t, err.Error(), `expected a JSON object, e.g. '{"status":"Done"}'`) + }) + + t.Run("positional arguments are rejected with steering", func(t *testing.T) { + tool, _ := wrapper.ToolByVerb("find") + _, _, err := parseVerbFlags(tool, []string{"space1"}, io.Discard) + require.Error(t, err) + assert.Contains(t, err.Error(), "find takes flags only (--space …)") + }) + + t.Run("unset boolean flags stay off the args map", func(t *testing.T) { + tool, _ := wrapper.ToolByVerb("delete-block") + args, _, err := parseVerbFlags(tool, []string{"--object", "1", "--block", "ab123"}, io.Discard) + require.NoError(t, err) + assert.NotContains(t, args, "recursive") + }) +} + +// runCLI captures run()'s exit code and both output channels (stdin empty — +// only the mcp verb reads it; runCLIWithStdin feeds it). +func runCLI(argv ...string) (code int, stdout, stderr string) { + return runCLIWithStdin("", argv...) +} + +func runCLIWithStdin(stdin string, argv ...string) (code int, stdout, stderr string) { + var out, errOut bytes.Buffer + code = run(argv, strings.NewReader(stdin), &out, &errOut) + return code, out.String(), errOut.String() +} + +func TestRunExitCodes(t *testing.T) { + t.Run("no arguments prints usage and exits 2", func(t *testing.T) { + code, stdout, _ := runCLI() + assert.Equal(t, 2, code) + assert.Contains(t, stdout, "usage: anytype ") + }) + + t.Run("help exits 0", func(t *testing.T) { + for _, h := range []string{"help", "--help", "-h"} { + code, stdout, stderr := runCLI(h) + assert.Equal(t, 0, code, h) + assert.Contains(t, stdout, "usage: anytype ") + assert.NotContains(t, stderr, "error:") + } + }) + + t.Run("verb --help exits 0 without an error line", func(t *testing.T) { + code, _, stderr := runCLI("find", "--help") + assert.Equal(t, 0, code, "--help is a request, not a mistake") + assert.NotContains(t, stderr, "error:") + assert.Contains(t, stderr, "-space", "the flag listing is shown") + }) + + t.Run("unknown verb exits 2 with the verb list", func(t *testing.T) { + code, _, stderr := runCLI("archive") + assert.Equal(t, 2, code) + assert.Contains(t, stderr, `unknown verb "archive"`) + assert.Contains(t, stderr, "tools") + }) + + t.Run("bad flags exit 2", func(t *testing.T) { + code, _, stderr := runCLI("find", "--nope") + assert.Equal(t, 2, code) + assert.NotEmpty(t, stderr) + }) + + t.Run("tools prints the machine-readable manifest and exits 0", func(t *testing.T) { + code, stdout, _ := runCLI("tools") + assert.Equal(t, 0, code) + var m wrapper.Manifest + require.NoError(t, json.Unmarshal([]byte(stdout), &m)) + require.Len(t, m.Tools, len(wrapper.Tools())) + }) + + t.Run("tools --tier small prints the small-tier manifest", func(t *testing.T) { + code, stdout, _ := runCLI("tools", "--tier", "small") + assert.Equal(t, 0, code) + var m wrapper.Manifest + require.NoError(t, json.Unmarshal([]byte(stdout), &m)) + require.Len(t, m.Tools, len(wrapper.ToolsForTier(wrapper.TierSmall))) + }) + + t.Run("a bad tier exits 2 naming the tiers", func(t *testing.T) { + for _, verb := range []string{"tools", "mcp"} { + code, _, stderr := runCLI(verb, "--tier", "medium") + assert.Equal(t, 2, code, verb) + assert.Contains(t, stderr, `unknown tier "medium" — tiers: small, large`, verb) + } + }) +} + +// TestRunMCP drives the mcp verb through stdio: the verb is the §8.20 +// long-lived delivery, so an initialize → tools/list script must answer +// over stdout and EOF must end the process cleanly with exit 0. +func TestRunMCP(t *testing.T) { + t.Run("EOF on stdin exits 0", func(t *testing.T) { + code, stdout, stderr := runCLIWithStdin("", "mcp") + assert.Equal(t, 0, code, stderr) + assert.Empty(t, stdout) + }) + + t.Run("initialize and tier-filtered tools/list over stdio", func(t *testing.T) { + script := `{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18"}}` + "\n" + + `{"jsonrpc":"2.0","method":"notifications/initialized"}` + "\n" + + `{"jsonrpc":"2.0","id":2,"method":"tools/list"}` + "\n" + code, stdout, stderr := runCLIWithStdin(script, "mcp", "--tier", "small") + require.Equal(t, 0, code, stderr) + + lines := strings.Split(strings.TrimSpace(stdout), "\n") + require.Len(t, lines, 2, "two requests, one notification → two responses") + + var initResp struct { + Result struct { + ProtocolVersion string `json:"protocolVersion"` + Instructions string `json:"instructions"` + } `json:"result"` + } + require.NoError(t, json.Unmarshal([]byte(lines[0]), &initResp)) + assert.Equal(t, "2025-06-18", initResp.Result.ProtocolVersion) + assert.NotEmpty(t, initResp.Result.Instructions) + + var listResp struct { + Result struct { + Tools []struct { + Name string `json:"name"` + } `json:"tools"` + } `json:"result"` + } + require.NoError(t, json.Unmarshal([]byte(lines[1]), &listResp)) + var names []string + for _, tool := range listResp.Result.Tools { + names = append(names, tool.Name) + } + assert.Equal(t, wrapper.ToolNamesForTier(wrapper.TierSmall), names) + }) + + t.Run("a tools/call reaches the API server", func(t *testing.T) { + stubServer(t, func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, "/v2/spaces", r.URL.Path) + require.Equal(t, "Bearer test-key", r.Header.Get("Authorization")) + fmt.Fprint(w, `{"data":[{"id":"bafyspace1","name":"Work"}],"total":1,"offset":0,"limit":25,"has_more":false}`) + }) + script := `{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"spaces"}}` + "\n" + code, stdout, stderr := runCLIWithStdin(script, "mcp") + require.Equal(t, 0, code, stderr) + assert.Contains(t, stdout, "Work — bafyspace1") + }) +} + +// stubServer runs a stub API server and points the CLI's env at it, with an +// isolated session file. +func stubServer(t *testing.T, handler http.HandlerFunc) { + t.Helper() + server := httptest.NewServer(handler) + t.Cleanup(server.Close) + t.Setenv("ANYTYPE_API_URL", server.URL) + t.Setenv("ANYTYPE_API_KEY", "test-key") + t.Setenv("ANYTYPE_CLI_SESSION", t.TempDir()+"/session.json") +} + +func TestRunEndToEnd(t *testing.T) { + t.Run("a verb runs against the API and prints the text form", func(t *testing.T) { + stubServer(t, func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, "GET", r.Method) + require.Equal(t, "/v2/spaces", r.URL.Path) + fmt.Fprint(w, `{"data":[{"id":"bafyspace1","name":"Work"}],"total":1,"offset":0,"limit":25,"has_more":false}`) + }) + + code, stdout, stderr := runCLI("spaces") + + assert.Equal(t, 0, code, stderr) + assert.Contains(t, stdout, "Work — bafyspace1") + }) + + t.Run("--json selects the machine shape", func(t *testing.T) { + stubServer(t, func(w http.ResponseWriter, r *http.Request) { + fmt.Fprint(w, `{"data":[{"id":"bafyspace1","name":"Work"}],"total":1,"offset":0,"limit":25,"has_more":false}`) + }) + + code, stdout, stderr := runCLI("spaces", "--json") + + assert.Equal(t, 0, code, stderr) + var got struct { + Spaces []struct { + Id string `json:"id"` + Name string `json:"name"` + } `json:"spaces"` + Total int `json:"total"` + } + require.NoError(t, json.Unmarshal([]byte(stdout), &got)) + require.Len(t, got.Spaces, 1) + assert.Equal(t, "bafyspace1", got.Spaces[0].Id) + }) + + t.Run("a tool error exits 1 with the error text", func(t *testing.T) { + stubServer(t, func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusBadRequest) + fmt.Fprint(w, `{"status":400,"code":"validation_failed","message":"filter parse error at offset 3"}`) + }) + + code, _, stderr := runCLI("find", "--space", "s1", "--filter", "x ~ 1") + + assert.Equal(t, 1, code) + assert.Contains(t, stderr, "error:") + assert.Contains(t, stderr, "filter parse error at offset 3") + }) +} diff --git a/cmd/apiv2eval/agent.go b/cmd/apiv2eval/agent.go new file mode 100644 index 0000000000..c5c38aa106 --- /dev/null +++ b/cmd/apiv2eval/agent.go @@ -0,0 +1,193 @@ +package main + +// agent.go — the agent loop: the same shape a real host runs. System +// prompt (the arm's own instructions — for the wrapper arm those are the +// product's MCP initialize instructions, not text the harness invented), +// the task as the user turn, then call → execute → feed the result back +// until the model stops calling tools or the turn budget runs out. +// +// Nothing here judges success: whether the document actually says what it +// should is decided afterwards, against the API, by the task's own check. + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "strings" + "time" + "unicode/utf8" +) + +// turnRecord is one model completion and the tool calls it produced. +type turnRecord struct { + Index int `json:"index"` + Reasoning string `json:"reasoning,omitempty"` + Content string `json:"content,omitempty"` + FinishReason string `json:"finish_reason,omitempty"` + Usage usage `json:"usage"` + LatencyMs int64 `json:"latency_ms"` + Calls []callRecord `json:"calls,omitempty"` +} + +// callRecord is one tool call exactly as the model emitted it, with what +// came back. +type callRecord struct { + Turn int `json:"turn"` + Tool string `json:"tool"` + Args json.RawMessage `json:"args"` + // ArgsError is set when the emitted arguments were not a JSON object — + // a malformed call never reaches the tool, and that is its own failure + // mode worth counting separately from a refusal. + ArgsError string `json:"args_error,omitempty"` + ResultText string `json:"result_text"` + IsError bool `json:"is_error,omitempty"` + // Exchanges are the HTTP calls this tool call made — the structured + // (status, code, issue path) facts behind the text the model saw. + Exchanges []exchange `json:"exchanges,omitempty"` +} + +// transcript is one agent run. +type transcript struct { + Turns []turnRecord `json:"turns"` + Calls []callRecord `json:"calls"` + FinalContent string `json:"final_content,omitempty"` + StoppedBy string `json:"stopped_by"` // model_done | turn_budget + PromptTokens int `json:"prompt_tokens"` + CompletionTokens int `json:"completion_tokens"` +} + +// errEnvironment marks a failure that is NOT the model's and NOT the API's +// contract: the model host timed out, the API went away mid-run. Attempts +// ending this way are recorded and excluded from the success rate. +var errEnvironment = errors.New("environment failure") + +// maxResultChars bounds one tool result fed back to the model. A full +// document read of a fixture is far under this; the bound only stops a +// pathological result from blowing the context window, and when it fires +// the record says so rather than pretending the model saw everything. +const maxResultChars = 24000 + +// agentConfig is what one run needs beyond its toolset. +type agentConfig struct { + chat *chatClient + model string + temperature float64 + maxTurns int + // rec attributes HTTP exchanges to the tool call that made them. + rec *recorder +} + +// runAgent drives one attempt's conversation. +func runAgent(ctx context.Context, cfg agentConfig, ts toolset, systemPrompt, userPrompt string) (*transcript, error) { + tools := make([]toolDef, 0, len(ts.tools())) + for _, spec := range ts.tools() { + tools = append(tools, newToolDef(spec.Name, spec.Description, spec.Parameters)) + } + messages := []chatMessage{ + {Role: "system", Content: systemPrompt}, + {Role: "user", Content: userPrompt}, + } + tr := &transcript{StoppedBy: "turn_budget"} + for i := 0; i < cfg.maxTurns; i++ { + start := time.Now() + resp, err := cfg.chat.complete(ctx, cfg.model, messages, tools, cfg.temperature) + if err != nil { + return tr, fmt.Errorf("%w: turn %d: %w", errEnvironment, i, err) + } + turn := turnRecord{ + Index: i, + Reasoning: resp.Reasoning, + Content: resp.Message.Content, + FinishReason: resp.FinishReason, + Usage: resp.Usage, + LatencyMs: time.Since(start).Milliseconds(), + } + tr.PromptTokens += resp.Usage.PromptTokens + tr.CompletionTokens += resp.Usage.CompletionTokens + + if len(resp.Message.ToolCalls) == 0 { + turn.FinishReason = resp.FinishReason + tr.Turns = append(tr.Turns, turn) + tr.FinalContent = resp.Message.Content + tr.StoppedBy = "model_done" + return tr, nil + } + + assistant := chatMessage{Role: "assistant", Content: resp.Message.Content, ToolCalls: resp.Message.ToolCalls} + messages = append(messages, assistant) + for _, call := range resp.Message.ToolCalls { + rec := callRecord{Turn: i, Tool: call.Function.Name} + raw, argsErr := call.argsJSON() + if argsErr == nil { + rec.Args = json.RawMessage(raw) + } else { + rec.Args = json.RawMessage(`null`) + } + args, err := call.argsMap() + if err != nil { + rec.ArgsError = err.Error() + rec.IsError = true + rec.ResultText = fmt.Sprintf("the arguments were not a JSON object: %v", err) + } else { + mark := 0 + if cfg.rec != nil { + mark = cfg.rec.mark() + } + outcome := ts.call(ctx, call.Function.Name, args) + rec.ResultText = outcome.Text + rec.IsError = outcome.IsError + if cfg.rec != nil { + rec.Exchanges = cfg.rec.since(mark) + } + } + turn.Calls = append(turn.Calls, rec) + tr.Calls = append(tr.Calls, rec) + messages = append(messages, chatMessage{ + Role: "tool", + ToolCallId: call.Id, + Name: call.Function.Name, + Content: clampResult(rec.ResultText), + }) + } + tr.Turns = append(tr.Turns, turn) + if err := ctx.Err(); err != nil { + return tr, fmt.Errorf("%w: %w", errEnvironment, err) + } + } + return tr, nil +} + +// clampResult bounds one tool result, marking the cut so the transcript +// never implies the model saw more than it did. +func clampResult(s string) string { + if len(s) <= maxResultChars { + return s + } + cut := maxResultChars + for cut > 0 && !utf8.RuneStart(s[cut]) { + cut-- + } + return s[:cut] + "\n… [truncated by the harness]" +} + +// summarizeCalls renders the tool calls of an attempt as one line each — +// the readable form used in the failure quotes. +func summarizeCalls(calls []callRecord) string { + var b strings.Builder + for _, c := range calls { + status := "ok" + if c.IsError { + status = "ERR" + } + fmt.Fprintf(&b, " t%d %s %s → %s %s\n", c.Turn, c.Tool, string(c.Args), status, firstLine(c.ResultText)) + } + return b.String() +} + +func firstLine(s string) string { + if i := strings.IndexByte(s, '\n'); i >= 0 { + return s[:i] + " …" + } + return s +} diff --git a/cmd/apiv2eval/api.go b/cmd/apiv2eval/api.go new file mode 100644 index 0000000000..de2cf4244b --- /dev/null +++ b/cmd/apiv2eval/api.go @@ -0,0 +1,466 @@ +package main + +// api.go — the harness's own thin client over the local /v2 API. Fixture +// setup and the programmatic success checks run through it, deliberately NOT +// through the wrapper under test: a check that shares the code under test +// proves nothing. It also carries the recording transport every arm shares, +// so every HTTP exchange a run makes — including the ones the wrapper makes +// on the model's behalf — lands in the attempt record with its status, C6 +// code and issue paths. + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "sync" + "time" + + v2model "github.com/anyproto/anytype-heart/core/api/v2/model" +) + +// maxRecordedBody bounds how much of a request body an exchange record +// keeps: the model's own payloads are the interesting part and they are +// small, while a fixture's markdown or a full document read is not worth +// carrying into every JSONL line. +const maxRecordedBody = 8 << 10 + +// exchange is one recorded HTTP call to the local API. +type exchange struct { + At time.Time `json:"at"` + Method string `json:"method"` + Path string `json:"path"` + Status int `json:"status"` + Code string `json:"code,omitempty"` + Message string `json:"message,omitempty"` + Issues []v2model.Issue `json:"issues,omitempty"` + RequestBody json.RawMessage `json:"request_body,omitempty"` + // Transport is set when the call never got an HTTP response at all (the + // API is down): that is an environment fact, not an API error. + Transport string `json:"transport_error,omitempty"` +} + +// recorder collects exchanges for the attempt currently running. +type recorder struct { + mu sync.Mutex + ex []exchange +} + +func (r *recorder) add(e exchange) { + r.mu.Lock() + defer r.mu.Unlock() + r.ex = append(r.ex, e) +} + +// take returns the exchanges recorded so far and clears the buffer, so one +// attempt's record never carries another's. +func (r *recorder) take() []exchange { + r.mu.Lock() + defer r.mu.Unlock() + ex := r.ex + r.ex = nil + return ex +} + +// mark returns the current position; since returns everything recorded +// after one. Together they attribute exchanges to the tool call that made +// them — which is what turns a text tip the model saw into the (status, +// code, path) triple the report needs. +func (r *recorder) mark() int { + r.mu.Lock() + defer r.mu.Unlock() + return len(r.ex) +} + +func (r *recorder) since(mark int) []exchange { + r.mu.Lock() + defer r.mu.Unlock() + if mark < 0 || mark > len(r.ex) { + return nil + } + out := make([]exchange, len(r.ex)-mark) + copy(out, r.ex[mark:]) + return out +} + +// recordingTransport records every request/response pair. It is the ONE +// place structured error facts are captured: the MCP delivery hands the +// model a text tip (by design), so status, C6 code and issue paths would +// otherwise be unrecoverable from the transcript alone. +type recordingTransport struct { + base http.RoundTripper + rec *recorder +} + +func (t *recordingTransport) RoundTrip(req *http.Request) (*http.Response, error) { + e := exchange{At: time.Now(), Method: req.Method, Path: req.URL.Path} + if req.Body != nil && req.Method != http.MethodGet { + body, err := io.ReadAll(req.Body) + req.Body.Close() + if err != nil { + return nil, fmt.Errorf("read request body for recording: %w", err) + } + req.Body = io.NopCloser(bytes.NewReader(body)) + if len(body) <= maxRecordedBody && json.Valid(body) { + e.RequestBody = json.RawMessage(body) + } + } + resp, err := t.base.RoundTrip(req) + if err != nil { + e.Transport = err.Error() + t.rec.add(e) + return nil, err + } + e.Status = resp.StatusCode + if resp.StatusCode < 200 || resp.StatusCode > 299 { + body, readErr := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) + resp.Body.Close() + if readErr != nil { + return nil, fmt.Errorf("read response body for recording: %w", readErr) + } + resp.Body = io.NopCloser(bytes.NewReader(body)) + var apiErr v2model.Error + if json.Unmarshal(body, &apiErr) == nil { + e.Code, e.Message, e.Issues = apiErr.Code, apiErr.Message, apiErr.Issues + } else { + e.Message = strings.TrimSpace(string(body)) + } + } + t.rec.add(e) + return resp, nil +} + +// apiClient is the harness's direct /v2 client. +type apiClient struct { + baseURL string + apiKey string + http *http.Client +} + +func newAPIClient(baseURL, apiKey string, rt http.RoundTripper) *apiClient { + return &apiClient{ + baseURL: strings.TrimSuffix(baseURL, "/"), + apiKey: apiKey, + http: &http.Client{Timeout: 60 * time.Second, Transport: rt}, + } +} + +// apiError carries a non-2xx answer with its C6 fields intact. +type apiError struct { + Status int + Code string + Message string + Issues []v2model.Issue +} + +func (e *apiError) Error() string { + return fmt.Sprintf("api %d %s: %s", e.Status, e.Code, e.Message) +} + +// call runs one request; out (when non-nil) receives the decoded 2xx body. +func (c *apiClient) call(ctx context.Context, method, path string, query url.Values, body, out any) ([]byte, error) { + target := c.baseURL + path + if len(query) > 0 { + target += "?" + query.Encode() + } + var reader io.Reader + if body != nil { + payload, err := json.Marshal(body) + if err != nil { + return nil, fmt.Errorf("encode request body: %w", err) + } + reader = bytes.NewReader(payload) + } + req, err := http.NewRequestWithContext(ctx, method, target, reader) + if err != nil { + return nil, fmt.Errorf("build request: %w", err) + } + if body != nil { + req.Header.Set("Content-Type", "application/json") + } + if c.apiKey != "" { + req.Header.Set("Authorization", "Bearer "+c.apiKey) + } + if method != http.MethodGet { + // C8: every mutation the harness itself makes carries a key too — + // fixture creation is a mutation and the retry policy is the server's + req.Header.Set("Idempotency-Key", newNonce(16)) + } + resp, err := c.http.Do(req) + if err != nil { + return nil, fmt.Errorf("call %s %s: %w", method, path, err) + } + defer resp.Body.Close() + raw, err := io.ReadAll(io.LimitReader(resp.Body, 32<<20)) + if err != nil { + return nil, fmt.Errorf("read response of %s %s: %w", method, path, err) + } + if resp.StatusCode < 200 || resp.StatusCode > 299 { + e := &apiError{Status: resp.StatusCode} + var decoded v2model.Error + if json.Unmarshal(raw, &decoded) == nil && decoded.Message != "" { + e.Code, e.Message, e.Issues = decoded.Code, decoded.Message, decoded.Issues + } else { + e.Message = strings.TrimSpace(string(raw)) + } + return raw, e + } + if out != nil { + if err := json.Unmarshal(raw, out); err != nil { + return raw, fmt.Errorf("decode response of %s %s: %w", method, path, err) + } + } + return raw, nil +} + +// whoami is the preflight call: it proves the server is up AND the key is +// accepted, which are two different failures with two different fixes. +func (c *apiClient) whoami(ctx context.Context) error { + _, err := c.call(ctx, http.MethodGet, "/v2/auth/whoami", nil, nil, nil) + if err != nil { + return fmt.Errorf("whoami: %w", err) + } + return nil +} + +type spaceRow struct { + Id string `json:"id"` + Name string `json:"name"` +} + +func (c *apiClient) listSpaces(ctx context.Context) ([]spaceRow, error) { + var resp struct { + Data []spaceRow `json:"data"` + } + if _, err := c.call(ctx, http.MethodGet, "/v2/spaces", url.Values{"limit": {"100"}}, nil, &resp); err != nil { + return nil, fmt.Errorf("list spaces: %w", err) + } + return resp.Data, nil +} + +func (c *apiClient) createSpace(ctx context.Context, name string) (string, error) { + var resp v2model.CreateResult + if _, err := c.call(ctx, http.MethodPost, "/v2/spaces", nil, map[string]any{"name": name}, &resp); err != nil { + return "", fmt.Errorf("create space %q: %w", name, err) + } + return resp.Id, nil +} + +// createObject makes one fixture object. +func (c *apiClient) createObject(ctx context.Context, spaceId, typeKey, name, markdown string) (string, error) { + body := map[string]any{"type": typeKey, "name": name} + if markdown != "" { + body["markdown"] = markdown + } + var resp v2model.CreateResult + path := "/v2/spaces/" + url.PathEscape(spaceId) + "/objects" + if _, err := c.call(ctx, http.MethodPost, path, nil, body, &resp); err != nil { + return "", fmt.Errorf("create object %q: %w", name, err) + } + return resp.Id, nil +} + +// searchPollInterval is how often waitSearchable re-asks. +const searchPollInterval = 500 * time.Millisecond + +// waitSearchable blocks until a search for the fixture's title returns it. +// Full-text indexing is asynchronous, so a fixture created a moment ago can +// be invisible to the search the wrapper arm's `find` runs — an attempt that +// starts before the index catches up fails for a reason that has nothing to +// do with the model or the API contract. Returns whether it became visible +// and how long that took. +func (c *apiClient) waitSearchable(ctx context.Context, spaceId, title, objectId string, timeout time.Duration) (bool, time.Duration, error) { + deadline := time.Now().Add(timeout) + path := "/v2/spaces/" + url.PathEscape(spaceId) + "/search" + start := time.Now() + for { + var resp struct { + Data []struct { + Id string `json:"id"` + } `json:"data"` + } + if _, err := c.call(ctx, http.MethodPost, path, url.Values{"limit": {"25"}}, + map[string]any{"query": title}, &resp); err != nil { + return false, time.Since(start), fmt.Errorf("search for the fixture: %w", err) + } + for _, row := range resp.Data { + if row.Id == objectId { + return true, time.Since(start), nil + } + } + if time.Now().After(deadline) { + return false, time.Since(start), nil + } + select { + case <-time.After(searchPollInterval): + case <-ctx.Done(): + return false, time.Since(start), ctx.Err() + } + } +} + +// typeExists reports whether a type key resolves in the space. +func (c *apiClient) typeExists(ctx context.Context, spaceId, typeKey string) bool { + path := "/v2/spaces/" + url.PathEscape(spaceId) + "/types/" + url.PathEscape(typeKey) + _, err := c.call(ctx, http.MethodGet, path, nil, nil, nil) + return err == nil +} + +// +// ---- the served document, as the checks read it ---- +// + +// document is the subset of a served object the checks assert on. +type document struct { + Id string `json:"id"` + Properties map[string]any `json:"properties"` + Blocks []docBlock `json:"blocks"` +} + +type docBlock struct { + Id string `json:"id"` + Type string `json:"type"` + Text string `json:"text"` + Indent float64 `json:"indent"` + Checked bool `json:"checked"` + Columns []docTableId `json:"columns"` + Rows []docTableRow `json:"rows"` +} + +type docTableId struct { + Id string `json:"id"` +} + +type docTableRow struct { + Id string `json:"id"` + IsHeader bool `json:"isHeader"` + Cells []json.RawMessage `json:"cells"` +} + +// getDocument reads the object in the DEFAULT (compact-label) shape — the +// same bytes the model's read serves, so a check and an echo classifier +// agree about what an id looks like. +func (c *apiClient) getDocument(ctx context.Context, spaceId, objectId string) (*document, []byte, error) { + path := "/v2/spaces/" + url.PathEscape(spaceId) + "/objects/" + url.PathEscape(objectId) + var doc document + raw, err := c.call(ctx, http.MethodGet, path, nil, nil, &doc) + if err != nil { + return nil, raw, fmt.Errorf("read object %s: %w", objectId, err) + } + return &doc, raw, nil +} + +// patchOps sends a single-op PATCH (the ops arm's executor). +func (c *apiClient) patchOps(ctx context.Context, spaceId, objectId string, ops []any) (*v2model.EditResult, error) { + path := "/v2/spaces/" + url.PathEscape(spaceId) + "/objects/" + url.PathEscape(objectId) + var result v2model.EditResult + if _, err := c.call(ctx, http.MethodPatch, path, nil, map[string]any{"ops": ops}, &result); err != nil { + return nil, err + } + return &result, nil +} + +// opSchema fetches one op's published schema (GET /v2/schemas/ops/{op}) — +// the bytes the ops arm serves the model as that tool's parameters. +func (c *apiClient) opSchema(ctx context.Context, op string) (json.RawMessage, json.RawMessage, error) { + var entry struct { + Schema json.RawMessage `json:"schema"` + Example json.RawMessage `json:"example"` + } + if _, err := c.call(ctx, http.MethodGet, "/v2/schemas/ops/"+url.PathEscape(op), nil, nil, &entry); err != nil { + return nil, nil, fmt.Errorf("fetch op schema %q: %w", op, err) + } + return entry.Schema, entry.Example, nil +} + +// +// ---- document helpers the checks share ---- +// + +// blockTexts returns every block's text in document order. +func (d *document) blockTexts() []string { + out := make([]string, 0, len(d.Blocks)) + for _, b := range d.Blocks { + out = append(out, b.Text) + } + return out +} + +// allText joins every block's text, table cells included. +func (d *document) allText() string { + var b strings.Builder + for _, blk := range d.Blocks { + b.WriteString(blk.Text) + b.WriteString("\n") + for _, row := range blk.Rows { + for _, cell := range row.Cells { + b.WriteString(cellText(cell)) + b.WriteString("\n") + } + } + } + return b.String() +} + +// findBlock returns the first block satisfying pred. +func (d *document) findBlock(pred func(docBlock) bool) (docBlock, bool) { + for _, b := range d.Blocks { + if pred(b) { + return b, true + } + } + return docBlock{}, false +} + +// table returns the first table block. +func (d *document) table() (docBlock, bool) { + return d.findBlock(func(b docBlock) bool { return b.Type == "table" }) +} + +// cellText renders a §6.1 cell (string | null | object | array of blocks) as +// its text. +func cellText(raw json.RawMessage) string { + if len(raw) == 0 || string(raw) == "null" { + return "" + } + var s string + if json.Unmarshal(raw, &s) == nil { + return s + } + var obj struct { + Text string `json:"text"` + } + if json.Unmarshal(raw, &obj) == nil { + return obj.Text + } + var arr []struct { + Text string `json:"text"` + } + if json.Unmarshal(raw, &arr) == nil && len(arr) > 0 { + return arr[0].Text + } + return "" +} + +// stringProperty reads a scalar string property value. +func (d *document) stringProperty(key string) (string, bool) { + v, ok := d.Properties[key] + if !ok { + return "", false + } + switch t := v.(type) { + case string: + return t, true + case []any: + if len(t) == 1 { + if s, ok := t[0].(string); ok { + return s, true + } + } + } + return "", false +} diff --git a/cmd/apiv2eval/chat.go b/cmd/apiv2eval/chat.go new file mode 100644 index 0000000000..7baae2416c --- /dev/null +++ b/cmd/apiv2eval/chat.go @@ -0,0 +1,223 @@ +package main + +// chat.go — the OpenAI-compatible chat client used to drive the local +// models (Ollama/LM Studio-class hosts serve this shape at /v1). Nothing +// here is Anytype-specific: it carries messages, tool definitions and the +// usage numbers back, and normalizes the two encodings servers use for tool +// arguments (JSON string, or an already-decoded object). + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "time" +) + +// chatMessage is one message in the conversation. +type chatMessage struct { + Role string `json:"role"` + Content string `json:"content,omitempty"` + ToolCalls []toolCall `json:"tool_calls,omitempty"` + ToolCallId string `json:"tool_call_id,omitempty"` + Name string `json:"name,omitempty"` +} + +// toolCall is one function call the model emitted. Arguments stay RAW: the +// whole point of the run is what the model actually wrote, so the record +// keeps the bytes and the classifiers decode a copy. +type toolCall struct { + Id string `json:"id,omitempty"` + Type string `json:"type,omitempty"` + Function struct { + Name string `json:"name"` + Arguments json.RawMessage `json:"arguments"` + } `json:"function"` +} + +// argsJSON returns the call's arguments as a JSON object, accepting both +// wire encodings: a JSON-encoded STRING (OpenAI's own shape, and Ollama's) +// or an inline object (some servers). +func (c toolCall) argsJSON() ([]byte, error) { + raw := bytes.TrimSpace(c.Function.Arguments) + if len(raw) == 0 { + return []byte("{}"), nil + } + if raw[0] == '"' { + var s string + if err := json.Unmarshal(raw, &s); err != nil { + return nil, fmt.Errorf("decode tool arguments string: %w", err) + } + s = strings.TrimSpace(s) + if s == "" { + return []byte("{}"), nil + } + return []byte(s), nil + } + return raw, nil +} + +// argsMap decodes the arguments into the map the tool executors take. +func (c toolCall) argsMap() (map[string]any, error) { + raw, err := c.argsJSON() + if err != nil { + return nil, err + } + var m map[string]any + if err := json.Unmarshal(raw, &m); err != nil { + return nil, fmt.Errorf("decode tool arguments object: %w", err) + } + if m == nil { + m = map[string]any{} + } + return m, nil +} + +// toolDef is one function-calling tool definition. +type toolDef struct { + Type string `json:"type"` + Function struct { + Name string `json:"name"` + Description string `json:"description"` + Parameters json.RawMessage `json:"parameters"` + } `json:"function"` +} + +func newToolDef(name, description string, parameters json.RawMessage) toolDef { + var t toolDef + t.Type = "function" + t.Function.Name = name + t.Function.Description = description + t.Function.Parameters = parameters + return t +} + +// usage is one completion's token accounting. +type usage struct { + PromptTokens int `json:"prompt_tokens"` + CompletionTokens int `json:"completion_tokens"` + TotalTokens int `json:"total_tokens"` +} + +// chatClient talks to one OpenAI-compatible endpoint. +type chatClient struct { + baseURL string + apiKey string + http *http.Client +} + +func newChatClient(baseURL, apiKey string, timeout time.Duration) *chatClient { + return &chatClient{ + baseURL: strings.TrimSuffix(baseURL, "/"), + apiKey: apiKey, + http: &http.Client{Timeout: timeout}, + } +} + +// chatResponse is what one completion returned. +type chatResponse struct { + Message chatMessage + Reasoning string + FinishReason string + Usage usage +} + +// complete runs one chat completion. +func (c *chatClient) complete(ctx context.Context, model string, messages []chatMessage, tools []toolDef, temperature float64) (*chatResponse, error) { + req := map[string]any{ + "model": model, + "messages": messages, + "temperature": temperature, + "stream": false, + } + if len(tools) > 0 { + req["tools"] = tools + } + payload, err := json.Marshal(req) + if err != nil { + return nil, fmt.Errorf("encode chat request: %w", err) + } + httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+"/chat/completions", bytes.NewReader(payload)) + if err != nil { + return nil, fmt.Errorf("build chat request: %w", err) + } + httpReq.Header.Set("Content-Type", "application/json") + if c.apiKey != "" { + httpReq.Header.Set("Authorization", "Bearer "+c.apiKey) + } + resp, err := c.http.Do(httpReq) + if err != nil { + return nil, fmt.Errorf("call chat completions for %s: %w", model, err) + } + defer resp.Body.Close() + body, err := io.ReadAll(io.LimitReader(resp.Body, 32<<20)) + if err != nil { + return nil, fmt.Errorf("read chat response: %w", err) + } + if resp.StatusCode < 200 || resp.StatusCode > 299 { + return nil, fmt.Errorf("chat completions for %s answered %d: %s", model, resp.StatusCode, strings.TrimSpace(string(body))) + } + var decoded struct { + Choices []struct { + Message struct { + chatMessage + Reasoning string `json:"reasoning"` + } `json:"message"` + FinishReason string `json:"finish_reason"` + } `json:"choices"` + Usage usage `json:"usage"` + } + if err := json.Unmarshal(body, &decoded); err != nil { + return nil, fmt.Errorf("decode chat response: %w", err) + } + if len(decoded.Choices) == 0 { + return nil, fmt.Errorf("chat completions for %s returned no choices", model) + } + choice := decoded.Choices[0] + return &chatResponse{ + Message: choice.Message.chatMessage, + Reasoning: choice.Message.Reasoning, + FinishReason: choice.FinishReason, + Usage: decoded.Usage, + }, nil +} + +// listModels returns the ids the endpoint serves — the preflight check that +// a requested model is actually pulled. +func (c *chatClient) listModels(ctx context.Context) ([]string, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+"/models", nil) + if err != nil { + return nil, fmt.Errorf("build models request: %w", err) + } + if c.apiKey != "" { + req.Header.Set("Authorization", "Bearer "+c.apiKey) + } + resp, err := c.http.Do(req) + if err != nil { + return nil, fmt.Errorf("list models: %w", err) + } + defer resp.Body.Close() + body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) + if err != nil { + return nil, fmt.Errorf("read models response: %w", err) + } + if resp.StatusCode < 200 || resp.StatusCode > 299 { + return nil, fmt.Errorf("models endpoint answered %d: %s", resp.StatusCode, strings.TrimSpace(string(body))) + } + var decoded struct { + Data []struct { + Id string `json:"id"` + } `json:"data"` + } + if err := json.Unmarshal(body, &decoded); err != nil { + return nil, fmt.Errorf("decode models response: %w", err) + } + ids := make([]string, 0, len(decoded.Data)) + for _, m := range decoded.Data { + ids = append(ids, m.Id) + } + return ids, nil +} diff --git a/cmd/apiv2eval/classify.go b/cmd/apiv2eval/classify.go new file mode 100644 index 0000000000..9a91e851d6 --- /dev/null +++ b/cmd/apiv2eval/classify.go @@ -0,0 +1,709 @@ +package main + +// classify.go — the three instrumented questions, computed from an +// attempt's recorded calls and HTTP exchanges. Each is a count over what +// the model actually emitted, never a judgment the model reported about +// itself. +// +// 1. Did an insert_blocks payload carry an `id`? The schema stopped +// publishing one (§8.30/§8.31) precisely because a decoder emits the +// fields it is shown. replace_subtree — which still publishes one — is +// the control: the same model, the same run, one schema with the field +// and one without. +// 2. When a block id was echoed back, was it the exact string the read +// served? The default read serves 5-char labels; the write channels +// resolve an exact id or a unique suffix, so "close" has grades. +// 3. After a refusal, did the next turn fix the field the error named, or +// repeat itself? A refusal a model cannot act on is only half a fix. +// +// Two more, added for the edit_text A/B (toolset.go): +// +// 4. Did the model fill edit_text's optional `block`, and what did that +// cost? EditTextWithBlock is the quantity the three arms vary the +// published surface to move; SnippetAmbiguous is the trade B1 buys it +// with — a refusal that names candidate blocks, which under B1 the model +// has no argument to act on. +// 5. Wasted reads: a read whose result the model did not need. An outline +// immediately followed by a full read of the same object (outline +// carries text only on headings, so a snippet cannot be located in it), +// and a full read that precedes an edit_text supplying no block. The +// second is only waste where the prompt already carries the text to +// find — on read-then-edit the model genuinely must read first — so the +// two kinds are counted apart and never summed into one number. + +import ( + "encoding/json" + "fmt" + "regexp" + "sort" + "strconv" + "strings" +) + +// idEmission is one `id` a payload carried, with the path it sat at. +type idEmission struct { + Turn int `json:"turn"` + Tool string `json:"tool"` + Path string `json:"path"` + Value string `json:"value"` +} + +// refUse is one block-reference argument the model wrote. +type refUse struct { + Turn int `json:"turn"` + Tool string `json:"tool"` + Arg string `json:"arg"` + Value string `json:"value"` + Class string `json:"class"` + IsError bool `json:"is_error"` +} + +// reference-echo classes (H2). +const ( + refExact = "exact_served" // the string a read served, verbatim + refStale = "stale_served" // served by an EARLIER read, not the latest + refSuffix = "suffix_of_served" // resolvable server-side by unique suffix + refSubstring = "substring_of_served" // recognisable but not resolvable + refCaseFold = "case_variant" // right characters, wrong case + refInvented = "not_served" // no read ever served this + refNoRead = "no_read_yet" // written before any read in this attempt + refHandleLike = "handle_number" // a find handle used where a block id belongs +) + +// repair is one refusal and what the model did on its next call. +type repair struct { + Turn int `json:"turn"` + Tool string `json:"tool"` + Status int `json:"status,omitempty"` + Code string `json:"code,omitempty"` + Path string `json:"path,omitempty"` + NamedField string `json:"named_field,omitempty"` + NextTool string `json:"next_tool,omitempty"` + Class string `json:"class"` + NextOK bool `json:"next_ok,omitempty"` +} + +// repair classes (H3). +const ( + repairFixedNamed = "fixed_named_field" + repairChangedElse = "changed_other_field" + repairIdentical = "identical_repeat" + repairSwitchRead = "switched_to_read" + repairSwitchTool = "switched_tool" + repairAbandoned = "abandoned" +) + +// wastedRead is one read whose result the model did not need. +type wastedRead struct { + Turn int `json:"turn"` + Tool string `json:"tool"` + Kind string `json:"kind"` +} + +// wasted-read kinds. +const ( + // wasteOutlineThenFull: an outline read immediately followed by a full + // read of the same object. Outline carries text only on headings, so a + // model that needs a snippet's block pays for both. + wasteOutlineThenFull = "outline_then_full" + // wasteReadBeforeSnippetEdit: a full read whose next write is an + // edit_text that supplied no block — the snippet located the block, and + // the document the read served went unused for that purpose. + wasteReadBeforeSnippetEdit = "full_read_before_snippet_edit" +) + +// The locator's two refusals, recognised by the product's own words. They +// are produced by the SERVER (§8.43, v2/service/locator.go) and re-spelled +// into the tool register by the wrapper (opsVocab: "retry with id naming" +// → "retry with block naming"); a harness test drives the real wrapper +// over server-shaped bodies, and the wording pins live where each half is +// produced (v2/service/edit_test.go, wrapper/tools_smallmodel_test.go). +const ( + snippetNoMatchText = "no block contains" + snippetAmbiguousText = "retry with block naming one of" + snippetTooManyText = "provide more context to make the match unique" +) + +// signals is the instrumented summary of one attempt. +type signals struct { + InsertBlocksCalls int `json:"insert_blocks_calls"` + InsertBlocksWithId int `json:"insert_blocks_with_id"` + ReplaceSubtreeCalls int `json:"replace_subtree_calls"` + ReplaceSubtreeIds int `json:"replace_subtree_with_id"` + IdEmissions []idEmission `json:"id_emissions,omitempty"` + // UnknownArgCalls counts calls refused for naming an argument the tool + // does not have — the wrapper-arm shadow of the same question. + UnknownArgCalls int `json:"unknown_arg_calls"` + // OpConstAbsent / OpConstWrong count the discriminator the op schemas + // mark required with a const. The ops arm sets it from the tool name, so + // these never affect an outcome — they measure how a small model reads a + // const, which is the same reading skill the rest of the schema needs. + OpConstAbsent int `json:"op_const_absent"` + OpConstWrong int `json:"op_const_wrong"` + Refs []refUse `json:"refs,omitempty"` + Repairs []repair `json:"repairs,omitempty"` + MalformedArgs int `json:"malformed_args"` + + // EditTextCalls / EditTextWithBlock are the edit_text A/B's quantity: + // how often the model filled an argument it did not need. Under the + // arm that publishes no block, a non-zero WithBlock is a field the + // model supplied without being shown it (the runner still accepts it — + // the arms vary the published surface, not the server). + EditTextCalls int `json:"edit_text_calls"` + EditTextWithBlock int `json:"edit_text_with_block"` + // SnippetAmbiguous / SnippetNoMatch count locateBlock's refusals — the + // price of snippet-only location, which B1 pays in full. + SnippetAmbiguous int `json:"snippet_ambiguous"` + SnippetNoMatch int `json:"snippet_no_match"` + WastedReads []wastedRead `json:"wasted_reads,omitempty"` + // FindCalls / FindMultiMatch watch the fixture isolation: a find that + // returns more than one object means the run is matching its own + // leftovers again, which decays a rate for a reason that is not the API. + FindCalls int `json:"find_calls"` + FindMultiMatch int `json:"find_multi_match"` + MaxFindMatches int `json:"max_find_matches"` + // ObjectArgIsBlockRef / SpaceIdAsObject count the two ways an id lands + // in the wrong argument. Both are "the model had an id and one slot to + // put it in": a block label written as `object` (seen the first time an + // arm published no block argument), and a space id written as `object` + // (seen when the harness handed one over naming no argument for it). + ObjectArgIsBlockRef int `json:"object_arg_is_block_ref"` + SpaceIdAsObject int `json:"space_id_as_object"` +} + +// opToolNames is the ops arm's tool set — the names that are also op +// discriminator values. +var opToolNames = func() map[string]bool { + m := make(map[string]bool, len(opsArmOps)) + for _, op := range opsArmOps { + m[op] = true + } + return m +}() + +// readingTools name the calls whose RESULT is a served document — the +// source of the ids an echo is measured against. +var readingTools = map[string]bool{"read": true, "read_object": true} + +// refArgs are the arguments that carry a block reference, per surface. The +// wrapper renames the op vocabulary (inside→under, id→block, table_id→table) +// and the ops arm keeps the raw names; both are listed because one harness +// classifies both arms. +var refArgs = map[string]bool{ + "block": true, "after": true, "under": true, "table": true, + "row": true, "col": true, "id": true, "before": true, + "inside": true, "table_id": true, +} + +// analyze computes the signals of one attempt from its call list. +func analyze(calls []callRecord) signals { + var s signals + var everServed map[string]bool + var currentServed map[string]bool + sawRead := false + + for i, call := range calls { + if call.ArgsError != "" { + s.MalformedArgs++ + continue + } + var args map[string]any + if err := json.Unmarshal(call.Args, &args); err != nil || args == nil { + continue + } + + // H1 — the payload id question, and its control + switch call.Tool { + case "insert_blocks": + s.InsertBlocksCalls++ + found := collectIdPaths(args, "") + if len(found) > 0 { + s.InsertBlocksWithId++ + } + for _, e := range found { + e.Turn, e.Tool = call.Turn, call.Tool + s.IdEmissions = append(s.IdEmissions, e) + } + case "replace_subtree": + s.ReplaceSubtreeCalls++ + // the control counts ids in the PAYLOAD (blocks[…]), not the op's + // own required `id` — that one names the block being replaced and + // is not the field under question + payload := map[string]any{} + if blocks, ok := args["blocks"]; ok { + payload["blocks"] = blocks + } + if found := collectIdPaths(payload, ""); len(found) > 0 { + s.ReplaceSubtreeIds++ + for _, e := range found { + e.Turn, e.Tool = call.Turn, call.Tool + s.IdEmissions = append(s.IdEmissions, e) + } + } + } + if call.IsError && strings.Contains(call.ResultText, "does not take") { + s.UnknownArgCalls++ + } + + // H4 — the edit_text A/B: the optional field, and what refusing to + // guess costs when it is absent + if call.Tool == "edit_text" { + s.EditTextCalls++ + block, _ := args["block"].(string) + if block != "" { + s.EditTextWithBlock++ + } + // only a call that named NO block can earn a snippet-location + // refusal. The server's own multiple-matches text is nearly the + // wrapper's, and it fires on the explicit-block path too — where + // it says something else entirely about the same words. + if block == "" { + switch { + case strings.Contains(call.ResultText, snippetAmbiguousText), + strings.Contains(call.ResultText, snippetTooManyText): + s.SnippetAmbiguous++ + case strings.Contains(call.ResultText, snippetNoMatchText): + s.SnippetNoMatch++ + } + } + } + if call.Tool == "find" && !call.IsError { + s.FindCalls++ + if n, ok := findMatchCount(call.ResultText); ok { + if n > s.MaxFindMatches { + s.MaxFindMatches = n + } + if n > 1 { + s.FindMultiMatch++ + } + } + } + if opToolNames[call.Tool] { + switch op, present := args["op"]; { + case !present: + s.OpConstAbsent++ + case op != call.Tool: + s.OpConstWrong++ + } + } + + // H2 — reference echo fidelity. Argument names are walked in sorted + // order: map iteration is randomized, and a record that reorders + // itself between runs of the same input is not a record. + argNames := make([]string, 0, len(args)) + for arg := range args { + argNames = append(argNames, arg) + } + sort.Strings(argNames) + for _, arg := range argNames { + if !refArgs[arg] { + continue + } + value, ok := args[arg].(string) + if !ok || value == "" { + continue + } + s.Refs = append(s.Refs, refUse{ + Turn: call.Turn, Tool: call.Tool, Arg: arg, Value: value, + Class: classifyRef(value, currentServed, everServed, sawRead), + IsError: call.IsError, + }) + } + + // an `object` argument that is neither a handle nor an object id, but + // a block reference a read served: the id the model had, in the only + // id-shaped slot its tool offered + if target, ok := args["object"].(string); ok && target != "" && !isHandleLike(target) && everServed[target] { + s.ObjectArgIsBlockRef++ + } + + // a read REFRESHES the served vocabulary for everything after it + if readingTools[call.Tool] && !call.IsError { + ids := servedIds(call.ResultText) + if len(ids) > 0 { + sawRead = true + currentServed = ids + if everServed == nil { + everServed = map[string]bool{} + } + for id := range ids { + everServed[id] = true + } + } + } + + // H3 — the repair loop + if call.IsError { + s.Repairs = append(s.Repairs, classifyRepair(calls, i)) + } + } + sort.SliceStable(s.Refs, func(i, j int) bool { return s.Refs[i].Turn < s.Refs[j].Turn }) + s.WastedReads = wastedReads(calls) + return s +} + +// wastedReads finds the reads whose result the model did not need. Both +// kinds are structural — an outline superseded by a full read of the same +// object, and a full read followed by an edit that located its block from +// the snippet — so neither depends on judging what the model "meant". +func wastedReads(calls []callRecord) []wastedRead { + var out []wastedRead + counted := map[int]bool{} + for i, call := range calls { + if !readingTools[call.Tool] || call.IsError || i+1 >= len(calls) { + continue + } + next := calls[i+1] + if !readingTools[next.Tool] || next.IsError { + continue + } + if readIsOutline(call) && !readIsOutline(next) && readTarget(call) == readTarget(next) { + counted[i] = true + out = append(out, wastedRead{Turn: call.Turn, Tool: call.Tool, Kind: wasteOutlineThenFull}) + } + } + for i, call := range calls { + // the edit's OUTCOME is deliberately not a condition: whether the + // model needed the read to compose the call is answered by the call's + // arguments. Requiring success would undercount exactly on the arm + // whose edits fail most, which is the arm the experiment is about. + if call.Tool != "edit_text" { + continue + } + var args map[string]any + if json.Unmarshal(call.Args, &args) != nil { + continue + } + if block, _ := args["block"].(string); block != "" { + continue + } + // walk back to the read that fed this edit, past nothing but reads: + // a read separated from the edit by another write served that write + for j := i - 1; j >= 0; j-- { + prior := calls[j] + if !readingTools[prior.Tool] { + break + } + if prior.IsError || readIsOutline(prior) || counted[j] { + continue + } + counted[j] = true + out = append(out, wastedRead{Turn: prior.Turn, Tool: prior.Tool, Kind: wasteReadBeforeSnippetEdit}) + break + } + } + sort.SliceStable(out, func(i, j int) bool { return out[i].Turn < out[j].Turn }) + return out +} + +// countSpaceIdAsObject counts calls that passed the SPACE id where an object +// belongs. The harness's own preamble caused this once — it named the space +// id without naming the argument it belongs to — so the count has to survive +// the fix that was supposed to end it, or nobody learns whether it did. +func countSpaceIdAsObject(calls []callRecord, spaceId string) int { + n := 0 + for _, call := range calls { + var args map[string]any + if json.Unmarshal(call.Args, &args) != nil { + continue + } + if target, _ := args["object"].(string); target == spaceId { + n++ + } + } + return n +} + +// readIsOutline reports whether a read asked for the outline shape — the +// wrapper spells it mode=outline, the ops arm outline=true. +func readIsOutline(call callRecord) bool { + var args map[string]any + if json.Unmarshal(call.Args, &args) != nil { + return false + } + if mode, _ := args["mode"].(string); mode == "outline" { + return true + } + outline, _ := args["outline"].(bool) + return outline +} + +// readTarget names the object a read addressed; the ops arm's read_object is +// bound to one object and names none. +func readTarget(call callRecord) string { + var args map[string]any + if json.Unmarshal(call.Args, &args) != nil { + return "" + } + target, _ := args["object"].(string) + return target +} + +// findMatchCountRe reads the count off find's own summary line ("3 matches", +// "12 matches — showing 10; narrow with …"). +var findMatchCountRe = regexp.MustCompile(`(?m)^(\d+) match`) + +// findMatchCount returns how many objects a find reported. +func findMatchCount(text string) (int, bool) { + if strings.Contains(text, "no matches") { + return 0, true + } + m := findMatchCountRe.FindStringSubmatch(text) + if m == nil { + return 0, false + } + n, err := strconv.Atoi(m[1]) + if err != nil { + return 0, false + } + return n, true +} + +// collectIdPaths walks a decoded payload and returns every `id` key in it, +// at any depth, with its JSON path. +func collectIdPaths(v any, path string) []idEmission { + var out []idEmission + switch t := v.(type) { + case map[string]any: + keys := make([]string, 0, len(t)) + for k := range t { + keys = append(keys, k) + } + sort.Strings(keys) + for _, k := range keys { + child := k + if path != "" { + child = path + "." + k + } + if k == "id" { + if s, ok := t[k].(string); ok { + out = append(out, idEmission{Path: child, Value: s}) + continue + } + } + out = append(out, collectIdPaths(t[k], child)...) + } + case []any: + for i, item := range t { + out = append(out, collectIdPaths(item, fmt.Sprintf("%s[%d]", path, i))...) + } + } + return out +} + +// servedIds extracts every doc-local id from a served document: block ids, +// plus table row and column ids — exactly the strings the write channels +// take as references. +func servedIds(body string) map[string]bool { + trimmed := strings.TrimSpace(body) + if !strings.HasPrefix(trimmed, "{") { + return nil + } + var doc struct { + Blocks []struct { + Id string `json:"id"` + Columns []struct { + Id string `json:"id"` + } `json:"columns"` + Rows []struct { + Id string `json:"id"` + } `json:"rows"` + } `json:"blocks"` + } + if err := json.Unmarshal([]byte(trimmed), &doc); err != nil { + return nil + } + ids := map[string]bool{} + for _, b := range doc.Blocks { + if b.Id != "" { + ids[b.Id] = true + } + for _, c := range b.Columns { + if c.Id != "" { + ids[c.Id] = true + } + } + for _, r := range b.Rows { + if r.Id != "" { + ids[r.Id] = true + } + } + } + if len(ids) == 0 { + return nil + } + return ids +} + +// classifyRef grades one echoed reference against what the reads served. +func classifyRef(value string, current, ever map[string]bool, sawRead bool) string { + if !sawRead { + if isHandleLike(value) { + return refHandleLike + } + return refNoRead + } + if current[value] { + return refExact + } + if ever[value] { + return refStale + } + for id := range ever { + if strings.EqualFold(id, value) { + return refCaseFold + } + } + // a bare number is a find handle written where a block reference belongs, + // even though it is a suffix of half the ids in any document — the suffix + // tests below would swallow it + if isHandleLike(value) { + return refHandleLike + } + for id := range ever { + if len(value) < len(id) && strings.HasSuffix(id, value) { + return refSuffix + } + } + for id := range ever { + if len(value) < len(id) && strings.Contains(id, value) { + return refSubstring + } + } + return refInvented +} + +// isHandleLike recognises a find handle (1, 2, …) written where a block +// reference belongs. +func isHandleLike(value string) bool { + if len(value) == 0 || len(value) > 3 { + return false + } + for _, r := range value { + if r < '0' || r > '9' { + return false + } + } + return true +} + +// classifyRepair looks at what the model did on the call AFTER a refusal. +func classifyRepair(calls []callRecord, i int) repair { + call := calls[i] + r := repair{Turn: call.Turn, Tool: call.Tool} + if len(call.Exchanges) > 0 { + for _, ex := range call.Exchanges { + if ex.Status >= 400 { + r.Status, r.Code = ex.Status, ex.Code + if len(ex.Issues) > 0 { + r.Path = ex.Issues[0].Path + } + } + } + } + r.NamedField = namedField(r.Path, call.ResultText) + if i+1 >= len(calls) { + r.Class = repairAbandoned + return r + } + next := calls[i+1] + r.NextTool = next.Tool + r.NextOK = !next.IsError + if next.Tool != call.Tool { + if readingTools[next.Tool] || next.Tool == "find" || next.Tool == "describe" || next.Tool == "spaces" { + r.Class = repairSwitchRead + } else { + r.Class = repairSwitchTool + } + return r + } + if string(next.Args) == string(call.Args) { + r.Class = repairIdentical + return r + } + if r.NamedField != "" && fieldChanged(call.Args, next.Args, r.NamedField) { + r.Class = repairFixedNamed + return r + } + r.Class = repairChangedElse + return r +} + +// namedField extracts the argument an error blamed, from the text the MODEL +// saw rather than from the wire. The two differ on purpose: the wrapper +// translates the op vocabulary into the tool vocabulary before the model +// reads it (ops[0].id → block), so scoring a repair against the raw issue +// path would blame the model for not fixing a field it was never shown. +// The wire path is the fallback for errors that carry no rendered issue. +func namedField(path, text string) string { + if field := fieldFromIssueLines(text); field != "" { + return field + } + if seg := lastPathSegment(path); seg != "" { + return seg + } + // wrapper-side refusals name the argument in quotes: `read: "mode" must + // be one of full, outline` + if i := strings.IndexByte(text, '"'); i >= 0 { + if j := strings.IndexByte(text[i+1:], '"'); j > 0 { + candidate := text[i+1 : i+1+j] + if candidate != "" && !strings.ContainsAny(candidate, " \n") { + return candidate + } + } + } + return "" +} + +// fieldFromIssueLines reads the path off a rendered C6 issue line, which +// both surfaces indent and prefix with ": ". +func fieldFromIssueLines(text string) string { + for _, line := range strings.Split(text, "\n") { + if line == "" || !(line[0] == ' ' || line[0] == '\t') { + continue + } + trimmed := strings.TrimSpace(line) + head, _, ok := strings.Cut(trimmed, ": ") + if !ok || head == "" || strings.ContainsAny(head, " \t\"") { + continue + } + if seg := lastPathSegment(head); seg != "" { + return seg + } + } + return "" +} + +// lastPathSegment reduces a JSON path to the field it addresses: +// ops[0].blocks[0].id → id, block → block. +func lastPathSegment(path string) string { + if path == "" { + return "" + } + seg := path + if i := strings.LastIndexByte(seg, '.'); i >= 0 { + seg = seg[i+1:] + } + if i := strings.IndexByte(seg, '['); i >= 0 { + seg = seg[:i] + } + return seg +} + +// fieldChanged reports whether the named top-level argument differs between +// two calls (absent on one side counts as a change). +func fieldChanged(before, after json.RawMessage, field string) bool { + var a, b map[string]any + if json.Unmarshal(before, &a) != nil || json.Unmarshal(after, &b) != nil { + return false + } + av, aok := a[field] + bv, bok := b[field] + if aok != bok { + return true + } + if !aok { + return false + } + return fmt.Sprintf("%v", av) != fmt.Sprintf("%v", bv) +} diff --git a/cmd/apiv2eval/classify_test.go b/cmd/apiv2eval/classify_test.go new file mode 100644 index 0000000000..6c0fedb210 --- /dev/null +++ b/cmd/apiv2eval/classify_test.go @@ -0,0 +1,401 @@ +package main + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + v2model "github.com/anyproto/anytype-heart/core/api/v2/model" +) + +// servedDoc is a read result in the shape the API serves: compact 5-char +// labels for minted block ids. +const servedDoc = `{"id":"obj1","blocks":[` + + `{"id":"a1b2c","type":"heading2","text":"Summary"},` + + `{"id":"d4e5f","type":"paragraph","text":"Revenue target for Q3 is 1.2M."},` + + `{"id":"t9d2c","type":"table","columns":[{"id":"c0011"},{"id":"c0022"}],` + + `"rows":[{"id":"r0011","isHeader":true,"cells":["Component","Status"]},{"id":"r0022","cells":["Beta","Pending"]}]}` + + `]}` + +func call(turn int, tool, args string) callRecord { + return callRecord{Turn: turn, Tool: tool, Args: json.RawMessage(args)} +} + +func TestAnalyzeInsertBlocksIdEmission(t *testing.T) { + t.Run("an id anywhere in an insert_blocks payload is counted, with its path", func(t *testing.T) { + // given + calls := []callRecord{ + call(0, "insert_blocks", `{"op":"insert_blocks","blocks":[{"id":"b17","type":"paragraph","text":"hi"}]}`), + call(1, "insert_blocks", `{"op":"insert_blocks","markdown":"## Risks"}`), + } + + // when + got := analyze(calls) + + // then + assert.Equal(t, 2, got.InsertBlocksCalls) + assert.Equal(t, 1, got.InsertBlocksWithId) + require.Len(t, got.IdEmissions, 1) + assert.Equal(t, "blocks[0].id", got.IdEmissions[0].Path) + assert.Equal(t, "b17", got.IdEmissions[0].Value) + }) + + t.Run("a nested row id counts too — the schema stopped showing that one as well", func(t *testing.T) { + // given + calls := []callRecord{ + call(0, "insert_blocks", `{"op":"insert_blocks","blocks":[{"type":"table","rows":[{"id":"r7","cells":["a"]}]}]}`), + } + + // when + got := analyze(calls) + + // then + assert.Equal(t, 1, got.InsertBlocksWithId) + require.Len(t, got.IdEmissions, 1) + assert.Equal(t, "blocks[0].rows[0].id", got.IdEmissions[0].Path) + }) + + t.Run("replace_subtree is the control: the op's own id does not count, a payload id does", func(t *testing.T) { + // given + calls := []callRecord{ + call(0, "replace_subtree", `{"op":"replace_subtree","id":"d4e5f","blocks":[{"type":"paragraph","text":"x"}]}`), + call(1, "replace_subtree", `{"op":"replace_subtree","id":"d4e5f","blocks":[{"id":"d4e5f","type":"paragraph","text":"x"}]}`), + } + + // when + got := analyze(calls) + + // then + assert.Equal(t, 2, got.ReplaceSubtreeCalls) + assert.Equal(t, 1, got.ReplaceSubtreeIds) + }) +} + +func TestAnalyzeReferenceEcho(t *testing.T) { + t.Run("classes grade an echo against what the last read served", func(t *testing.T) { + // given + read := callRecord{Turn: 0, Tool: "read", Args: json.RawMessage(`{"object":"1"}`), ResultText: servedDoc} + calls := []callRecord{ + read, + call(1, "edit_text", `{"object":"1","block":"d4e5f","find":"Q3","replace":"Q4"}`), + call(2, "edit_text", `{"object":"1","block":"D4E5F","find":"a","replace":"b"}`), + call(3, "edit_text", `{"object":"1","block":"4e5f","find":"a","replace":"b"}`), + call(4, "edit_text", `{"object":"1","block":"zzzzz","find":"a","replace":"b"}`), + call(5, "check_item", `{"object":"1","block":"2","checked":true}`), + } + want := []string{refExact, refCaseFold, refSuffix, refInvented, refHandleLike} + + // when + got := analyze(calls) + + // then + require.Len(t, got.Refs, len(want)) + for i, class := range want { + assert.Equal(t, class, got.Refs[i].Class, "ref %d (%q)", i, got.Refs[i].Value) + } + }) + + t.Run("a reference written before any read is its own class", func(t *testing.T) { + // given + calls := []callRecord{call(0, "edit_text", `{"object":"1","block":"abcde","find":"a","replace":"b"}`)} + + // when + got := analyze(calls) + + // then + require.Len(t, got.Refs, 1) + assert.Equal(t, refNoRead, got.Refs[0].Class) + }) + + t.Run("an id from an earlier read that the latest one no longer serves is stale, not invented", func(t *testing.T) { + // given + second := `{"id":"obj1","blocks":[{"id":"a1b2c","type":"heading2","text":"Summary"}]}` + calls := []callRecord{ + {Turn: 0, Tool: "read", Args: json.RawMessage(`{"object":"1"}`), ResultText: servedDoc}, + {Turn: 1, Tool: "read", Args: json.RawMessage(`{"object":"1"}`), ResultText: second}, + call(2, "delete_block", `{"object":"1","block":"d4e5f"}`), + } + + // when + got := analyze(calls) + + // then + require.Len(t, got.Refs, 1) + assert.Equal(t, refStale, got.Refs[0].Class) + }) + + t.Run("table row and column labels are references too", func(t *testing.T) { + // given + calls := []callRecord{ + {Turn: 0, Tool: "read", Args: json.RawMessage(`{"object":"1"}`), ResultText: servedDoc}, + call(1, "set_cell", `{"object":"1","table":"t9d2c","row":"r0022","col":"c0022","value":"Done"}`), + } + + // when + got := analyze(calls) + + // then + require.Len(t, got.Refs, 3) + for _, ref := range got.Refs { + assert.Equal(t, refExact, ref.Class, "%s=%q", ref.Arg, ref.Value) + } + }) +} + +func TestAnalyzeRepairAfterRefusal(t *testing.T) { + refused := func(turn int, tool, args, path, text string) callRecord { + c := call(turn, tool, args) + c.IsError = true + c.ResultText = text + c.Exchanges = []exchange{{ + Status: 400, Code: "invalid_input", + Issues: []v2model.Issue{{Path: path, Message: text}}, + }} + return c + } + + t.Run("changing the field the error named is the repair we hope for", func(t *testing.T) { + // given + calls := []callRecord{ + refused(0, "edit_text", `{"object":"1","block":"zz","find":"Q3","replace":"Q4"}`, "block", "unknown block"), + call(1, "edit_text", `{"object":"1","block":"d4e5f","find":"Q3","replace":"Q4"}`), + } + + // when + got := analyze(calls) + + // then + require.Len(t, got.Repairs, 1) + assert.Equal(t, repairFixedNamed, got.Repairs[0].Class) + assert.Equal(t, "block", got.Repairs[0].NamedField) + assert.Equal(t, 400, got.Repairs[0].Status) + assert.True(t, got.Repairs[0].NextOK) + }) + + t.Run("an identical re-send is the loop the refusal was meant to prevent", func(t *testing.T) { + // given + args := `{"object":"1","block":"zz","find":"Q3","replace":"Q4"}` + calls := []callRecord{ + refused(0, "edit_text", args, "block", "unknown block"), + refused(1, "edit_text", args, "block", "unknown block"), + } + + // when + got := analyze(calls) + + // then + require.Len(t, got.Repairs, 2) + assert.Equal(t, repairIdentical, got.Repairs[0].Class) + assert.Equal(t, repairAbandoned, got.Repairs[1].Class) + }) + + t.Run("reaching for a read after a refusal is its own class", func(t *testing.T) { + // given + calls := []callRecord{ + refused(0, "edit_text", `{"object":"1","find":"Q3","replace":"Q4"}`, "find", "no block contains"), + call(1, "read", `{"object":"1","mode":"outline"}`), + } + + // when + got := analyze(calls) + + // then + require.Len(t, got.Repairs, 1) + assert.Equal(t, repairSwitchRead, got.Repairs[0].Class) + }) + + t.Run("an op-path issue names the field at its last segment", func(t *testing.T) { + // given + calls := []callRecord{ + refused(0, "insert_blocks", `{"op":"insert_blocks","blocks":[{"id":"x","type":"paragraph"}]}`, + "ops[0].blocks[0].id", "id is not part of insert_blocks"), + call(1, "insert_blocks", `{"op":"insert_blocks","blocks":[{"type":"paragraph"}]}`), + } + + // when + got := analyze(calls) + + // then + require.Len(t, got.Repairs, 1) + assert.Equal(t, "id", got.Repairs[0].NamedField) + // the id moved out of a nested payload — a top-level compare cannot + // see it, so the honest class is "changed something else" + assert.Equal(t, repairChangedElse, got.Repairs[0].Class) + }) +} + +func TestAnalyzeCountsMalformedArguments(t *testing.T) { + // given + calls := []callRecord{{Turn: 0, Tool: "read", ArgsError: "invalid character 'x'"}} + + // when + got := analyze(calls) + + // then + assert.Equal(t, 1, got.MalformedArgs) + assert.Empty(t, got.Refs) +} + +func TestReferenceOrderIsDeterministic(t *testing.T) { + // given — one call carrying three references; map iteration would + // reorder them between runs, and a record that reorders itself is not a + // record + calls := []callRecord{ + {Turn: 0, Tool: "read", Args: json.RawMessage(`{"object":"1"}`), ResultText: servedDoc}, + call(1, "set_cell", `{"object":"1","table":"t9d2c","row":"r0022","col":"c0022","value":"Done"}`), + } + + // when + first := analyze(calls) + + // then + for i := 0; i < 20; i++ { + again := analyze(calls) + require.Equal(t, len(first.Refs), len(again.Refs)) + for j := range first.Refs { + assert.Equal(t, first.Refs[j].Arg, again.Refs[j].Arg, "ref %d reordered on run %d", j, i) + } + } + assert.Equal(t, []string{"col", "row", "table"}, []string{first.Refs[0].Arg, first.Refs[1].Arg, first.Refs[2].Arg}) +} + +func TestAnalyzeCountsTheOpDiscriminator(t *testing.T) { + // given — the ops arm sets `op` from the tool name, so a model that + // omits it or writes a positional word into it never fails for that; + // the reading skill is still worth a number + calls := []callRecord{ + call(0, "insert_blocks", `{"op":"insert_blocks","markdown":"## Risks"}`), + call(1, "insert_blocks", `{"markdown":"## Risks"}`), + call(2, "insert_blocks", `{"op":"last","markdown":"## Risks"}`), + call(3, "edit_text", `{"object":"1","find":"a","replace":"b"}`), + } + + // when + got := analyze(calls) + + // then + assert.Equal(t, 1, got.OpConstAbsent) + assert.Equal(t, 1, got.OpConstWrong, "a wrapper tool without an op field must not be counted") +} + +func TestAnalyzeCountsTheOptionalBlockAndTheReadsItCost(t *testing.T) { + t.Run("the shape gemma4:e2b produced on 3 of 3 attempts: outline, full, edit with block", func(t *testing.T) { + // given + calls := []callRecord{ + {Turn: 0, Tool: "find", Args: json.RawMessage(`{"space":"s","query":"Zafuriko"}`), + ResultText: "1. Zafuriko (page)\n1 matches"}, + {Turn: 1, Tool: "read", Args: json.RawMessage(`{"object":"1","mode":"outline"}`), ResultText: servedDoc}, + {Turn: 2, Tool: "read", Args: json.RawMessage(`{"object":"1","mode":"full"}`), ResultText: servedDoc}, + call(3, "edit_text", `{"object":"1","block":"d4e5f","find":"Q3","replace":"Q4"}`), + } + + // when + got := analyze(calls) + + // then + assert.Equal(t, 1, got.EditTextCalls) + assert.Equal(t, 1, got.EditTextWithBlock) + require.Len(t, got.WastedReads, 1, "the outline was superseded by the full read") + assert.Equal(t, wasteOutlineThenFull, got.WastedReads[0].Kind) + assert.Equal(t, 1, got.FindCalls) + assert.Equal(t, 0, got.FindMultiMatch) + assert.Equal(t, 1, got.MaxFindMatches) + }) + + t.Run("a full read before a snippet-only edit is the read the A/B tries to remove", func(t *testing.T) { + // given + calls := []callRecord{ + {Turn: 0, Tool: "read", Args: json.RawMessage(`{"object":"1","mode":"outline"}`), ResultText: servedDoc}, + {Turn: 1, Tool: "read", Args: json.RawMessage(`{"object":"1","mode":"full"}`), ResultText: servedDoc}, + call(2, "edit_text", `{"object":"1","find":"Q3","replace":"Q4"}`), + } + + // when + got := analyze(calls) + + // then + require.Len(t, got.WastedReads, 2) + assert.Equal(t, wasteOutlineThenFull, got.WastedReads[0].Kind) + assert.Equal(t, wasteReadBeforeSnippetEdit, got.WastedReads[1].Kind) + assert.Equal(t, 0, got.EditTextWithBlock) + }) + + t.Run("a read that fed an earlier write is not waste", func(t *testing.T) { + // given — the read served the add_blocks anchor; the later snippet + // edit did not need it + calls := []callRecord{ + {Turn: 0, Tool: "read", Args: json.RawMessage(`{"object":"1","mode":"full"}`), ResultText: servedDoc}, + call(1, "add_blocks", `{"object":"1","after":"d4e5f","markdown":"## Risks"}`), + call(2, "edit_text", `{"object":"1","find":"Q3","replace":"Q4"}`), + } + + // when + got := analyze(calls) + + // then + assert.Empty(t, got.WastedReads) + }) + + t.Run("find matches are counted so a contaminated run says so", func(t *testing.T) { + // given — what the shared-stem fixtures produced: one more leftover + // match on every attempt of a run + calls := []callRecord{ + {Turn: 0, Tool: "find", Args: json.RawMessage(`{"space":"s","query":"Quarterly plan 84353d"}`), + ResultText: "1. Quarterly plan 84353d (page)\n2. Quarterly plan 1425d9 (page)\n3 matches"}, + } + + // when + got := analyze(calls) + + // then + assert.Equal(t, 1, got.FindCalls) + assert.Equal(t, 1, got.FindMultiMatch) + assert.Equal(t, 3, got.MaxFindMatches) + }) +} + +func TestAnalyzeCountsIdsWrittenIntoTheObjectArgument(t *testing.T) { + // given — the two ways a model puts an id in the one slot its tool + // offers: a block label served by a read (seen the first time an arm + // published no block argument), and the space id (seen when the harness + // named one without naming its argument) + const spaceId = "bafyreispace.abc" + calls := []callRecord{ + {Turn: 0, Tool: "read", Args: json.RawMessage(`{"object":"` + spaceId + `","mode":"full"}`), + ResultText: "no working session for object", IsError: true}, + {Turn: 1, Tool: "find", Args: json.RawMessage(`{"space":"` + spaceId + `","query":"Zafuriko"}`), + ResultText: "1. Zafuriko (page)\n1 matches"}, + {Turn: 2, Tool: "read", Args: json.RawMessage(`{"object":"1","mode":"full"}`), ResultText: servedDoc}, + call(3, "edit_text", `{"object":"d4e5f","find":"Q3","replace":"Q4"}`), + } + + // when + got := analyze(calls) + + // then + assert.Equal(t, 1, got.ObjectArgIsBlockRef, "d4e5f is a block the read served, not an object") + assert.Equal(t, 1, countSpaceIdAsObject(calls, spaceId)) + assert.Equal(t, 0, countSpaceIdAsObject(calls, "some-other-space")) +} + +func TestWastedReadsDoNotDependOnTheEditSucceeding(t *testing.T) { + // given — the shape arm B1 produced live: the model read anyway, had no + // block argument to put the label in, and put it in `object` instead. + // Counting only successful edits would hide that read on exactly the arm + // the experiment is trying to measure. + calls := []callRecord{ + {Turn: 0, Tool: "read", Args: json.RawMessage(`{"object":"1","mode":"full"}`), ResultText: servedDoc}, + {Turn: 1, Tool: "edit_text", Args: json.RawMessage(`{"object":"d4e5f","find":"Q3","replace":"Q4"}`), + ResultText: `object "d4e5f" not found in space "s1"`, IsError: true}, + } + + // when + got := analyze(calls) + + // then + require.Len(t, got.WastedReads, 1) + assert.Equal(t, wasteReadBeforeSnippetEdit, got.WastedReads[0].Kind) + assert.Equal(t, 1, got.ObjectArgIsBlockRef) +} diff --git a/cmd/apiv2eval/harness_test.go b/cmd/apiv2eval/harness_test.go new file mode 100644 index 0000000000..daaab4cf5f --- /dev/null +++ b/cmd/apiv2eval/harness_test.go @@ -0,0 +1,594 @@ +package main + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/anyproto/anytype-heart/core/api/wrapper" +) + +// stubAPI is a minimal stand-in for the local /v2 server: enough of the +// surface for one read→edit loop, with a scripted refusal so the harness's +// error capture can be tested without a live app. It is NOT a model of the +// API — the real runs use the real server; this only exercises the harness. +type stubAPI struct { + mu sync.Mutex + patches []json.RawMessage + // refuseFirstPatch answers the first PATCH with a C6 400 naming a path. + refuseFirstPatch bool + doc string + // patchQueue scripts PATCH answers in order (e.g. the server's locator + // refusals, §8.43); when empty, the editOK default answers. + patchQueue []stubResponse +} + +// stubResponse is one scripted HTTP answer. +type stubResponse struct { + status int + body string +} + +func newStubAPI(doc string) *httptest.Server { + s := &stubAPI{doc: doc} + return httptest.NewServer(s) +} + +func (s *stubAPI) ServeHTTP(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch { + case r.URL.Path == "/v2/auth/whoami": + fmt.Fprint(w, `{"grant":{"scoped":false}}`) + case strings.HasSuffix(r.URL.Path, "/search"): + fmt.Fprint(w, `{"data":[{"id":"obj1","name":"Quarterly plan ab12","type":"page"}],"total":1,"has_more":false}`) + case r.Method == http.MethodGet && strings.Contains(r.URL.Path, "/objects/"): + fmt.Fprint(w, s.doc) + case r.Method == http.MethodPatch: + var body struct { + Ops []json.RawMessage `json:"ops"` + } + _ = json.NewDecoder(r.Body).Decode(&body) + s.mu.Lock() + first := len(s.patches) == 0 + s.patches = append(s.patches, body.Ops...) + var scripted *stubResponse + if len(s.patchQueue) > 0 { + scripted, s.patchQueue = &s.patchQueue[0], s.patchQueue[1:] + } + s.mu.Unlock() + if scripted != nil { + w.WriteHeader(scripted.status) + fmt.Fprint(w, scripted.body) + return + } + if first && s.refuseFirstPatch { + w.WriteHeader(http.StatusBadRequest) + fmt.Fprint(w, `{"status":400,"code":"invalid_input","message":"block \"zz\" not found",`+ + `"issues":[{"path":"ops[0].id","message":"no block matches \"zz\"","hint":"GET the object with ?outline=true to list block ids"}]}`) + return + } + fmt.Fprint(w, `{"etag":"e1","diff_stats":{"blocks_changed":1}}`) + case strings.HasPrefix(r.URL.Path, "/v2/schemas/ops/"): + op := strings.TrimPrefix(r.URL.Path, "/v2/schemas/ops/") + fmt.Fprintf(w, `{"kind":%q,"endpoint":"PATCH","schema":{"type":"object","x-op":%q},"example":{"ops":[]}}`, op, op) + default: + w.WriteHeader(http.StatusNotFound) + fmt.Fprint(w, `{"status":404,"code":"not_found","message":"stub has no route for `+r.URL.Path+`"}`) + } +} + +// scriptedModel serves the OpenAI chat-completions shape, replaying one +// scripted assistant message per call. +type scriptedModel struct { + mu sync.Mutex + turns []string // raw JSON for choices[0].message + seen []map[string]any +} + +func newScriptedModel(turns ...string) (*httptest.Server, *scriptedModel) { + m := &scriptedModel{turns: turns} + return httptest.NewServer(m), m +} + +func (m *scriptedModel) ServeHTTP(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + if strings.HasSuffix(r.URL.Path, "/models") { + fmt.Fprint(w, `{"data":[{"id":"stub"}]}`) + return + } + var req map[string]any + _ = json.NewDecoder(r.Body).Decode(&req) + m.mu.Lock() + m.seen = append(m.seen, req) + var message string + if len(m.turns) > 0 { + message, m.turns = m.turns[0], m.turns[1:] + } else { + message = `{"role":"assistant","content":"done"}` + } + m.mu.Unlock() + fmt.Fprintf(w, `{"choices":[{"index":0,"message":%s,"finish_reason":"stop"}],`+ + `"usage":{"prompt_tokens":10,"completion_tokens":5,"total_tokens":15}}`, message) +} + +func toolCallTurn(name, args string) string { + encoded, err := json.Marshal(args) + if err != nil { + panic(err) + } + return fmt.Sprintf(`{"role":"assistant","content":"","tool_calls":[{"id":"c1","type":"function",`+ + `"function":{"name":%q,"arguments":%s}}]}`, name, encoded) +} + +func TestWrapperArmDrivesTheProductMCPServer(t *testing.T) { + // given + api := newStubAPI(servedDoc) + defer api.Close() + rec := &recorder{} + client := wrapper.NewClient(api.URL, "key") + client.HTTP = &http.Client{Transport: &recordingTransport{base: http.DefaultTransport, rec: rec}} + runner := wrapper.NewRunner(client, wrapper.NewMemoryStore()) + + // when + ts, err := newMCPToolset(context.Background(), runner, wrapper.TierSmall, editTextAsShipped) + + // then + require.NoError(t, err) + defer ts.close() + + names := make([]string, 0, len(ts.tools())) + for _, spec := range ts.tools() { + names = append(names, spec.Name) + assert.NotEmpty(t, spec.Parameters, "tool %s has no schema", spec.Name) + } + assert.Equal(t, wrapper.ToolNamesForTier(wrapper.TierSmall), names, + "the arm must serve the tier's published set, not a set the harness wrote") + assert.Contains(t, ts.instructions(), "find", "the system prompt is the product's own instructions") + + out := ts.call(context.Background(), "find", map[string]any{"space": "space1", "query": "Quarterly"}) + assert.False(t, out.IsError) + assert.Contains(t, out.Text, "1. Quarterly plan ab12") + + out = ts.call(context.Background(), "nope", map[string]any{}) + assert.True(t, out.IsError) + assert.Contains(t, out.Text, "unknown tool") +} + +// editTextSpec returns the edit_text entry one variant publishes, built over +// the real MCP server so the bytes are the product's own. +func editTextSpec(t *testing.T, variant editTextVariant) toolSpec { + t.Helper() + client := wrapper.NewClient("http://stub", "key") + client.HTTP = &http.Client{Transport: &stubTransport{handler: &stubAPI{doc: servedDoc}}} + ts, err := newMCPToolset(context.Background(), wrapper.NewRunner(client, wrapper.NewMemoryStore()), + wrapper.TierSmall, variant) + require.NoError(t, err) + t.Cleanup(func() { ts.close() }) + for _, spec := range ts.tools() { + if spec.Name == "edit_text" { + return spec + } + } + t.Fatalf("the %q arm publishes no edit_text", variant) + return toolSpec{} +} + +// The A/B's whole validity rests on this: three arms that differ in the +// PUBLISHED edit_text definition and in nothing else. If the schema drifts +// so that B1 stops removing anything, or if a variant reaches the runner, +// the experiment measures something other than the surface. +func TestTheEditTextArmsVaryThePublishedSurfaceAndNothingElse(t *testing.T) { + // given + a := editTextSpec(t, editTextAsShipped) + b1 := editTextSpec(t, editTextNoBlock) + b2 := editTextSpec(t, editTextProse) + + // then — A is the shipped definition, verbatim + shipped, ok := wrapper.ToolByName("edit_text") + require.True(t, ok) + assert.Equal(t, shipped.Description, a.Description) + assert.Contains(t, string(a.Parameters), `"block"`) + + // B1 drops block from the schema AND from the prose that names it + assert.NotContains(t, string(b1.Parameters), `"block"`, + "B1 must publish no block argument at all") + assert.NotContains(t, b1.Description, "block is optional") + assert.Equal(t, strings.Replace(a.Description, editTextBlockSentence, "", 1), b1.Description, + "B1 differs from A by exactly the removed sentence") + + // B2 keeps the schema byte-identical and leads with the instruction + assert.JSONEq(t, string(a.Parameters), string(b2.Parameters)) + assert.True(t, strings.HasPrefix(b2.Description, editTextNoReadFirst), "B2 leads with the instruction") + assert.Equal(t, editTextNoReadFirst+a.Description, b2.Description) + + // and every other tool is untouched in all three + for _, variant := range []editTextVariant{editTextNoBlock, editTextProse} { + client := wrapper.NewClient("http://stub", "key") + client.HTTP = &http.Client{Transport: &stubTransport{handler: &stubAPI{doc: servedDoc}}} + ts, err := newMCPToolset(context.Background(), wrapper.NewRunner(client, wrapper.NewMemoryStore()), + wrapper.TierSmall, variant) + require.NoError(t, err) + defer ts.close() + names := make([]string, 0, len(ts.tools())) + for _, spec := range ts.tools() { + names = append(names, spec.Name) + } + assert.Equal(t, wrapper.ToolNamesForTier(wrapper.TierSmall), names, + "a variant may not add or drop a tool") + } +} + +func TestTheEditTextArmsShareOneExecutor(t *testing.T) { + // given — the same call, snippet-only, against each arm. The server must + // behave identically or a difference between the arms is not + // attributable to what they published. + for _, variant := range []editTextVariant{editTextAsShipped, editTextNoBlock, editTextProse} { + t.Run(string("variant="+variant), func(t *testing.T) { + stub := &stubAPI{doc: servedDoc} + client := wrapper.NewClient("http://stub", "key") + client.HTTP = &http.Client{Transport: &stubTransport{handler: stub}} + ts, err := newMCPToolset(context.Background(), wrapper.NewRunner(client, wrapper.NewMemoryStore()), + wrapper.TierSmall, variant) + require.NoError(t, err) + defer ts.close() + _ = ts.call(context.Background(), "find", map[string]any{"space": "space1", "query": "Quarterly"}) + + // when — no block: the op goes out id-less and the SERVER locates + // the block from find, under its own lock (§8.43 — the wrapper's + // client-side locate and its read are gone) + out := ts.call(context.Background(), "edit_text", + map[string]any{"object": "1", "find": "Q3", "replace": "Q4"}) + + // then + assert.False(t, out.IsError, out.Text) + func() { + stub.mu.Lock() + defer stub.mu.Unlock() + require.Len(t, stub.patches, 1) + assert.Contains(t, string(stub.patches[0]), `"find":"Q3"`) + assert.NotContains(t, string(stub.patches[0]), `"id"`, + "every arm sends the same id-less op — resolution is the server's") + }() + + // and a block sent to the arm that does not publish one still + // works: the arms vary the published surface, not the server + out = ts.call(context.Background(), "edit_text", + map[string]any{"object": "1", "find": "Q3", "replace": "Q4", "block": "d4e5f"}) + assert.False(t, out.IsError, out.Text) + func() { + stub.mu.Lock() + defer stub.mu.Unlock() + require.Len(t, stub.patches, 2) + assert.Contains(t, string(stub.patches[1]), `"id":"d4e5f"`, + "an explicit block rides the op's id, on every arm") + }() + }) + } +} + +// The locator refusals are produced by the SERVER now (§8.43 — +// v2/service/locator.go, re-spelled into the tool register by the +// wrapper's opsVocab/restVocab), so this drives the real wrapper over +// stubbed server refusals whose bodies mirror the server's wire shape. +// The classifier phrases must match the text a model actually sees; the +// end-to-end wording pins live where each half is produced — +// v2/service/edit_test.go for the server texts, wrapper's +// tools_smallmodel_test.go for the translation. +func TestSnippetRefusalsAreCountedFromTheProductsOwnText(t *testing.T) { + // given — the server's two locator refusals, as locator.go serves them + // for a document where "Q3" is in two blocks and "Q9" in none + const ambiguousBody = `{"status":400,"code":"ambiguous_input",` + + `"message":"\"Q3\" appears in 2 blocks — retry with id naming one of:\n block a1b2c (paragraph): \"Revenue target for Q3 is 1.2M.\"\n block d4e5f (paragraph): \"Q3 review is due.\"",` + + `"issues":[{"path":"ops[0].find","message":"the find text appears in 2 blocks — a locator must identify exactly one","hint":"add surrounding text to find until it appears in one block only, or give the block id"}]}` + const noMatchBody = `{"status":404,"code":"not_found",` + + `"message":"no block contains \"Q9\" — copy the find text exactly, including inline markup (text is markdown source: ** [ ] etc. count)",` + + `"issues":[{"path":"ops[0].find","message":"the find text must appear in exactly one block for the locator to resolve","hint":"GET the object with ?outline=true to list them, then copy the text exactly as a read serves it — or give the block id"}]}` + stub := &stubAPI{doc: servedDoc, patchQueue: []stubResponse{ + {status: http.StatusBadRequest, body: ambiguousBody}, + {status: http.StatusNotFound, body: noMatchBody}, + }} + client := wrapper.NewClient("http://stub", "key") + client.HTTP = &http.Client{Transport: &stubTransport{handler: stub}} + ts, err := newMCPToolset(context.Background(), wrapper.NewRunner(client, wrapper.NewMemoryStore()), + wrapper.TierSmall, editTextNoBlock) + require.NoError(t, err) + defer ts.close() + _ = ts.call(context.Background(), "find", map[string]any{"space": "space1", "query": "Quarterly"}) + + // when + ambiguous := ts.call(context.Background(), "edit_text", + map[string]any{"object": "1", "find": "Q3", "replace": "Q4"}) + missing := ts.call(context.Background(), "edit_text", + map[string]any{"object": "1", "find": "Q9", "replace": "Q4"}) + + // then + require.True(t, ambiguous.IsError) + require.True(t, missing.IsError) + got := analyze([]callRecord{ + {Turn: 0, Tool: "edit_text", Args: json.RawMessage(`{"object":"1","find":"Q3","replace":"Q4"}`), + ResultText: ambiguous.Text, IsError: true}, + {Turn: 1, Tool: "edit_text", Args: json.RawMessage(`{"object":"1","find":"Q9","replace":"Q4"}`), + ResultText: missing.Text, IsError: true}, + }) + assert.Equal(t, 1, got.SnippetAmbiguous, "refusal was: %s", ambiguous.Text) + assert.Equal(t, 1, got.SnippetNoMatch, "refusal was: %s", missing.Text) + assert.Equal(t, 2, got.EditTextCalls) + assert.Equal(t, 0, got.EditTextWithBlock) +} + +func TestAgentLoopRecordsCallsErrorsAndTokens(t *testing.T) { + // given + stub := &stubAPI{doc: servedDoc, refuseFirstPatch: true} + api := httptest.NewServer(stub) + defer api.Close() + model, script := newScriptedModel( + toolCallTurn("find", `{"space":"space1","query":"Quarterly plan"}`), + toolCallTurn("edit_text", `{"object":"1","find":"Q3","replace":"Q4","block":"zz"}`), + toolCallTurn("edit_text", `{"object":"1","find":"Q3","replace":"Q4","block":"d4e5f"}`), + `{"role":"assistant","content":"Changed Q3 to Q4."}`, + ) + defer model.Close() + + rec := &recorder{} + client := wrapper.NewClient(api.URL, "key") + client.HTTP = &http.Client{Transport: &recordingTransport{base: http.DefaultTransport, rec: rec}} + client.Backoff = func(int) time.Duration { return 0 } + runner := wrapper.NewRunner(client, wrapper.NewMemoryStore()) + ts, err := newMCPToolset(context.Background(), runner, wrapper.TierSmall, editTextAsShipped) + require.NoError(t, err) + defer ts.close() + + // when + tr, err := runAgent(context.Background(), agentConfig{ + chat: newChatClient(model.URL, "", 30*time.Second), + model: "stub", + maxTurns: 6, + rec: rec, + }, ts, ts.instructions(), "change Q3 to Q4") + + // then + require.NoError(t, err) + assert.Equal(t, "model_done", tr.StoppedBy) + assert.Equal(t, "Changed Q3 to Q4.", tr.FinalContent) + require.Len(t, tr.Calls, 3) + assert.Equal(t, 40, tr.PromptTokens) + assert.Equal(t, 20, tr.CompletionTokens) + + refused := tr.Calls[1] + assert.True(t, refused.IsError) + require.NotEmpty(t, refused.Exchanges, "the refusal must carry its structured facts") + last := refused.Exchanges[len(refused.Exchanges)-1] + assert.Equal(t, 400, last.Status) + assert.Equal(t, "invalid_input", last.Code) + require.Len(t, last.Issues, 1) + assert.Equal(t, "ops[0].id", last.Issues[0].Path) + assert.Contains(t, refused.ResultText, `block "zz" not found`, + "the model sees the server's own text, translated to the tool vocabulary") + assert.NotContains(t, refused.ResultText, "ops[0]", "the op vocabulary must not leak to the model") + + // the tool definitions the model was handed came from the product + require.NotEmpty(t, script.seen) + tools, _ := json.Marshal(script.seen[0]["tools"]) + assert.Contains(t, string(tools), `"edit_text"`) + + sig := analyze(tr.Calls) + require.Len(t, sig.Repairs, 1) + assert.Equal(t, repairFixedNamed, sig.Repairs[0].Class) + assert.Equal(t, "block", sig.Repairs[0].NamedField) + assert.Equal(t, 400, sig.Repairs[0].Status) +} + +func TestOpsArmServesThePublishedOpSchemasVerbatim(t *testing.T) { + // given + api := newStubAPI(servedDoc) + defer api.Close() + client := newAPIClient(api.URL, "key", &recordingTransport{base: http.DefaultTransport, rec: &recorder{}}) + + // when + ts, err := newOpsToolset(context.Background(), client, "space1", "obj1") + + // then + require.NoError(t, err) + require.Len(t, ts.tools(), len(opsArmOps)+1) + assert.Equal(t, "read_object", ts.tools()[0].Name) + for i, op := range opsArmOps { + spec := ts.tools()[i+1] + assert.Equal(t, op, spec.Name) + assert.JSONEq(t, fmt.Sprintf(`{"type":"object","x-op":%q}`, op), string(spec.Parameters), + "the arm must pass the served schema through unchanged") + } + + out := ts.call(context.Background(), "read_object", map[string]any{}) + assert.False(t, out.IsError) + assert.Contains(t, out.Text, `"a1b2c"`) +} + +func TestOpsArmSendsThePayloadAsOneOpAndSurfacesTheRefusal(t *testing.T) { + // given + stub := &stubAPI{doc: servedDoc, refuseFirstPatch: true} + api := httptest.NewServer(stub) + defer api.Close() + client := newAPIClient(api.URL, "key", &recordingTransport{base: http.DefaultTransport, rec: &recorder{}}) + ts, err := newOpsToolset(context.Background(), client, "space1", "obj1") + require.NoError(t, err) + + // when + refused := ts.call(context.Background(), "insert_blocks", map[string]any{"markdown": "## Risks"}) + ok := ts.call(context.Background(), "insert_blocks", map[string]any{"markdown": "## Risks"}) + + // then + assert.True(t, refused.IsError) + assert.Contains(t, refused.Text, "400 invalid_input") + assert.Contains(t, refused.Text, "ops[0].id:", "the raw arm shows the path-addressed issue as served") + assert.False(t, ok.IsError) + + stub.mu.Lock() + defer stub.mu.Unlock() + require.Len(t, stub.patches, 2) + assert.JSONEq(t, `{"op":"insert_blocks","markdown":"## Risks"}`, string(stub.patches[0]), + "the op name comes from the tool name when the model omits the const") +} + +func TestAgentLoopStopsAtTheTurnBudget(t *testing.T) { + // given + model, _ := newScriptedModel( + toolCallTurn("read_object", `{}`), + toolCallTurn("read_object", `{}`), + toolCallTurn("read_object", `{}`), + ) + defer model.Close() + api := newStubAPI(servedDoc) + defer api.Close() + client := newAPIClient(api.URL, "key", &recordingTransport{base: http.DefaultTransport, rec: &recorder{}}) + ts, err := newOpsToolset(context.Background(), client, "space1", "obj1") + require.NoError(t, err) + + // when + tr, err := runAgent(context.Background(), agentConfig{ + chat: newChatClient(model.URL, "", 30*time.Second), model: "stub", maxTurns: 2, + }, ts, "sys", "user") + + // then + require.NoError(t, err) + assert.Equal(t, "turn_budget", tr.StoppedBy) + assert.Len(t, tr.Turns, 2) +} + +func TestModelFailureIsAnEnvironmentFailureNotATaskFailure(t *testing.T) { + // given + dead := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + })) + defer dead.Close() + api := newStubAPI(servedDoc) + defer api.Close() + client := newAPIClient(api.URL, "key", &recordingTransport{base: http.DefaultTransport, rec: &recorder{}}) + ts, err := newOpsToolset(context.Background(), client, "space1", "obj1") + require.NoError(t, err) + + // when + _, err = runAgent(context.Background(), agentConfig{ + chat: newChatClient(dead.URL, "", 5*time.Second), model: "stub", maxTurns: 2, + }, ts, "sys", "user") + + // then + require.Error(t, err) + assert.ErrorIs(t, err, errEnvironment) +} + +func TestToolArgumentsDecodeFromBothWireShapes(t *testing.T) { + tests := []struct { + name string + raw string + want map[string]any + }{ + {"json-encoded string (OpenAI, Ollama)", `"{\"object\":\"1\"}"`, map[string]any{"object": "1"}}, + {"inline object", `{"object":"1"}`, map[string]any{"object": "1"}}, + {"empty string", `""`, map[string]any{}}, + {"absent", ``, map[string]any{}}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // given + var c toolCall + c.Function.Arguments = json.RawMessage(tt.raw) + + // when + got, err := c.argsMap() + + // then + require.NoError(t, err) + assert.Equal(t, tt.want, got) + }) + } +} + +func TestOpsArmSetsTheDiscriminatorFromTheToolName(t *testing.T) { + // given — a model that writes a positional word into the const field. + // The arm splits the ops into separate tools, which a raw HTTP caller + // does not have, so the const must not decide the outcome here. + stub := &stubAPI{doc: servedDoc} + api := httptest.NewServer(stub) + defer api.Close() + client := newAPIClient(api.URL, "key", &recordingTransport{base: http.DefaultTransport, rec: &recorder{}}) + ts, err := newOpsToolset(context.Background(), client, "space1", "obj1") + require.NoError(t, err) + + // when + out := ts.call(context.Background(), "insert_blocks", map[string]any{"op": "last", "markdown": "## Risks"}) + + // then + assert.False(t, out.IsError) + stub.mu.Lock() + defer stub.mu.Unlock() + require.Len(t, stub.patches, 1) + assert.JSONEq(t, `{"op":"insert_blocks","markdown":"## Risks"}`, string(stub.patches[0])) +} + +func TestWaitSearchableReturnsWhenTheIndexCatchesUp(t *testing.T) { + // given — the fixture is invisible to search on the first poll and + // visible on the second, which is what an asynchronous full-text index + // looks like from the outside + var polls int + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + polls++ + if polls == 1 { + fmt.Fprint(w, `{"data":[],"total":0}`) + return + } + fmt.Fprint(w, `{"data":[{"id":"obj1"}],"total":1}`) + })) + defer srv.Close() + client := newAPIClient(srv.URL, "key", &recordingTransport{base: http.DefaultTransport, rec: &recorder{}}) + + // when + ok, took, err := client.waitSearchable(context.Background(), "space1", "Quarterly plan ab12", "obj1", 5*time.Second) + + // then + require.NoError(t, err) + assert.True(t, ok) + assert.Equal(t, 2, polls) + assert.Greater(t, took, time.Duration(0)) +} + +func TestWaitSearchableGivesUpWithoutClaimingSuccess(t *testing.T) { + // given + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + fmt.Fprint(w, `{"data":[],"total":0}`) + })) + defer srv.Close() + client := newAPIClient(srv.URL, "key", &recordingTransport{base: http.DefaultTransport, rec: &recorder{}}) + + // when + ok, _, err := client.waitSearchable(context.Background(), "space1", "missing", "obj1", 100*time.Millisecond) + + // then + require.NoError(t, err) + assert.False(t, ok, "a timeout is an environment fact, never a silent pass") +} + +// stubTransport serves the stub API in-process, with no listening socket: +// http.RoundTripper straight into the handler. The live smoke tests use it +// because a test binary that binds a local port can be treated differently +// by a sandbox's network policy than one that does not, and a smoke test +// that fails for that reason tells you nothing about the model. +type stubTransport struct{ handler http.Handler } + +func (t *stubTransport) RoundTrip(req *http.Request) (*http.Response, error) { + w := httptest.NewRecorder() + t.handler.ServeHTTP(w, req) + resp := w.Result() + resp.Request = req + return resp, nil +} diff --git a/cmd/apiv2eval/livemodel_test.go b/cmd/apiv2eval/livemodel_test.go new file mode 100644 index 0000000000..740ce3c4be --- /dev/null +++ b/cmd/apiv2eval/livemodel_test.go @@ -0,0 +1,95 @@ +package main + +import ( + "context" + "net/http" + "os" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/anyproto/anytype-heart/core/api/wrapper" +) + +// A real model against a stub API. This proves the one thing the scripted +// tests cannot — that a ~2B model can read the tier's published schemas and +// drive the loop at all — without needing the app running, and it is how the +// harness gets smoke-tested before an hours-long run. It is NOT a result: +// the API is a stub, so nothing here says whether an edit lands. +// +// APIV2EVAL_LIVE_MODEL=gemma4:e2b OLLAMA_BASE_URL=http://host:11434/v1 \ +// go test ./cmd/apiv2eval -run TestLiveModelDrivesTheTierSmallToolSet -v +func TestLiveModelDrivesTheTierSmallToolSet(t *testing.T) { + model := os.Getenv("APIV2EVAL_LIVE_MODEL") + if model == "" { + t.Skip("set APIV2EVAL_LIVE_MODEL (and OLLAMA_BASE_URL) to run the live smoke test") + } + baseURL := os.Getenv("OLLAMA_BASE_URL") + if baseURL == "" { + baseURL = "http://127.0.0.1:11434/v1" + } + + // given — the stub API is served in-process, without a socket + rec := &recorder{} + client := wrapper.NewClient("http://stub", "key") + client.HTTP = &http.Client{Transport: &recordingTransport{base: &stubTransport{handler: &stubAPI{doc: servedDoc}}, rec: rec}} + runner := wrapper.NewRunner(client, wrapper.NewMemoryStore()) + ts, err := newMCPToolset(context.Background(), runner, wrapper.TierSmall, editTextAsShipped) + require.NoError(t, err) + defer ts.close() + + // when + tr, err := runAgent(context.Background(), agentConfig{ + chat: newChatClient(baseURL, "ollama", 5*time.Minute), + model: model, + maxTurns: 6, + rec: rec, + }, ts, ts.instructions()+"\n\nWork in the Anytype space space1. When the work is done, reply with one short sentence and no tool call.", + `In the note titled "Quarterly plan ab12", change Q3 to Q4. Change nothing else.`) + + // then + require.NoError(t, err) + t.Logf("stopped by %s after %d turns, %d prompt + %d completion tokens\n%s", + tr.StoppedBy, len(tr.Turns), tr.PromptTokens, tr.CompletionTokens, summarizeCalls(tr.Calls)) + assert.NotEmpty(t, tr.Calls, "the model made no tool call at all — the tier's schemas did not reach it") + + sig := analyze(tr.Calls) + t.Logf("refs: %+v", sig.Refs) + t.Logf("repairs: %+v", sig.Repairs) +} + +// The same smoke test for the ops arm, whose schemas are an order of +// magnitude larger than the wrapper's — the size at which a small model +// stops answering is worth knowing before, not during, a long run. +func TestLiveModelDrivesTheOpsArm(t *testing.T) { + model := os.Getenv("APIV2EVAL_LIVE_MODEL") + if model == "" { + t.Skip("set APIV2EVAL_LIVE_MODEL (and OLLAMA_BASE_URL) to run the live smoke test") + } + baseURL := os.Getenv("OLLAMA_BASE_URL") + if baseURL == "" { + baseURL = "http://127.0.0.1:11434/v1" + } + + // given — the ops arm needs the published op schemas, which only the + // server serves; against the stub they are placeholders, so this checks + // the loop and the tool count, not the schemas themselves + client := newAPIClient("http://stub", "key", + &recordingTransport{base: &stubTransport{handler: &stubAPI{doc: servedDoc}}, rec: &recorder{}}) + ts, err := newOpsToolset(context.Background(), client, "space1", "obj1") + require.NoError(t, err) + + // when + tr, err := runAgent(context.Background(), agentConfig{ + chat: newChatClient(baseURL, "ollama", 5*time.Minute), + model: model, + maxTurns: 4, + }, ts, ts.instructions(), "Change Q3 to Q4 in this document. Read it first.") + + // then + require.NoError(t, err) + t.Logf("stopped by %s after %d turns\n%s", tr.StoppedBy, len(tr.Turns), summarizeCalls(tr.Calls)) + assert.NotEmpty(t, tr.Calls) +} diff --git a/cmd/apiv2eval/main.go b/cmd/apiv2eval/main.go new file mode 100644 index 0000000000..bab8519e98 --- /dev/null +++ b/cmd/apiv2eval/main.go @@ -0,0 +1,796 @@ +// Command apiv2eval is a development harness, not a shipped tool: it runs +// small local models through real read→edit loops against a real local +// Anytype API and records what happened. It is the agent-loop runner +// core/api/eval defers ("the agent-loop runner that drives models against a +// scratch space … is intentionally absent here"); that package keeps the +// Phase-0 scoring primitives, and the task ids here follow its names where +// the tasks correspond. Token counts come from the model host's own usage +// numbers rather than eval.CountTokens' 4-bytes-per-token approximation. +// +// The question it exists to answer is the one no review round can: does a +// small model complete the loop, or walk into a 400 it cannot get out of. +// Every cell of (model × surface × task) runs more than once — small models +// are high-variance and one sample is not a rate — and every attempt is +// judged by asking the API what the document says afterwards, never by the +// model's own account of what it did. +// +// apiv2eval -n 3 # the whole matrix +// apiv2eval -models gemma4:e2b -arms ops # one cell +// apiv2eval -list # print the matrix and exit +// +// The ab/… arms are the edit_text A/B (toolset.go): three surfaces over the +// same tasks that differ ONLY in the edit_text definition the model is +// shown. They are not in the default arm list — mixing them into the matrix +// would average three surfaces into one headline rate — so the experiment is +// its own run: +// +// apiv2eval -arms ab/a-shipped,ab/b1-noblock,ab/b2-prose -n 5 +// +// Configuration comes from the repo's .env: ANYTYPE_API_URL, ANYTYPE_API_KEY +// and OLLAMA_BASE_URL (the OpenAI-compatible model endpoint). The API must +// be running; the harness refuses to start otherwise, naming which of the +// two failures it hit — server down, or key rejected. +package main + +import ( + "bufio" + "context" + "crypto/rand" + "encoding/hex" + "encoding/json" + "errors" + "flag" + "fmt" + "io" + "net/http" + "net/url" + "os" + "os/signal" + "path/filepath" + "strings" + "syscall" + "time" + + "github.com/anyproto/anytype-heart/core/api/wrapper" +) + +func main() { + if err := run(); err != nil { + fmt.Fprintln(os.Stderr, "error:", err) + os.Exit(1) + } +} + +// options are the harness flags. +type options struct { + envFile string + models string + arms string + taskFilter string + n int + maxTurns int + temperature float64 + modelTimout time.Duration + spaceId string + spaceName string + outDir string + list bool + probe bool + // exampleShape selects which example the probe pairs with each schema + // (probe.go): "published" — byte-for-byte as GET /v2/schemas/ops/{op} + // serves it — or "op", that example unwrapped to the single op inside. + exampleShape string + // constAsEnum is the probe's one diagnostic deviation from a served + // schema — see rewriteConstAsEnum. + constAsEnum bool +} + +func run() error { + var opt options + flag.StringVar(&opt.envFile, "env", ".env", "file holding ANYTYPE_API_URL / ANYTYPE_API_KEY / OLLAMA_BASE_URL") + flag.StringVar(&opt.models, "models", "gemma4:e2b,gemma4:e4b", "comma-separated model ids to evaluate") + flag.StringVar(&opt.arms, "arms", strings.Join(defaultArms, ","), + "comma-separated surfaces: "+strings.Join(allArms, ", ")) + flag.StringVar(&opt.taskFilter, "tasks", "", "comma-separated task ids (default: all)") + flag.IntVar(&opt.n, "n", 3, "attempts per cell — report this number, never present one sample as a rate") + flag.IntVar(&opt.maxTurns, "max-turns", 8, "turn budget per attempt") + flag.Float64Var(&opt.temperature, "temperature", 0, "sampling temperature") + flag.DurationVar(&opt.modelTimout, "model-timeout", 5*time.Minute, "per-completion timeout") + flag.StringVar(&opt.spaceId, "space", "", "space to work in (default: reuse or create the eval space)") + flag.StringVar(&opt.spaceName, "space-name", "APIv2 eval", "name of the eval space when one must be created") + flag.StringVar(&opt.outDir, "out", "eval-out", "output directory for attempts.jsonl and summary.txt") + flag.BoolVar(&opt.list, "list", false, "print the run matrix and exit") + flag.BoolVar(&opt.probe, "probe", false, "run the one-turn schema-emission probe instead of the loop (needs no live API)") + flag.StringVar(&opt.exampleShape, "probe-example", exampleAsPublished, + "probe only: which example to pair with each op schema — published (a whole PATCH body, as served) or op (unwrapped to one op)") + flag.BoolVar(&opt.constAsEnum, "probe-const-as-enum", false, + "probe only, diagnostic: spell the op discriminator as a single-value enum instead of const (the default is the schema as served)") + flag.Parse() + + env, err := loadEnv(opt.envFile) + if err != nil { + return err + } + // The process environment wins over the env FILE, not the other way round: + // the file is the default, an explicit `VAR=… apiv2eval …` is the override. + // (Reversed, a stale host in .env silently beat the command line — which is + // how a run went to a LAN address after the host moved to Tailscale.) + apiURL := firstNonEmpty(os.Getenv("ANYTYPE_API_URL"), env["ANYTYPE_API_URL"], wrapper.DefaultBaseURL) + apiKey := firstNonEmpty(os.Getenv("ANYTYPE_API_KEY"), env["ANYTYPE_API_KEY"]) + modelURL := firstNonEmpty(os.Getenv("OLLAMA_BASE_URL"), env["OLLAMA_BASE_URL"], "http://127.0.0.1:11434/v1") + modelKey := firstNonEmpty(os.Getenv("OPENAI_API_KEY"), env["OPENAI_API_KEY"], "ollama") + + arms, err := parseArms(opt.arms) + if err != nil { + return err + } + selected, err := selectTasks(opt.taskFilter) + if err != nil { + return err + } + models := splitList(opt.models) + if len(models) == 0 { + return fmt.Errorf("no models selected") + } + if err := checkTaskGating(); err != nil { + return err + } + cells, skipped, err := planCells(models, arms, selected) + if err != nil { + return err + } + + if opt.list { + printMatrix(os.Stdout, models, arms, selected, cells, skipped, opt.n) + return nil + } + + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + + if opt.probe { + if opt.exampleShape != exampleAsPublished && opt.exampleShape != exampleAtOpLevel { + return fmt.Errorf("unknown -probe-example %q — shapes: %s, %s", opt.exampleShape, exampleAsPublished, exampleAtOpLevel) + } + // the probe asks only what the model WRITES given a published schema, + // so it skips the API preflight entirely + chat := newChatClient(modelURL, modelKey, opt.modelTimout) + if err := checkModels(ctx, chat, modelURL, models); err != nil { + return err + } + return runProbe(ctx, chat, models, opt) + } + + rec := &recorder{} + transport := &recordingTransport{base: http.DefaultTransport, rec: rec} + api := newAPIClient(apiURL, apiKey, transport) + chat := newChatClient(modelURL, modelKey, opt.modelTimout) + + // preflight — both halves fail fast, and they fail differently + if apiKey == "" { + return fmt.Errorf("no ANYTYPE_API_KEY in %s or the environment — the API refuses every call without one", opt.envFile) + } + if err := api.whoami(ctx); err != nil { + var ae *apiError + if errors.As(err, &ae) && ae.Status == http.StatusUnauthorized { + return fmt.Errorf("the Anytype API at %s rejected the key — create a fresh one in the app (Settings → API keys) and update %s: %w", apiURL, opt.envFile, err) + } + return fmt.Errorf("the Anytype API at %s is not answering — start the app (or the build under test) and retry; nothing was run: %w", apiURL, err) + } + if err := checkModels(ctx, chat, modelURL, models); err != nil { + return err + } + spaceId, err := resolveSpace(ctx, api, opt) + if err != nil { + return err + } + rec.take() // preflight exchanges belong to no attempt + + if err := os.MkdirAll(opt.outDir, 0o755); err != nil { + return fmt.Errorf("create output dir: %w", err) + } + jsonlPath := filepath.Join(opt.outDir, "attempts.jsonl") + file, err := os.OpenFile(jsonlPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644) + if err != nil { + return fmt.Errorf("open attempts file: %w", err) + } + defer file.Close() + writer := bufio.NewWriter(file) + defer writer.Flush() + + runId := time.Now().UTC().Format("20060102-150405") + fmt.Printf("run %s — space %s, %d models × %d arms × %d tasks × n=%d = %d attempts\n", + runId, spaceId, len(models), len(arms), len(selected), opt.n, len(cells)*opt.n) + printSkipped(os.Stdout, skipped) + + var attempts []attemptRecord + for _, model := range models { + // one model at a time: a shared host reloads weights on every switch + for seq := 1; seq <= opt.n; seq++ { + for _, arm := range arms { + for _, t := range selected { + if !cells[cellKey{model, arm.name, t.Id}] { + continue + } + if ctx.Err() != nil { + fmt.Println("interrupted — writing what ran") + return finish(writer, attempts, skipped, opt, runId) + } + rec.take() + att := runAttempt(ctx, attemptDeps{ + api: api, chat: chat, rec: rec, opt: opt, + runId: runId, spaceId: spaceId, + }, model, arm, t, seq) + attempts = append(attempts, att) + line, err := json.Marshal(att) + if err != nil { + return fmt.Errorf("encode attempt record: %w", err) + } + if _, err := writer.Write(append(line, '\n')); err != nil { + return fmt.Errorf("write attempt record: %w", err) + } + writer.Flush() + fmt.Printf(" %-12s %-15s %-15s #%d → %-11s %2d turns %6d tok %s\n", + model, arm.name, t.Id, seq, att.Outcome, att.Turns, + att.PromptTokens+att.CompletionTokens, firstLine(att.CheckDetail)) + } + } + } + } + return finish(writer, attempts, skipped, opt, runId) +} + +func finish(writer *bufio.Writer, attempts []attemptRecord, skipped []skippedCell, opt options, runId string) error { + if err := writer.Flush(); err != nil { + return fmt.Errorf("flush attempts file: %w", err) + } + summary := buildSummary(runId, attempts, skipped, opt) + path := filepath.Join(opt.outDir, "summary.txt") + if err := os.WriteFile(path, []byte(summary), 0o644); err != nil { + return fmt.Errorf("write summary: %w", err) + } + fmt.Println() + fmt.Print(summary) + fmt.Printf("\nrecords: %s\n", filepath.Join(opt.outDir, "attempts.jsonl")) + return nil +} + +// +// ---- one attempt ---- +// + +const ( + surfaceWrapper = "wrapper" + surfaceOps = "ops" +) + +// fixtureIndexTimeout bounds the wait for a fresh fixture to become +// searchable; spaceReadyTimeout bounds the wait for a freshly created eval +// space to become readable. +const ( + fixtureIndexTimeout = 30 * time.Second + spaceReadyTimeout = 60 * time.Second +) + +// arm names. The ab/… three are the edit_text A/B: the same small tier, the +// same runner, three different published edit_text definitions. +const ( + armWrapperSmall = "wrapper/small" + armWrapperLarge = "wrapper/large" + armOps = "ops" + armEditTextA = "ab/a-shipped" + armEditTextB1 = "ab/b1-noblock" + armEditTextB2 = "ab/b2-prose" +) + +// defaultArms is the matrix; allArms is everything -arms accepts. +var ( + defaultArms = []string{armWrapperSmall, armWrapperLarge, armOps} + allArms = []string{armWrapperSmall, armWrapperLarge, armOps, armEditTextA, armEditTextB1, armEditTextB2} +) + +// armSpec is one surface under test. +type armSpec struct { + name string + surface string + tier wrapper.Tier + // variant is the published-surface variation this arm serves — the only + // thing that differs between the three ab/… arms (toolset.go). + variant editTextVariant +} + +// publishedTools returns the tool names this arm serves the model, read from +// the same table the arm publishes from: the tier filter for the wrapper, +// the op list for the ops arm. The capability gate asks THIS rather than a +// hand-kept note of which task runs where, so removing a tool from a tier +// updates the gate with it. +func (a armSpec) publishedTools() []string { + switch a.surface { + case surfaceWrapper: + return wrapper.ToolNamesForTier(a.tier) + case surfaceOps: + return append([]string{"read_object"}, opsArmOps...) + default: + return nil + } +} + +// skippedCell is one (model, arm, task) the run did not measure, with the +// reason. Skips are reported, never silent: a cell that quietly disappears +// reads as "not applicable" when it may mean "we stopped measuring this". +type skippedCell struct { + Model string + Arm string + Task string + Reason string +} + +// planCells derives which cells a run measures. A cell is skipped when the +// arm's published tool set has no tool for a capability the task requires — +// asking a model to fill a table cell with no set_cell in its tier is not a +// measurement of anything, and scoring the answer as a failure moves the +// headline rate by the number of such cells. +func planCells(models []string, arms []armSpec, selected []task) (map[cellKey]bool, []skippedCell, error) { + cells := map[cellKey]bool{} + var skipped []skippedCell + for _, model := range models { + for _, arm := range arms { + published := map[string]bool{} + for _, name := range arm.publishedTools() { + published[name] = true + } + for _, t := range selected { + key := cellKey{model, arm.name, t.Id} + if !t.runsOnArm(arm.surface) { + skipped = append(skipped, skippedCell{model, arm.name, t.Id, + fmt.Sprintf("the task does not run on the %s surface", arm.surface)}) + continue + } + missing := "" + for _, c := range t.Requires { + tool, err := capabilityTool(c, arm.surface) + if err != nil { + return nil, nil, fmt.Errorf("gate %s on %s: %w", t.Id, arm.name, err) + } + if !published[tool] { + missing = fmt.Sprintf("%s publishes no %s — the task cannot %s without it", + arm.name, tool, c) + break + } + } + if missing != "" { + skipped = append(skipped, skippedCell{model, arm.name, t.Id, missing}) + continue + } + cells[key] = true + } + } + } + return cells, skipped, nil +} + +// printSkipped writes the skipped cells to a stream, or says there are none. +func printSkipped(w io.Writer, skipped []skippedCell) { + if len(skipped) == 0 { + fmt.Fprintln(w, "no cells skipped — every arm publishes a tool for every capability its tasks require") + return + } + fmt.Fprintf(w, "%d cells skipped:\n", len(skipped)) + for _, s := range skipped { + fmt.Fprintf(w, " %-14s %-14s %-16s — %s\n", s.Model, s.Arm, s.Task, s.Reason) + } +} + +// attemptRecord is one (model, arm, task, seq) run, as written to JSONL. +type attemptRecord struct { + Run string `json:"run"` + StartedAt time.Time `json:"started_at"` + DurationMs int64 `json:"duration_ms"` + Model string `json:"model"` + Arm string `json:"arm"` + Surface string `json:"surface"` + Tier string `json:"tier,omitempty"` + // Variant is the published-surface variation the arm served (the + // edit_text A/B) — empty for the arms that publish the shipped surface. + Variant string `json:"variant,omitempty"` + Task string `json:"task"` + Seq int `json:"seq"` + SpaceId string `json:"space_id"` + ObjectId string `json:"object_id,omitempty"` + Title string `json:"title,omitempty"` + System string `json:"system_prompt"` + Prompt string `json:"prompt"` + // Outcome is success | failure | environment. An environment outcome is + // the harness's or the host's fault and never enters a success rate. + Outcome string `json:"outcome"` + CheckDetail string `json:"check_detail,omitempty"` + EnvError string `json:"env_error,omitempty"` + Turns int `json:"turns"` + StoppedBy string `json:"stopped_by,omitempty"` + ToolCalls int `json:"tool_calls"` + FailedCalls int `json:"failed_calls"` + // FixtureIndexMs is how long the fixture took to become searchable. + FixtureIndexMs int64 `json:"fixture_index_ms,omitempty"` + // WrongTargetWrites counts mutations that landed on some OTHER object. + // Fixtures share a space and a title stem, so a find that returns + // several and a handle picked from the wrong row writes to a real note + // that is not the one under test — a failure whose cause is the + // reference channel, not the edit. + WrongTargetWrites int `json:"wrong_target_writes,omitempty"` + PromptTokens int `json:"prompt_tokens"` + CompletionTokens int `json:"completion_tokens"` + Signals signals `json:"signals"` + Transcript *transcript `json:"transcript,omitempty"` +} + +const ( + outcomeSuccess = "success" + outcomeFailure = "failure" + outcomeEnv = "environment" +) + +type attemptDeps struct { + api *apiClient + chat *chatClient + rec *recorder + opt options + runId string + + spaceId string +} + +func runAttempt(ctx context.Context, deps attemptDeps, model string, arm armSpec, t task, seq int) attemptRecord { + started := time.Now() + att := attemptRecord{ + Run: deps.runId, StartedAt: started, Model: model, Arm: arm.name, + Surface: arm.surface, Tier: string(arm.tier), Variant: string(arm.variant), + Task: t.Id, Seq: seq, SpaceId: deps.spaceId, + } + finishRecord := func() attemptRecord { + att.DurationMs = time.Since(started).Milliseconds() + return att + } + + fx, err := setupFixture(ctx, deps.api, deps.spaceId, t) + if err != nil { + att.Outcome, att.EnvError = outcomeEnv, err.Error() + return finishRecord() + } + att.ObjectId, att.Title = fx.ObjectId, fx.Title + + // the wrapper arm reaches the object through find, which searches; the + // index is asynchronous, so an attempt started too early fails for a + // reason that is neither the model's nor the API's + if arm.surface == surfaceWrapper { + ok, took, err := deps.api.waitSearchable(ctx, deps.spaceId, fx.Title, fx.ObjectId, fixtureIndexTimeout) + att.FixtureIndexMs = took.Milliseconds() + switch { + case err != nil: + att.Outcome, att.EnvError = outcomeEnv, fmt.Errorf("wait for the fixture to be searchable: %w", err).Error() + return finishRecord() + case !ok: + att.Outcome = outcomeEnv + att.EnvError = fmt.Sprintf("the fixture %q was still not searchable after %s — the full-text index had not caught up", fx.Title, fixtureIndexTimeout) + return finishRecord() + } + } + + ts, err := buildToolset(ctx, deps, arm, fx) + if err != nil { + att.Outcome, att.EnvError = outcomeEnv, err.Error() + return finishRecord() + } + defer ts.close() + + att.System = ts.instructions() + "\n\n" + armPreamble(arm, deps.spaceId) + att.Prompt = t.Prompt(fx) + + tr, err := runAgent(ctx, agentConfig{ + chat: deps.chat, model: model, temperature: deps.opt.temperature, + maxTurns: deps.opt.maxTurns, rec: deps.rec, + }, ts, att.System, att.Prompt) + if tr != nil { + att.Transcript = tr + att.Turns = len(tr.Turns) + att.StoppedBy = tr.StoppedBy + att.ToolCalls = len(tr.Calls) + for _, c := range tr.Calls { + if c.IsError { + att.FailedCalls++ + } + } + att.PromptTokens, att.CompletionTokens = tr.PromptTokens, tr.CompletionTokens + att.Signals = analyze(tr.Calls) + att.Signals.SpaceIdAsObject = countSpaceIdAsObject(tr.Calls, deps.spaceId) + att.WrongTargetWrites = countWrongTargetWrites(tr.Calls, fx.ObjectId) + } + if err != nil { + // a model timeout or a host that went away is not a task failure + att.Outcome, att.EnvError = outcomeEnv, err.Error() + return finishRecord() + } + + doc, _, err := deps.api.getDocument(ctx, deps.spaceId, fx.ObjectId) + if err != nil { + att.Outcome, att.EnvError = outcomeEnv, fmt.Errorf("check read: %w", err).Error() + return finishRecord() + } + verdict := t.Check(doc, fx) + if verdict.OK { + att.Outcome = outcomeSuccess + } else { + att.Outcome = outcomeFailure + att.CheckDetail = verdict.Detail + } + return finishRecord() +} + +// countWrongTargetWrites counts successful mutations addressed at an object +// other than the fixture. +func countWrongTargetWrites(calls []callRecord, objectId string) int { + n := 0 + for _, c := range calls { + for _, ex := range c.Exchanges { + if ex.Method == http.MethodGet || ex.Status < 200 || ex.Status > 299 { + continue + } + if !strings.Contains(ex.Path, "/objects/") || strings.HasSuffix(ex.Path, "/objects") { + continue + } + if !strings.HasSuffix(ex.Path, "/objects/"+objectId) { + n++ + } + } + } + return n +} + +// buildToolset constructs the arm's surface for one attempt. Both are built +// fresh per attempt: the wrapper's session (handles, the idempotency reuse +// record) must not leak between attempts. +func buildToolset(ctx context.Context, deps attemptDeps, arm armSpec, fx *fixture) (toolset, error) { + switch arm.surface { + case surfaceWrapper: + client := wrapper.NewClient(deps.api.baseURL, deps.api.apiKey) + client.HTTP = &http.Client{Timeout: 60 * time.Second, Transport: deps.api.http.Transport} + runner := wrapper.NewRunner(client, wrapper.NewMemoryStore()) + ts, err := newMCPToolset(ctx, runner, arm.tier, arm.variant) + if err != nil { + return nil, fmt.Errorf("build wrapper toolset: %w", err) + } + return ts, nil + case surfaceOps: + ts, err := newOpsToolset(ctx, deps.api, deps.spaceId, fx.ObjectId) + if err != nil { + return nil, fmt.Errorf("build ops toolset: %w", err) + } + return ts, nil + default: + return nil, fmt.Errorf("unknown surface %q", arm.surface) + } +} + +// armPreamble is the small amount of context a host would supply: which +// space the work happens in (wrapper arm), or that the object is already +// selected (ops arm). Everything else the model is told comes from the +// product's own instructions. +// +// The space id names its ARGUMENT here. The first version said only "Work in +// the Anytype space ", and a model opened its attempt with +// read {"object": ""} — the one id in its context, handed to +// it in a position that named no argument, and `read`'s object takes "a full +// object id". That was the harness's doing, not the surface's: the wrapper +// refused it with "no working session … run find first" and the model +// recovered on the next turn. The sentence below is the product's own — it +// is what the `spaces` tool prints under its listing — so labelling the +// channel adds no steering the model would not have had if it had asked for +// the space itself. +func armPreamble(arm armSpec, spaceId string) string { + if arm.surface == surfaceOps { + return "The object named in the request is the one your tools already act on. " + + "When the work is done, reply with one short sentence and no tool call." + } + return "Work in the Anytype space " + spaceId + " — pass that id as space to find, describe and create. " + + "When the work is done, reply with one short sentence and no tool call." +} + +// +// ---- selection, preflight, small helpers ---- +// + +// checkModels fails fast when the model endpoint is down or does not serve +// a requested model — a missing model would otherwise fail every attempt +// one at a time, an hour into a run. +func checkModels(ctx context.Context, chat *chatClient, modelURL string, models []string) error { + served, err := chat.listModels(ctx) + if err != nil { + return fmt.Errorf("the model endpoint at %s is not answering; nothing was run: %w", modelURL, err) + } + for _, m := range models { + if !containsString(served, m) { + return fmt.Errorf("model %q is not served by %s — it has: %s", m, modelURL, strings.Join(served, ", ")) + } + } + return nil +} + +func parseArms(spec string) ([]armSpec, error) { + var out []armSpec + for _, name := range splitList(spec) { + small := armSpec{name: name, surface: surfaceWrapper, tier: wrapper.TierSmall} + switch name { + case armOps: + out = append(out, armSpec{name: name, surface: surfaceOps}) + case armWrapperSmall: + out = append(out, small) + case armWrapperLarge: + out = append(out, armSpec{name: name, surface: surfaceWrapper, tier: wrapper.TierLarge}) + case armEditTextA: + // byte-for-byte the shipped surface — the A/B's control, run + // beside its variants rather than borrowed from another run + small.variant = editTextAsShipped + out = append(out, small) + case armEditTextB1: + small.variant = editTextNoBlock + out = append(out, small) + case armEditTextB2: + small.variant = editTextProse + out = append(out, small) + default: + return nil, fmt.Errorf("unknown arm %q — arms: %s", name, strings.Join(allArms, ", ")) + } + } + if len(out) == 0 { + return nil, fmt.Errorf("no arms selected") + } + return out, nil +} + +func selectTasks(filter string) ([]task, error) { + all := tasks() + if filter == "" { + return all, nil + } + wanted := splitList(filter) + var out []task + for _, want := range wanted { + found := false + for _, t := range all { + if t.Id == want { + out = append(out, t) + found = true + } + } + if !found { + var ids []string + for _, t := range all { + ids = append(ids, t.Id) + } + return nil, fmt.Errorf("unknown task %q — tasks: %s", want, strings.Join(ids, ", ")) + } + } + return out, nil +} + +// resolveSpace picks the space fixtures are created in: the flag, else an +// existing space with the eval name, else a fresh one. A dedicated space +// keeps every run's fixtures out of the user's real notes — the API has no +// object delete, so fixtures accumulate and must accumulate somewhere +// harmless. +func resolveSpace(ctx context.Context, api *apiClient, opt options) (string, error) { + if opt.spaceId != "" { + return opt.spaceId, nil + } + spaces, err := api.listSpaces(ctx) + if err != nil { + return "", err + } + for _, s := range spaces { + if s.Name == opt.spaceName { + return s.Id, nil + } + } + id, err := api.createSpace(ctx, opt.spaceName) + if err != nil { + return "", fmt.Errorf("create the eval space (pass -space to use an existing one): %w", err) + } + // a just-created space is not necessarily loaded yet; without this every + // attempt of the run would fail at fixture creation and be recorded as an + // environment failure, which is true but useless + deadline := time.Now().Add(spaceReadyTimeout) + for { + if _, err := api.call(ctx, http.MethodGet, "/v2/spaces/"+url.PathEscape(id), nil, nil, nil); err == nil { + break + } + if time.Now().After(deadline) { + return "", fmt.Errorf("the eval space %s was created but not readable after %s", id, spaceReadyTimeout) + } + select { + case <-time.After(time.Second): + case <-ctx.Done(): + return "", ctx.Err() + } + } + fmt.Printf("created eval space %q (%s)\n", opt.spaceName, id) + return id, nil +} + +func printMatrix(w io.Writer, models []string, arms []armSpec, selected []task, cells map[cellKey]bool, skipped []skippedCell, n int) { + total := 0 + for _, m := range models { + for _, a := range arms { + for _, t := range selected { + if !cells[cellKey{m, a.name, t.Id}] { + continue + } + fmt.Fprintf(w, "%-14s %-14s %-16s ×%d\n", m, a.name, t.Id, n) + total += n + } + } + } + fmt.Fprintf(w, "\n%d attempts\n", total) + printSkipped(w, skipped) +} + +// loadEnv reads a KEY=VALUE file. Values are never printed: the file holds +// the API key and nothing in this harness may put it in an output. +func loadEnv(path string) (map[string]string, error) { + out := map[string]string{} + data, err := os.ReadFile(path) + if err != nil { + if os.IsNotExist(err) { + return out, nil + } + return nil, fmt.Errorf("read env file %s: %w", path, err) + } + for _, line := range strings.Split(string(data), "\n") { + line = strings.TrimSpace(line) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + key, value, ok := strings.Cut(line, "=") + if !ok { + continue + } + value = strings.TrimSpace(value) + value = strings.Trim(value, `"'`) + out[strings.TrimSpace(key)] = value + } + return out, nil +} + +func newNonce(n int) string { + b := make([]byte, n) + if _, err := rand.Read(b); err != nil { + return fmt.Sprintf("%d", time.Now().UnixNano()%1000000) + } + return hex.EncodeToString(b) +} + +func splitList(s string) []string { + var out []string + for _, part := range strings.Split(s, ",") { + if p := strings.TrimSpace(part); p != "" { + out = append(out, p) + } + } + return out +} + +func firstNonEmpty(values ...string) string { + for _, v := range values { + if v != "" { + return v + } + } + return "" +} + +func containsString(list []string, want string) bool { + for _, v := range list { + if v == want { + return true + } + } + return false +} diff --git a/cmd/apiv2eval/probe.go b/cmd/apiv2eval/probe.go new file mode 100644 index 0000000000..9f6c55a8d0 --- /dev/null +++ b/cmd/apiv2eval/probe.go @@ -0,0 +1,518 @@ +package main + +// probe.go — the schema-emission probe: one turn, no live API. +// +// H1 asks whether a model emits a field the schema does not show it. That +// question is answered by the FIRST payload a model writes, and the first +// payload depends on the tool schema alone — not on anything the server +// answers. So the probe hands the model the API's own published op schemas +// (read in-process from the same table the /v2/schemas/ops route serves), +// takes exactly one completion, and records what it wrote. +// +// It is a strictly weaker instrument than a run: it says nothing about +// recovery, about ids echoed from a read, or about whether an edit lands. +// It exists so the one question that does NOT need a live server can be +// answered when there is no live server — and, when there is, as a cheap +// large-n complement to the loop runs. + +import ( + "bufio" + "context" + "encoding/json" + "fmt" + "os" + "path/filepath" + "sort" + "strconv" + "strings" + "time" + + v2service "github.com/anyproto/anytype-heart/core/api/v2/service" +) + +// probeOps are the two ops the payload-id question is about: insert_blocks +// publishes no id slot since §8.30/§8.31, replace_subtree still does. Same +// model, same prompt shape, one schema with the field and one without. +var probeOps = []string{"insert_blocks", "replace_subtree"} + +// probeCase is one authoring intent. +type probeCase struct { + id string + prompt string + // wantOp is the op the intent calls for; a call to the other one is + // recorded, not corrected. + wantOp string +} + +func probeCases() []probeCase { + return []probeCase{ + { + id: "add_section", + wantOp: "insert_blocks", + prompt: "Add a section at the end of this document: a level-2 heading reading Risks, " + + "then two bullet points reading Vendor delay and Budget overrun.", + }, + { + id: "add_table", + wantOp: "insert_blocks", + prompt: "Add a table at the end of this document with two columns, a header row reading " + + "Component and Status, and one row reading Beta and Pending.", + }, + { + id: "add_after_block", + wantOp: "insert_blocks", + prompt: "The document has a paragraph with id b7. Add a checkbox item reading Follow up directly after it.", + }, + { + id: "replace_subtree", + wantOp: "replace_subtree", + prompt: "The document has a bulleted list item with id b7. Replace it and everything under it with " + + "a single paragraph reading Deferred to Q4.", + }, + // The matched pair: the SAME temptation put to both ops. A read is + // quoted, so an id is right there to copy, and the intent is + // phrased so carrying it over is a plausible reading. One op's + // schema publishes the slot, the other's does not — which is the + // whole §8.30 claim, reduced to two prompts that differ only in + // which tool answers them. + { + id: "copy_block_new", + wantOp: "insert_blocks", + prompt: "A read of this document returned this block:\n" + + `{"id":"c3f1a","type":"bulletedListItem","text":"Ship the beta"}` + "\n" + + "Add a second, identical bullet at the end of the document.", + }, + { + id: "echo_block_existing", + wantOp: "replace_subtree", + prompt: "A read of this document returned this block:\n" + + `{"id":"c3f1a","type":"bulletedListItem","text":"Ship the beta"}` + "\n" + + "Replace that block with a paragraph reading Shipped, keeping the block's identity.", + }, + } +} + +// probeRecord is one probe attempt. +type probeRecord struct { + Run string `json:"run"` + StartedAt time.Time `json:"started_at"` + Model string `json:"model"` + Case string `json:"case"` + Seq int `json:"seq"` + WantOp string `json:"want_op"` + // ExampleShape is which example the tool description carried, and + // ConstAsEnum whether the discriminator's const was spelled as a + // single-value enum — see probeToolSpecs. + ExampleShape string `json:"example_shape,omitempty"` + ConstAsEnum bool `json:"const_as_enum,omitempty"` + CalledOp string `json:"called_op,omitempty"` + Args string `json:"args,omitempty"` + // Channel is which authoring channel an insert_blocks call used: markdown + // (no id is expressible at all) or blocks (where the removed slot was). + Channel string `json:"channel,omitempty"` + IdEmissions []idEmission `json:"id_emissions,omitempty"` + // RefusalRisks name payload shapes the server refuses, recognised + // statically (see staticRefusalRisks) — the probe never sends anything. + RefusalRisks []string `json:"refusal_risks,omitempty"` + // MissingOpConst records that the payload omitted `op`, which every op + // schema marks required with a const; OpConstValue is what it wrote + // instead when it wrote something other than the const. The tool name + // already determines the value, so this measures schema compliance, not + // intent — and the wrong-value case is the common one, so it is counted + // apart from the absent one. + MissingOpConst bool `json:"missing_op_const,omitempty"` + OpConstValue string `json:"op_const_value,omitempty"` + NoToolCall bool `json:"no_tool_call,omitempty"` + ArgsError string `json:"args_error,omitempty"` + EnvError string `json:"env_error,omitempty"` + Usage usage `json:"usage"` +} + +// runProbe runs the one-turn schema-emission probe over the model list. +func runProbe(ctx context.Context, chat *chatClient, models []string, opt options) error { + specs, err := probeToolSpecs(opt.exampleShape, opt.constAsEnum) + if err != nil { + return err + } + tools := make([]toolDef, 0, len(specs)) + for _, spec := range specs { + tools = append(tools, newToolDef(spec.Name, spec.Description, spec.Parameters)) + } + system := "You edit one Anytype document through its HTTP API. Each tool is a single PATCH op; " + + "its parameters are the API's own published schema for that op — follow it exactly. " + + "Call exactly one tool." + + if err := os.MkdirAll(opt.outDir, 0o755); err != nil { + return fmt.Errorf("create output dir: %w", err) + } + file, err := os.OpenFile(filepath.Join(opt.outDir, "probe.jsonl"), os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644) + if err != nil { + return fmt.Errorf("open probe file: %w", err) + } + defer file.Close() + writer := bufio.NewWriter(file) + defer writer.Flush() + + runId := time.Now().UTC().Format("20060102-150405") + fmt.Printf("probe %s — %d models × %d cases × n=%d (one turn each, no API needed)\n", + runId, len(models), len(probeCases()), opt.n) + + var records []probeRecord + for _, model := range models { + for seq := 1; seq <= opt.n; seq++ { + for _, pc := range probeCases() { + if ctx.Err() != nil { + return writeProbeSummary(writer, records, runId, opt) + } + rec := probeOnce(ctx, chat, model, pc, tools, system, opt, runId, seq) + rec.ExampleShape = opt.exampleShape + rec.ConstAsEnum = opt.constAsEnum + records = append(records, rec) + line, err := json.Marshal(rec) + if err != nil { + return fmt.Errorf("encode probe record: %w", err) + } + if _, err := writer.Write(append(line, '\n')); err != nil { + return fmt.Errorf("write probe record: %w", err) + } + writer.Flush() + fmt.Printf(" %-12s %-16s #%d → %-15s %-9s ids=%d %s\n", + model, pc.id, seq, orDash(rec.CalledOp), orDash(rec.Channel), + len(rec.IdEmissions), firstLine(rec.EnvError)) + } + } + } + return writeProbeSummary(writer, records, runId, opt) +} + +func probeOnce(ctx context.Context, chat *chatClient, model string, pc probeCase, + tools []toolDef, system string, opt options, runId string, seq int) probeRecord { + rec := probeRecord{ + Run: runId, StartedAt: time.Now(), Model: model, Case: pc.id, Seq: seq, WantOp: pc.wantOp, + } + messages := []chatMessage{{Role: "system", Content: system}, {Role: "user", Content: pc.prompt}} + resp, err := chat.complete(ctx, model, messages, tools, opt.temperature) + if err != nil { + rec.EnvError = err.Error() + return rec + } + rec.Usage = resp.Usage + if len(resp.Message.ToolCalls) == 0 { + rec.NoToolCall = true + return rec + } + call := resp.Message.ToolCalls[0] + rec.CalledOp = call.Function.Name + raw, err := call.argsJSON() + if err != nil { + rec.ArgsError = err.Error() + return rec + } + rec.Args = string(raw) + var args map[string]any + if err := json.Unmarshal(raw, &args); err != nil { + rec.ArgsError = err.Error() + return rec + } + switch { + case args["markdown"] != nil: + rec.Channel = "markdown" + case args["blocks"] != nil: + rec.Channel = "blocks" + } + // the op's OWN id (replace_subtree's target) is not the field under + // question — only ids inside the authored payload are + payload := map[string]any{} + if blocks, ok := args["blocks"]; ok { + payload["blocks"] = blocks + } + rec.IdEmissions = collectIdPaths(payload, "") + for i := range rec.IdEmissions { + rec.IdEmissions[i].Tool = call.Function.Name + } + rec.RefusalRisks = staticRefusalRisks(call.Function.Name, args) + op, hasOp := args["op"] + rec.MissingOpConst = !hasOp + if hasOp { + if written, _ := json.Marshal(op); string(written) != strconv.Quote(call.Function.Name) { + rec.OpConstValue = string(written) + } + } + return rec +} + +// refusal risks recognised without sending anything. +const ( + riskPositionNotIside = "position_with_after_or_before" +) + +// staticRefusalRisks names payload shapes the server refuses, recognised +// from the payload alone. Exactly one guard is transcribed here — the +// targeting rule in resolveTarget (stateops.go), which refuses `position` +// alongside `after`/`before`, where the anchor already names the slot. +// Nothing else is duplicated here — a full local validator would be a second +// implementation of the server, and the loop runs against the real one. +// +// The shape this classifier was WRITTEN for is gone from it: `position` with +// no targeting field at all was a guaranteed 400, and the probe measured +// gemma4:e2b producing it on 20 payloads — 10 of 10 in add_section, 10 of 10 +// in copy_block_new. The surface changed rather than the classifier: with +// nothing else targeted, position now picks an end of the DOCUMENT (§8.32), +// so the shape the model reaches for is the shape that works and there is no +// risk left to name. +func staticRefusalRisks(op string, args map[string]any) []string { + if op != "insert_blocks" && op != "move_block" { + return nil + } + position, _ := args["position"].(string) + if position == "" { + return nil + } + targeted := "" + for _, field := range []string{"after", "before", "inside"} { + if v, _ := args[field].(string); v != "" { + targeted = field + } + } + if targeted == "after" || targeted == "before" { + return []string{riskPositionNotIside} + } + return nil +} + +// example shapes the probe can serve alongside a schema. +const ( + // exampleAsPublished is the example byte-for-byte as GET + // /v2/schemas/ops/{op} serves it: a whole PATCH request body, + // {"ops":[{…}]}. + exampleAsPublished = "published" + // exampleAtOpLevel is that example unwrapped to the single op inside it + // — an instance of the schema served beside it. + exampleAtOpLevel = "op" +) + +// probeToolSpecs reads the published op schemas in-process, from the same +// table GET /v2/schemas/ops/{op} serves — so the probe runs with no server, +// on the bytes the server would have sent. +// +// The example shape is a variable because the two halves of that discovery +// response are at DIFFERENT levels: `schema` describes one op object +// (additionalProperties:false, `op` required with a const), while `example` +// is a whole request body, {"ops":[{…}]} — so the served example is not an +// instance of the served schema and would be rejected by it. A consumer that +// reads the pair together, which is the small consumer §5 built the route +// for, gets contradictory instructions. Serving both shapes turns that into +// a measurement instead of an opinion. +func probeToolSpecs(exampleShape string, constAsEnum bool) ([]toolSpec, error) { + var svc v2service.Service + specs := make([]toolSpec, 0, len(probeOps)) + for _, op := range probeOps { + entry, err := svc.SchemaOp(op) + if err != nil { + return nil, fmt.Errorf("read published schema for %q: %w", op, err) + } + example := string(entry.Example) + if exampleShape == exampleAtOpLevel { + example = unwrapOpsExample(entry.Example) + } + schema := entry.Schema + if constAsEnum { + schema = rewriteConstAsEnum(schema, op) + } + specs = append(specs, toolSpec{ + Name: op, + Description: fmt.Sprintf("PATCH op %q on the document. Example: %s", op, example), + Parameters: schema, + }) + } + return specs, nil +} + +// rewriteConstAsEnum turns the discriminator's `const` into a single-value +// `enum`. This is the ONE place the harness alters a served schema, and it +// is a diagnostic, never a measurement: when a model writes a positional +// word into a field pinned by `const`, there are two explanations — the +// model ignored the keyword, or the host's tool-schema rendering dropped it +// before the model ever saw it. `enum` is the older, more widely handled +// spelling of the same constraint, so the same run with one keyword swapped +// separates them. The default is always the schema as served. +func rewriteConstAsEnum(schema json.RawMessage, op string) json.RawMessage { + from := fmt.Sprintf(`"op":{"const":%q}`, op) + to := fmt.Sprintf(`"op":{"enum":[%q]}`, op) + return json.RawMessage(strings.Replace(string(schema), from, to, 1)) +} + +// unwrapOpsExample reduces {"ops":[X]} to X, leaving anything else alone. +func unwrapOpsExample(raw json.RawMessage) string { + var body struct { + Ops []json.RawMessage `json:"ops"` + } + if err := json.Unmarshal(raw, &body); err != nil || len(body.Ops) != 1 { + return string(raw) + } + return string(body.Ops[0]) +} + +func writeProbeSummary(writer *bufio.Writer, records []probeRecord, runId string, opt options) error { + if err := writer.Flush(); err != nil { + return fmt.Errorf("flush probe file: %w", err) + } + summary := buildProbeSummary(runId, records, opt) + path := filepath.Join(opt.outDir, "probe-summary.txt") + if err := os.WriteFile(path, []byte(summary), 0o644); err != nil { + return fmt.Errorf("write probe summary: %w", err) + } + fmt.Println() + fmt.Print(summary) + return nil +} + +// buildProbeSummary renders the probe table. +func buildProbeSummary(runId string, records []probeRecord, opt options) string { + var b []byte + add := func(format string, args ...any) { b = append(b, fmt.Sprintf(format, args...)...) } + add("Schema-emission probe — run %s\n", runId) + add("one turn per attempt, published op schemas, no live API\n") + add("attempts per (model, case): %d · attempts: %d\n\n", opt.n, len(records)) + + type key struct{ model, op string } + type agg struct { + calls, withId, markdown, blocks, none, envErr int + } + byOp := map[key]*agg{} + for _, r := range records { + k := key{r.Model, orDash(r.CalledOp)} + if byOp[k] == nil { + byOp[k] = &agg{} + } + a := byOp[k] + a.calls++ + if len(r.IdEmissions) > 0 { + a.withId++ + } + switch r.Channel { + case "markdown": + a.markdown++ + case "blocks": + a.blocks++ + } + if r.NoToolCall { + a.none++ + } + if r.EnvError != "" { + a.envErr++ + } + } + keys := make([]key, 0, len(byOp)) + for k := range byOp { + keys = append(keys, k) + } + sort.Slice(keys, func(i, j int) bool { + if keys[i].model != keys[j].model { + return keys[i].model < keys[j].model + } + return keys[i].op < keys[j].op + }) + add("%-14s %-16s %7s %10s %10s %8s %8s\n", "model", "tool called", "calls", "…with id", "markdown", "blocks", "no call") + for _, k := range keys { + a := byOp[k] + add("%-14s %-16s %7d %10d %10d %8d %8d\n", k.model, k.op, a.calls, a.withId, a.markdown, a.blocks, a.none) + } + + add("\nid emissions, by payload path\n\n") + paths := map[string]int{} + for _, r := range records { + for _, e := range r.IdEmissions { + paths[e.Tool+" "+e.Path]++ + } + } + if len(paths) == 0 { + add("(none)\n") + } else { + type row struct { + path string + n int + } + var rows []row + for p, n := range paths { + rows = append(rows, row{p, n}) + } + sort.Slice(rows, func(i, j int) bool { return rows[i].n > rows[j].n }) + for _, r := range rows { + add("%4d× %s\n", r.n, r.path) + } + } + + add("\npayloads the server would refuse, by shape\n\n") + risks := map[string]int{} + for _, r := range records { + for _, risk := range r.RefusalRisks { + risks[r.Model+" "+r.CalledOp+" "+risk]++ + } + } + if len(risks) == 0 { + add("(none)\n") + } else { + names := make([]string, 0, len(risks)) + for name := range risks { + names = append(names, name) + } + sort.Slice(names, func(i, j int) bool { return risks[names[i]] > risks[names[j]] }) + for _, name := range names { + add("%4d× %s\n", risks[name], name) + } + } + + missingOp := map[string]int{} + wrongOp := map[string]int{} + wroteValues := map[string]int{} + calls := map[string]int{} + for _, r := range records { + if r.CalledOp == "" { + continue + } + calls[r.Model]++ + switch { + case r.MissingOpConst: + missingOp[r.Model]++ + case r.OpConstValue != "": + wrongOp[r.Model]++ + wroteValues[r.Model+" wrote "+r.OpConstValue]++ + } + } + add("\nthe required `op` const (the tool name determines its one legal value)\n\n") + models := make([]string, 0, len(calls)) + for m := range calls { + models = append(models, m) + } + sort.Strings(models) + for _, m := range models { + add("%-14s %d/%d absent, %d/%d wrong\n", m, missingOp[m], calls[m], wrongOp[m], calls[m]) + } + values := make([]string, 0, len(wroteValues)) + for v := range wroteValues { + values = append(values, v) + } + sort.Slice(values, func(i, j int) bool { return wroteValues[values[i]] > wroteValues[values[j]] }) + for _, v := range values { + add("%4d× %s\n", wroteValues[v], v) + } + + envErrs := 0 + for _, r := range records { + if r.EnvError != "" { + envErrs++ + } + } + if envErrs > 0 { + add("\nenvironment failures (excluded): %d\n", envErrs) + } + return string(b) +} + +func orDash(s string) string { + if s == "" { + return "—" + } + return s +} diff --git a/cmd/apiv2eval/probe_test.go b/cmd/apiv2eval/probe_test.go new file mode 100644 index 0000000000..af84283ae1 --- /dev/null +++ b/cmd/apiv2eval/probe_test.go @@ -0,0 +1,173 @@ +package main + +import ( + "context" + "encoding/json" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestProbeToolSpecsComeFromThePublishedSchemas(t *testing.T) { + // when + specs, err := probeToolSpecs(exampleAsPublished, false) + + // then + require.NoError(t, err) + require.Len(t, specs, 2) + + byName := map[string]toolSpec{} + for _, s := range specs { + byName[s.Name] = s + } + // the matched pair the probe exists for: one schema publishes the payload + // id slot, the other does not — if this ever stops holding, the probe is + // measuring nothing + insert := string(byName["insert_blocks"].Parameters) + replace := string(byName["replace_subtree"].Parameters) + assert.NotContains(t, insert, `"id"`, "insert_blocks must publish no id slot anywhere (§8.30/§8.31)") + assert.Contains(t, replace, `"id"`, "replace_subtree still publishes one — it is the control") +} + +func TestStaticRefusalRisks(t *testing.T) { + tests := []struct { + name string + op string + args string + want []string + }{ + { + // the shape gemma4:e2b produced 20/20 times: it is no longer a + // refusal, so it is no longer a risk (§8.32) + name: "position with no targeting field targets the document", + op: "insert_blocks", + args: `{"op":"insert_blocks","blocks":[{"type":"paragraph"}],"position":"last"}`, + }, + { + name: "the same shape asking for the start of the document", + op: "insert_blocks", + args: `{"op":"insert_blocks","blocks":[{"type":"paragraph"}],"position":"first"}`, + }, + { + name: "position alongside after is refused too", + op: "insert_blocks", + args: `{"op":"insert_blocks","after":"b3","blocks":[{"type":"paragraph"}],"position":"last"}`, + want: []string{riskPositionNotIside}, + }, + { + name: "position with inside is the one legal use", + op: "insert_blocks", + args: `{"op":"insert_blocks","inside":"b3","blocks":[{"type":"paragraph"}],"position":"first"}`, + }, + { + name: "no position, no risk", + op: "insert_blocks", + args: `{"op":"insert_blocks","markdown":"## Risks"}`, + }, + { + name: "ops without targeting are out of scope", + op: "replace_subtree", + args: `{"op":"replace_subtree","id":"b7","blocks":[{"type":"paragraph"}],"position":"last"}`, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // given + var args map[string]any + require.NoError(t, json.Unmarshal([]byte(tt.args), &args)) + + // when + got := staticRefusalRisks(tt.op, args) + + // then + assert.Equal(t, tt.want, got) + }) + } +} + +func TestServedOpExampleIsAnInstanceOfItsOwnSchema(t *testing.T) { + // This test was written to DOCUMENT the defect: GET /v2/schemas/ops/{op} + // answered with a `schema` describing ONE op (additionalProperties:false, + // `op` required with a const) beside an `example` that was a whole PATCH + // request body, {"ops":[{…}]} — so the example was rejected by the schema + // served with it. Unwrapping it took gemma4:e4b's missing-`op` rate from + // 9/60 to 0/60, and the route now serves the op level. The assertion is + // inverted rather than deleted, so the harness's two example shapes stay + // meaningful and a regression to the wrapped body is caught here as well + // as at the source (TestServedOpExampleValidatesAgainstItsOwnSchema). + specs, err := probeToolSpecs(exampleAsPublished, false) + require.NoError(t, err) + for _, spec := range specs { + var schema struct { + Properties map[string]any `json:"properties"` + AdditionalProperties *bool `json:"additionalProperties"` + } + require.NoError(t, json.Unmarshal(spec.Parameters, &schema)) + require.NotNil(t, schema.AdditionalProperties) + assert.False(t, *schema.AdditionalProperties, "%s: the op schema is C13-strict", spec.Name) + assert.NotContains(t, schema.Properties, "ops", "%s: the schema describes one op, not a request body", spec.Name) + assert.NotContains(t, spec.Description, `{"ops":[`, "%s: the served example is not a request body", spec.Name) + assert.Contains(t, spec.Description, `"op":"`+spec.Name+`"`, "%s: it is an instance of the schema", spec.Name) + } + + // the two shapes the probe can serve are now the same bytes: -probe-example + // stays a knob, but it no longer separates anything + unwrapped, err := probeToolSpecs(exampleAtOpLevel, false) + require.NoError(t, err) + require.Len(t, unwrapped, len(specs)) + for i, spec := range unwrapped { + assert.Equal(t, specs[i].Description, spec.Description) + } +} + +func TestRewriteConstAsEnumTouchesOnlyTheDiscriminator(t *testing.T) { + // given — the harness's one deliberate deviation from a served schema + served, err := probeToolSpecs(exampleAtOpLevel, false) + require.NoError(t, err) + swapped, err := probeToolSpecs(exampleAtOpLevel, true) + require.NoError(t, err) + require.Len(t, swapped, len(served)) + + for i, spec := range swapped { + // then + assert.Contains(t, string(served[i].Parameters), `"op":{"const":"`+spec.Name+`"}`) + assert.Contains(t, string(spec.Parameters), `"op":{"enum":["`+spec.Name+`"]}`) + assert.NotContains(t, string(spec.Parameters), `"const"`, "no other const may be rewritten") + // everything else is byte-identical + restored := strings.Replace(string(spec.Parameters), + `"op":{"enum":["`+spec.Name+`"]}`, `"op":{"const":"`+spec.Name+`"}`, 1) + assert.Equal(t, string(served[i].Parameters), restored) + } +} + +func TestProbeRecordsWhatWasWrittenIntoTheDiscriminator(t *testing.T) { + tests := []struct { + name string + args string + wantMissing bool + wantValue string + }{ + {name: "the const, correctly", args: `{"op":"insert_blocks","markdown":"x"}`}, + {name: "absent", args: `{"markdown":"x"}`, wantMissing: true}, + {name: "a positional word", args: `{"op":"append","markdown":"x"}`, wantValue: `"append"`}, + {name: "an empty object", args: `{"op":{},"markdown":"x"}`, wantValue: `{}`}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // given + model, _ := newScriptedModel(toolCallTurn("insert_blocks", tt.args)) + defer model.Close() + + // when + got := probeOnce(context.Background(), newChatClient(model.URL, "", 30*time.Second), + "stub", probeCase{id: "c", wantOp: "insert_blocks"}, nil, "sys", options{}, "run1", 1) + + // then + assert.Equal(t, tt.wantMissing, got.MissingOpConst) + assert.Equal(t, tt.wantValue, got.OpConstValue) + }) + } +} diff --git a/cmd/apiv2eval/report.go b/cmd/apiv2eval/report.go new file mode 100644 index 0000000000..7e597d69a6 --- /dev/null +++ b/cmd/apiv2eval/report.go @@ -0,0 +1,458 @@ +package main + +// report.go — the readable summary beside the JSONL. Rates are printed as +// k/n with n always visible: with three or five attempts per cell a bare +// percentage would be a lie of precision, and small models vary enough that +// the spread between cells matters more than any single number. + +import ( + "fmt" + "sort" + "strings" +) + +// cellKey identifies one (model, arm, task) cell. +type cellKey struct { + model string + arm string + task string +} + +type cellStats struct { + attempts int + success int + env int + turns int + tokens int + calls int + failed int +} + +func (c cellStats) counted() int { return c.attempts - c.env } + +// buildSummary renders the whole report. +func buildSummary(runId string, attempts []attemptRecord, skipped []skippedCell, opt options) string { + var b strings.Builder + fmt.Fprintf(&b, "API v2 small-model evaluation — run %s\n", runId) + fmt.Fprintf(&b, "attempts per cell (n): %d · turn budget: %d · temperature: %.2f\n", + opt.n, opt.maxTurns, opt.temperature) + fmt.Fprintf(&b, "attempts: %d\n\n", len(attempts)) + + // the skipped cells lead, before any rate: a rate over a matrix with + // holes in it means something different from a rate over a full one, and + // the reader has to know which they are looking at + b.WriteString("## Cells not run\n\n") + if len(skipped) == 0 { + b.WriteString("(none — every arm publishes a tool for every capability its tasks require)\n") + } else { + for _, s := range skipped { + fmt.Fprintf(&b, "%-14s %-14s %-16s — %s\n", s.Model, s.Arm, s.Task, s.Reason) + } + } + b.WriteString("\n") + + byCell := map[cellKey]*cellStats{} + byArm := map[cellKey]*cellStats{} + for _, a := range attempts { + ck := cellKey{a.Model, a.Arm, a.Task} + ak := cellKey{a.Model, a.Arm, ""} + for _, key := range []struct { + m map[cellKey]*cellStats + k cellKey + }{{byCell, ck}, {byArm, ak}} { + s := key.m[key.k] + if s == nil { + s = &cellStats{} + key.m[key.k] = s + } + s.attempts++ + switch a.Outcome { + case outcomeSuccess: + s.success++ + case outcomeEnv: + s.env++ + } + if a.Outcome != outcomeEnv { + s.turns += a.Turns + s.tokens += a.PromptTokens + a.CompletionTokens + s.calls += a.ToolCalls + s.failed += a.FailedCalls + } + } + } + + b.WriteString("## Success rate by surface\n\n") + b.WriteString(fmt.Sprintf("%-14s %-14s %8s %8s %8s %8s %8s\n", "model", "arm", "passed", "rate", "turns", "tokens", "err/call")) + for _, k := range sortedCellKeys(byArm) { + s := byArm[k] + fmt.Fprintf(&b, "%-14s %-14s %8s %8s %8.1f %8.0f %8s\n", + k.model, k.arm, fmt.Sprintf("%d/%d", s.success, s.counted()), rate(s), + mean(s.turns, s.counted()), mean(s.tokens, s.counted()), + fmt.Sprintf("%d/%d", s.failed, s.calls)) + } + + b.WriteString("\n## Success rate by task\n\n") + b.WriteString(fmt.Sprintf("%-14s %-14s %-16s %8s %8s\n", "model", "arm", "task", "passed", "turns")) + for _, k := range sortedCellKeys(byCell) { + s := byCell[k] + fmt.Fprintf(&b, "%-14s %-14s %-16s %8s %8.1f\n", + k.model, k.arm, k.task, fmt.Sprintf("%d/%d", s.success, s.counted()), mean(s.turns, s.counted())) + } + + b.WriteString("\n## H1 — does the model emit an id where the schema does not show one?\n") + b.WriteString("(insert_blocks publishes no id slot since §8.30/§8.31; replace_subtree still does — the control)\n\n") + b.WriteString(fmt.Sprintf("%-14s %-14s %14s %14s %16s %16s\n", "model", "arm", "insert_blocks", "…with id", "replace_subtree", "…with id")) + h1 := map[cellKey]*[4]int{} + for _, a := range attempts { + k := cellKey{a.Model, a.Arm, ""} + if h1[k] == nil { + h1[k] = &[4]int{} + } + h1[k][0] += a.Signals.InsertBlocksCalls + h1[k][1] += a.Signals.InsertBlocksWithId + h1[k][2] += a.Signals.ReplaceSubtreeCalls + h1[k][3] += a.Signals.ReplaceSubtreeIds + } + for _, k := range sortedIntCellKeys(h1) { + v := h1[k] + fmt.Fprintf(&b, "%-14s %-14s %14d %14d %16d %16d\n", k.model, k.arm, v[0], v[1], v[2], v[3]) + } + unknownArg, opAbsent, opWrong := 0, 0, 0 + for _, a := range attempts { + unknownArg += a.Signals.UnknownArgCalls + opAbsent += a.Signals.OpConstAbsent + opWrong += a.Signals.OpConstWrong + } + fmt.Fprintf(&b, "\ncalls refused for naming an argument the tool does not have: %d\n", unknownArg) + b.WriteString("(the wrapper's add_blocks has no id channel at all — on that surface the field is unemittable by construction)\n") + fmt.Fprintf(&b, "ops-arm `op` discriminator: %d absent, %d wrong (set from the tool name either way — never an outcome)\n", + opAbsent, opWrong) + + b.WriteString("\n## H2 — was an echoed block id the exact string the read served?\n\n") + refClasses := []string{refExact, refSuffix, refStale, refCaseFold, refSubstring, refHandleLike, refInvented, refNoRead} + h2 := map[cellKey]map[string]int{} + for _, a := range attempts { + k := cellKey{a.Model, a.Arm, ""} + if h2[k] == nil { + h2[k] = map[string]int{} + } + for _, r := range a.Signals.Refs { + h2[k][r.Class]++ + } + } + writeClassCounts(&b, h2, refClasses) + + b.WriteString("\n## H3 — after a refusal, what did the next turn do?\n\n") + repairClasses := []string{repairFixedNamed, repairChangedElse, repairIdentical, repairSwitchRead, repairSwitchTool, repairAbandoned} + h3 := map[cellKey]map[string]int{} + for _, a := range attempts { + k := cellKey{a.Model, a.Arm, ""} + if h3[k] == nil { + h3[k] = map[string]int{} + } + for _, r := range a.Signals.Repairs { + h3[k][r.Class]++ + } + } + writeClassCounts(&b, h3, repairClasses) + wrongTarget := 0 + for _, a := range attempts { + if a.WrongTargetWrites > 0 { + wrongTarget++ + } + } + if wrongTarget > 0 { + fmt.Fprintf(&b, "\nattempts that wrote to an object other than their fixture: %d\n", wrongTarget) + } + + b.WriteString("\n## H4 — edit_text's optional `block`: does the model fill a field it is shown?\n") + b.WriteString("(the ab/… arms differ ONLY in the published edit_text definition; the runner behind them is identical,\n") + b.WriteString(" so a block sent to an arm that does not publish one still works and is still counted here)\n\n") + fmt.Fprintf(&b, "%-14s %-14s %10s %10s %10s %12s %10s %14s\n", + "model", "arm", "edit_text", "…w/ block", "ambiguous", "no-match", "wasted", "…of which O→F") + type abStats struct { + calls, withBlock, ambiguous, noMatch int + outlineThenFull, readBeforeEdit int + } + ab := map[cellKey]*abStats{} + for _, a := range attempts { + k := cellKey{a.Model, a.Arm, ""} + s := ab[k] + if s == nil { + s = &abStats{} + ab[k] = s + } + s.calls += a.Signals.EditTextCalls + s.withBlock += a.Signals.EditTextWithBlock + s.ambiguous += a.Signals.SnippetAmbiguous + s.noMatch += a.Signals.SnippetNoMatch + for _, w := range a.Signals.WastedReads { + switch w.Kind { + case wasteOutlineThenFull: + s.outlineThenFull++ + case wasteReadBeforeSnippetEdit: + s.readBeforeEdit++ + } + } + } + for _, k := range sortedABKeys(ab) { + s := ab[k] + fmt.Fprintf(&b, "%-14s %-14s %10d %10d %10d %12d %10d %14d\n", + k.model, k.arm, s.calls, s.withBlock, s.ambiguous, s.noMatch, + s.outlineThenFull+s.readBeforeEdit, s.outlineThenFull) + } + b.WriteString("\nwasted reads are counted apart, never summed into a judgment:\n") + b.WriteString(" " + wasteOutlineThenFull + " — an outline read superseded by a full read of the same object\n") + b.WriteString(" " + wasteReadBeforeSnippetEdit + " — a full read before an edit_text that located its block from the snippet;\n") + b.WriteString(" on read-then-edit that read is NOT waste (the model must learn the old text), on edit-one-word it is\n") + + blockAsObject, spaceAsObject := 0, 0 + for _, a := range attempts { + blockAsObject += a.Signals.ObjectArgIsBlockRef + spaceAsObject += a.Signals.SpaceIdAsObject + } + if blockAsObject+spaceAsObject > 0 { + b.WriteString("\n## `object` arguments that were not objects\n\n") + fmt.Fprintf(&b, "a block reference a read served, passed as object: %d\n", blockAsObject) + fmt.Fprintf(&b, "the SPACE id passed as object: %d\n", spaceAsObject) + b.WriteString("(both are one id and one slot to put it in — the second is the shape the harness's own preamble invited)\n") + } + + findCalls, multi, maxMatches := 0, 0, 0 + for _, a := range attempts { + findCalls += a.Signals.FindCalls + multi += a.Signals.FindMultiMatch + if a.Signals.MaxFindMatches > maxMatches { + maxMatches = a.Signals.MaxFindMatches + } + } + if findCalls > 0 { + fmt.Fprintf(&b, "\nfixture isolation: %d/%d find calls returned more than one object (most matches seen: %d)\n", + multi, findCalls, maxMatches) + b.WriteString("(fixtures are never deleted, so anything above zero means a run is finding its own leftovers)\n") + } + + recovered, withErrors := 0, 0 + for _, a := range attempts { + if a.Outcome == outcomeEnv || a.FailedCalls == 0 { + continue + } + withErrors++ + if a.Outcome == outcomeSuccess { + recovered++ + } + } + fmt.Fprintf(&b, "\nattempts that hit at least one refusal and still passed: %d/%d\n", recovered, withErrors) + + b.WriteString("\n## Refusals by (status, code, path)\n\n") + type errKey struct { + status int + code string + path string + } + errCounts := map[errKey]int{} + errSample := map[errKey]string{} + for _, a := range attempts { + if a.Transcript == nil { + continue + } + for _, c := range a.Transcript.Calls { + for _, ex := range c.Exchanges { + if ex.Status < 400 { + continue + } + path := "" + if len(ex.Issues) > 0 { + path = ex.Issues[0].Path + } + k := errKey{ex.Status, ex.Code, path} + errCounts[k]++ + if errSample[k] == "" { + errSample[k] = ex.Message + } + } + } + } + type errRow struct { + k errKey + n int + } + var rows []errRow + for k, n := range errCounts { + rows = append(rows, errRow{k, n}) + } + sort.Slice(rows, func(i, j int) bool { + if rows[i].n != rows[j].n { + return rows[i].n > rows[j].n + } + return rows[i].k.code < rows[j].k.code + }) + for _, r := range rows { + fmt.Fprintf(&b, "%4d× %d %s %s\n %s\n", r.n, r.k.status, r.k.code, r.k.path, firstLine(errSample[r.k])) + } + if len(rows) == 0 { + b.WriteString("(none)\n") + } + + // wrapper-side refusals never become HTTP calls — they are counted apart + wrapperRefusals := map[string]int{} + for _, a := range attempts { + if a.Transcript == nil { + continue + } + for _, c := range a.Transcript.Calls { + if !c.IsError || len(c.Exchanges) > 0 { + continue + } + wrapperRefusals[firstLine(c.ResultText)]++ + } + } + if len(wrapperRefusals) > 0 { + b.WriteString("\n## Refusals raised before any HTTP call (wrapper-side validation)\n\n") + type wr struct { + text string + n int + } + var wrs []wr + for text, n := range wrapperRefusals { + wrs = append(wrs, wr{text, n}) + } + sort.Slice(wrs, func(i, j int) bool { return wrs[i].n > wrs[j].n }) + for _, w := range wrs { + fmt.Fprintf(&b, "%4d× %s\n", w.n, w.text) + } + } + + // One quoted failure per (arm, task): a rate says how often the loop + // breaks, a transcript says why, and a paraphrase of a transcript is + // worth neither. + quoted := map[cellKey]bool{} + var failures []attemptRecord + for _, a := range attempts { + if a.Outcome != outcomeFailure { + continue + } + k := cellKey{"", a.Arm, a.Task} + if quoted[k] { + continue + } + quoted[k] = true + failures = append(failures, a) + } + if len(failures) > 0 { + b.WriteString("\n## One failing transcript per (arm, task)\n") + for _, a := range failures { + fmt.Fprintf(&b, "\n%s · %s · %s #%d\n", a.Model, a.Arm, a.Task, a.Seq) + fmt.Fprintf(&b, " prompt: %s\n", a.Prompt) + if a.Transcript != nil { + b.WriteString(summarizeCalls(a.Transcript.Calls)) + if a.Transcript.FinalContent != "" { + fmt.Fprintf(&b, " said: %s\n", firstLine(a.Transcript.FinalContent)) + } + } + fmt.Fprintf(&b, " check: %s\n", a.CheckDetail) + } + } + + envAttempts := 0 + for _, a := range attempts { + if a.Outcome == outcomeEnv { + envAttempts++ + } + } + if envAttempts > 0 { + b.WriteString("\n## Environment failures (excluded from every rate above)\n\n") + for _, a := range attempts { + if a.Outcome != outcomeEnv { + continue + } + fmt.Fprintf(&b, "%-14s %-14s %-16s #%d — %s\n", a.Model, a.Arm, a.Task, a.Seq, firstLine(a.EnvError)) + } + } + return b.String() +} + +// writeClassCounts renders one classification per (model, arm) as a compact +// key=value line. A column per class would be 160 characters wide and +// mostly zeros; the classes that fired are the ones worth reading. +func writeClassCounts(b *strings.Builder, counts map[cellKey]map[string]int, classes []string) { + for _, k := range sortedClassKeys(counts) { + var parts []string + total := 0 + for _, class := range classes { + if n := counts[k][class]; n > 0 { + parts = append(parts, fmt.Sprintf("%s=%d", class, n)) + total += n + } + } + if total == 0 { + parts = append(parts, "(none)") + } + fmt.Fprintf(b, "%-14s %-14s %3d · %s\n", k.model, k.arm, total, strings.Join(parts, " · ")) + } + if len(counts) == 0 { + b.WriteString("(none)\n") + } +} + +func rate(s *cellStats) string { + if s.counted() == 0 { + return "—" + } + return fmt.Sprintf("%.0f%%", 100*float64(s.success)/float64(s.counted())) +} + +func mean(total, n int) float64 { + if n == 0 { + return 0 + } + return float64(total) / float64(n) +} + +func sortedCellKeys(m map[cellKey]*cellStats) []cellKey { + keys := make([]cellKey, 0, len(m)) + for k := range m { + keys = append(keys, k) + } + sortCellKeys(keys) + return keys +} + +func sortedIntCellKeys(m map[cellKey]*[4]int) []cellKey { + keys := make([]cellKey, 0, len(m)) + for k := range m { + keys = append(keys, k) + } + sortCellKeys(keys) + return keys +} + +func sortedABKeys[T any](m map[cellKey]*T) []cellKey { + keys := make([]cellKey, 0, len(m)) + for k := range m { + keys = append(keys, k) + } + sortCellKeys(keys) + return keys +} + +func sortedClassKeys(m map[cellKey]map[string]int) []cellKey { + keys := make([]cellKey, 0, len(m)) + for k := range m { + keys = append(keys, k) + } + sortCellKeys(keys) + return keys +} + +func sortCellKeys(keys []cellKey) { + sort.Slice(keys, func(i, j int) bool { + if keys[i].model != keys[j].model { + return keys[i].model < keys[j].model + } + if keys[i].arm != keys[j].arm { + return keys[i].arm < keys[j].arm + } + return keys[i].task < keys[j].task + }) +} diff --git a/cmd/apiv2eval/report_test.go b/cmd/apiv2eval/report_test.go new file mode 100644 index 0000000000..b0f79393b5 --- /dev/null +++ b/cmd/apiv2eval/report_test.go @@ -0,0 +1,95 @@ +package main + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + v2model "github.com/anyproto/anytype-heart/core/api/v2/model" +) + +func TestSummaryExcludesEnvironmentFailuresFromTheRate(t *testing.T) { + // given — three attempts in one cell: one pass, one real failure, one + // the model host timed out on. The rate must be 1/2, never 1/3: an + // environment failure is not evidence about the API. + attempts := []attemptRecord{ + {Model: "m", Arm: "ops", Task: "edit-one-word", Seq: 1, Outcome: outcomeSuccess, Turns: 2, ToolCalls: 2}, + {Model: "m", Arm: "ops", Task: "edit-one-word", Seq: 2, Outcome: outcomeFailure, Turns: 4, ToolCalls: 4, FailedCalls: 2, + CheckDetail: "Q3 still present"}, + {Model: "m", Arm: "ops", Task: "edit-one-word", Seq: 3, Outcome: outcomeEnv, Turns: 1, EnvError: "model timeout"}, + } + + // when + got := buildSummary("run1", attempts, nil, options{n: 3, maxTurns: 8}) + + // then + assert.Contains(t, got, "1/2") + assert.NotContains(t, got, "1/3") + assert.Contains(t, got, "Environment failures (excluded from every rate above)") + assert.Contains(t, got, "model timeout") +} + +func TestSummaryQuotesAFailingTranscriptAndItsRefusals(t *testing.T) { + // given + failing := attemptRecord{ + Model: "m", Arm: "wrapper/small", Task: "edit-one-word", Seq: 1, + Outcome: outcomeFailure, CheckDetail: "Q3 still present", + Prompt: `In the note titled "Quarterly plan ab12", change Q3 to Q4.`, + Transcript: &transcript{ + Calls: []callRecord{{ + Turn: 1, Tool: "edit_text", + Args: json.RawMessage(`{"object":"1","block":"zz","find":"Q3","replace":"Q4"}`), + IsError: true, + ResultText: "block \"zz\" not found\n block: no block matches \"zz\"", + Exchanges: []exchange{{ + Method: "PATCH", Path: "/v2/spaces/s1/objects/obj1", Status: 400, Code: "invalid_input", + Message: `block "zz" not found`, + Issues: []v2model.Issue{{Path: "ops[0].id", Message: `no block matches "zz"`}}, + }}, + }}, + FinalContent: "I updated the note.", + }, + Signals: signals{Repairs: []repair{{Class: repairAbandoned, Code: "invalid_input"}}}, + } + + // when + got := buildSummary("run1", []attemptRecord{failing}, nil, options{n: 1, maxTurns: 8}) + + // then + assert.Contains(t, got, "One failing transcript per (arm, task)") + assert.Contains(t, got, `{"object":"1","block":"zz","find":"Q3","replace":"Q4"}`) + assert.Contains(t, got, "400 invalid_input ops[0].id", "the refusal table keeps the wire path") + assert.Contains(t, got, "said: I updated the note.", "the model's self-report is quoted, never believed") + assert.Contains(t, got, "check: Q3 still present") + assert.Contains(t, got, repairAbandoned) +} + +func TestSummaryReportsTheInsertBlocksControlSideBySide(t *testing.T) { + // given + attempts := []attemptRecord{{ + Model: "m", Arm: "ops", Task: "append-section", Outcome: outcomeSuccess, + Signals: signals{ + InsertBlocksCalls: 4, InsertBlocksWithId: 1, + ReplaceSubtreeCalls: 2, ReplaceSubtreeIds: 2, + }, + }} + + // when + got := buildSummary("run1", attempts, nil, options{n: 1, maxTurns: 8}) + + // then + require.Contains(t, got, "H1 — does the model emit an id where the schema does not show one?") + assert.Contains(t, got, "replace_subtree still does — the control") + assert.Contains(t, got, "4") +} + +func TestSummaryHandlesAnEmptyRun(t *testing.T) { + // when — an interrupted run before its first attempt must still write a + // summary rather than panic + got := buildSummary("run1", nil, nil, options{n: 3, maxTurns: 8}) + + // then + assert.Contains(t, got, "attempts: 0") +} diff --git a/cmd/apiv2eval/tasks.go b/cmd/apiv2eval/tasks.go new file mode 100644 index 0000000000..ad8298221f --- /dev/null +++ b/cmd/apiv2eval/tasks.go @@ -0,0 +1,468 @@ +package main + +// tasks.go — the task table. Each task is a realistic small edit with a +// fixture the harness creates fresh per attempt and a check that asks the +// API what the document says afterwards. No check reads the model's own +// account of what it did, and none is a string match on chat output. +// +// Tasks are also chosen to reach the three instrumented questions: the +// add-content task is the one where insert_blocks authors a payload, the +// table and restructure tasks are where block ids must be echoed, and the +// deliberately ambiguous snippet in the table task is a refusal a model has +// to repair from. +// +// A task declares the CAPABILITIES it cannot be done without, and the run +// derives which cells to skip from them (capabilityTools + planCells). It +// does not name tiers by hand: that is what let fill-table-cell run on a +// small tier with no set_cell, where the model correctly reported the limit +// and the matrix scored the answer as a failure. + +import ( + "context" + "crypto/rand" + "fmt" + "strings" + "time" +) + +// fixture is one attempt's freshly created object. +type fixture struct { + ObjectId string + Title string + Extra map[string]string +} + +// checkResult is a task check's verdict. +type checkResult struct { + OK bool + Detail string +} + +// task is one evaluated intent. +type task struct { + Id string + Intent string + // Arms restricts which surfaces run it (empty = both). + Arms []string + // Requires names the capabilities the task cannot be completed without. + // The gate is DERIVED from this against each arm's published tool set + // (capabilityTools, armSpec.publishedTools): a cell whose arm publishes + // no tool for a required capability is not run, because it asks a model + // to do something the surface it was handed cannot do. + Requires []capability + // Markdown is the fixture body; the name is minted per attempt by + // fixtureTitle, so repeated runs never edit — or find — each other's + // objects. + Markdown string + // Prompt is the user turn. It names the object by TITLE (the wrapper arm + // must find it) — the ops arm is bound to the object already. + Prompt func(fx *fixture) string + Check func(doc *document, fx *fixture) checkResult +} + +func (t task) runsOnArm(arm string) bool { + if len(t.Arms) == 0 { + return true + } + for _, a := range t.Arms { + if a == arm { + return true + } + } + return false +} + +// +// ---- capability gating ---- +// + +// capability is one thing a task cannot be done without, named once for both +// surfaces. Tasks declare capabilities rather than tool names because the +// wrapper re-verbs the op vocabulary (replace_text → edit_text, insert_blocks +// → add_blocks) and the gate has to ask both surfaces the same question. +// Since the C2 rename (APIV2.md §8.46) most of the pairs coincide — both +// surfaces spell snake_case now — but the mapping stays: the two that differ +// still differ, and it is also what says a tier serves the tool at all. +type capability string + +const ( + capRead capability = "read the document" + capEditText capability = "replace text in a block" + capAddBlocks capability = "add blocks" + capSetCell capability = "write a table cell" + capDeleteBlock capability = "delete a block" + capSetProperties capability = "set a property" +) + +// capabilityTools names the tool each surface would use for each capability +// — the ONE place the two vocabularies meet. A hand-kept list of which task +// runs where is what let fill-table-cell run on a tier with no set_cell: +// the model recognised the limit and said so, and the matrix scored six +// guaranteed losses into the headline rate (§8.31's drift class, one level +// up from the schema). +var capabilityTools = map[capability]map[string]string{ + capRead: {surfaceWrapper: "read", surfaceOps: "read_object"}, + capEditText: {surfaceWrapper: "edit_text", surfaceOps: "replace_text"}, + capAddBlocks: {surfaceWrapper: "add_blocks", surfaceOps: "insert_blocks"}, + capSetCell: {surfaceWrapper: "set_cell", surfaceOps: "set_cell"}, + capDeleteBlock: {surfaceWrapper: "delete_block", surfaceOps: "delete_block"}, + capSetProperties: {surfaceWrapper: "set_properties", surfaceOps: "set_properties"}, +} + +// capabilityTool returns the surface's tool for a capability. +func capabilityTool(c capability, surface string) (string, error) { + bySurface, ok := capabilityTools[c] + if !ok { + return "", fmt.Errorf("capability %q is not in capabilityTools", c) + } + tool, ok := bySurface[surface] + if !ok { + return "", fmt.Errorf("capability %q names no tool on the %s surface", c, surface) + } + return tool, nil +} + +// checkTaskGating verifies every declared capability resolves on every +// surface, at startup rather than an hour into a run: an unmapped capability +// would otherwise skip a cell silently, which reads in the report exactly +// like a cell that was deliberately not measured. +func checkTaskGating() error { + for _, t := range tasks() { + for _, c := range t.Requires { + for _, surface := range []string{surfaceWrapper, surfaceOps} { + if _, err := capabilityTool(c, surface); err != nil { + return fmt.Errorf("task %s: %w", t.Id, err) + } + } + } + } + return nil +} + +// tasks is the table. +func tasks() []task { + return []task{ + { + Id: "edit-one-word", + Intent: "change one word in a document", + Requires: []capability{capEditText}, + Markdown: "## Summary\n" + + "Revenue target for Q3 is 1.2M.\n\n" + + "## Owner\n" + + "The finance team reviews this monthly.\n", + Prompt: func(fx *fixture) string { + return fmt.Sprintf("In the page titled %q, change Q3 to Q4. Change nothing else.", fx.Title) + }, + Check: func(doc *document, fx *fixture) checkResult { + want := "Revenue target for Q4 is 1.2M." + if _, ok := doc.findBlock(func(b docBlock) bool { return strings.TrimSpace(b.Text) == want }); !ok { + return checkResult{Detail: fmt.Sprintf("no block reads %q; blocks: %q", want, doc.blockTexts())} + } + if strings.Contains(doc.allText(), "Q3") { + return checkResult{Detail: "Q3 still present: " + strings.Join(doc.blockTexts(), " | ")} + } + if !strings.Contains(doc.allText(), "The finance team reviews this monthly.") { + return checkResult{Detail: "collateral damage: the Owner section was changed"} + } + return checkResult{OK: true} + }, + }, + { + Id: "append-section", + Intent: "add a section of content", + Requires: []capability{capAddBlocks}, + Markdown: "## Overview\n" + + "The migration runs in three stages.\n", + Prompt: func(fx *fixture) string { + return fmt.Sprintf("Add a section at the end of the page titled %q: a heading that reads Risks, "+ + "followed by two bullet points reading Vendor delay and Budget overrun.", fx.Title) + }, + Check: func(doc *document, fx *fixture) checkResult { + headingAt := -1 + for i, b := range doc.Blocks { + if strings.HasPrefix(b.Type, "heading") && strings.TrimSpace(b.Text) == "Risks" { + headingAt = i + } + } + if headingAt < 0 { + return checkResult{Detail: fmt.Sprintf("no heading block reads \"Risks\"; blocks: %s", describeBlocks(doc))} + } + want := map[string]bool{"Vendor delay": false, "Budget overrun": false} + for _, b := range doc.Blocks[headingAt+1:] { + if b.Type != "bulletedListItem" { + continue + } + if _, ok := want[strings.TrimSpace(b.Text)]; ok { + want[strings.TrimSpace(b.Text)] = true + } + } + for text, found := range want { + if !found { + return checkResult{Detail: fmt.Sprintf("no bullet after the heading reads %q; blocks: %s", text, describeBlocks(doc))} + } + } + if !strings.Contains(doc.allText(), "The migration runs in three stages.") { + return checkResult{Detail: "collateral damage: the Overview section was changed"} + } + return checkResult{OK: true} + }, + }, + { + Id: "fill-table-cell", + Intent: "fill a table cell", + Requires: []capability{capSetCell}, + Markdown: "## Components\n\n" + + "| Component | Status |\n" + + "| --- | --- |\n" + + "| Alpha | Done |\n" + + "| Beta | Pending |\n" + + "| Gamma | Pending |\n", + Prompt: func(fx *fixture) string { + return fmt.Sprintf("In the page titled %q there is a components table. The Beta component is finished — "+ + "set its Status cell to Done. Leave every other row alone.", fx.Title) + }, + Check: func(doc *document, fx *fixture) checkResult { + table, ok := doc.table() + if !ok { + return checkResult{Detail: "the table block is gone: " + describeBlocks(doc)} + } + statusCol := -1 + for _, row := range table.Rows { + if !row.IsHeader { + continue + } + for i, cell := range row.Cells { + if strings.EqualFold(strings.TrimSpace(cellText(cell)), "Status") { + statusCol = i + } + } + } + if statusCol < 0 { + return checkResult{Detail: "no Status column in the header row"} + } + want := map[string]string{"Alpha": "Done", "Beta": "Done", "Gamma": "Pending"} + got := map[string]string{} + for _, row := range table.Rows { + if row.IsHeader || len(row.Cells) == 0 { + continue + } + name := strings.TrimSpace(cellText(row.Cells[0])) + value := "" + if statusCol < len(row.Cells) { + value = strings.TrimSpace(cellText(row.Cells[statusCol])) + } + got[name] = value + } + for name, value := range want { + if got[name] != value { + return checkResult{Detail: fmt.Sprintf("row %s status is %q, want %q (table now: %v)", name, got[name], value, got)} + } + } + return checkResult{OK: true} + }, + }, + { + Id: "restructure-section", + Intent: "replace a subtree with different content", + Requires: []capability{capDeleteBlock, capAddBlocks}, + Markdown: "## Next steps\n" + + "- Ship the beta\n" + + "- Collect feedback\n" + + "- Write the report\n", + Prompt: func(fx *fixture) string { + return fmt.Sprintf("In the page titled %q, replace the three bullet points under Next steps with a single "+ + "paragraph reading exactly: Deferred to Q4. Keep the Next steps heading.", fx.Title) + }, + Check: func(doc *document, fx *fixture) checkResult { + if _, ok := doc.findBlock(func(b docBlock) bool { + return strings.HasPrefix(b.Type, "heading") && strings.TrimSpace(b.Text) == "Next steps" + }); !ok { + return checkResult{Detail: "the Next steps heading is gone: " + describeBlocks(doc)} + } + for _, b := range doc.Blocks { + if b.Type == "bulletedListItem" { + return checkResult{Detail: "a bullet survives: " + describeBlocks(doc)} + } + } + para, ok := doc.findBlock(func(b docBlock) bool { + return b.Type == "paragraph" && strings.TrimSpace(b.Text) == "Deferred to Q4." + }) + if !ok { + return checkResult{Detail: "no paragraph reads \"Deferred to Q4.\": " + describeBlocks(doc)} + } + _ = para + return checkResult{OK: true} + }, + }, + { + Id: "set-property", + Intent: "set a property value", + Requires: []capability{capSetProperties}, + Markdown: "## Scope\n" + + "Three vendors were compared on price and support.\n", + Prompt: func(fx *fixture) string { + return fmt.Sprintf("Set the description property of the page titled %q to exactly: Reviewed by the ops team.", fx.Title) + }, + Check: func(doc *document, fx *fixture) checkResult { + got, ok := doc.stringProperty("description") + if !ok { + return checkResult{Detail: fmt.Sprintf("no description property; properties: %v", propertyKeys(doc))} + } + if strings.TrimSpace(got) != "Reviewed by the ops team." { + return checkResult{Detail: fmt.Sprintf("description is %q", got)} + } + return checkResult{OK: true} + }, + }, + { + // the §7.5a sweep's coverage: every OTHER task names only + // single-word keys, which cannot tell a camelCase wire + // vocabulary from a snake_case one. This one can — a model that + // re-spells `due_date` back to `dueDate` from its training prior + // still succeeds (the fold layer forgives it), but a SERVER that + // stops advertising the slug fails the check. + Id: "set-multiword-property", + Intent: "set a bundled property whose key re-spells on the wire", + Requires: []capability{capSetProperties}, + Markdown: "## Scope\n" + + "The ops review is due at the end of the week.\n", + Prompt: func(fx *fixture) string { + return fmt.Sprintf("Set the due date of the page titled %q to 2026-08-01.", fx.Title) + }, + Check: func(doc *document, fx *fixture) checkResult { + got, ok := doc.stringProperty("due_date") + if !ok { + return checkResult{Detail: fmt.Sprintf("no due_date property; properties: %v", propertyKeys(doc))} + } + if !strings.HasPrefix(got, "2026-08-01") { + return checkResult{Detail: fmt.Sprintf("due_date is %q", got)} + } + return checkResult{OK: true} + }, + }, + { + Id: "read-then-edit", + Intent: "multi-step: a read is required before the edit is knowable", + Requires: []capability{capRead, capEditText}, + Markdown: "## Meeting notes\n" + + "Owner: Priya Raman\n" + + "Next review: 12 May\n", + Prompt: func(fx *fixture) string { + return fmt.Sprintf("The owner of the page titled %q has changed to Dana Whitfield. "+ + "Update the note so the Owner line names the new owner instead of the old one.", fx.Title) + }, + // the two lines of the fixture are ONE block: markdown without a + // blank line between them imports as a single paragraph holding a + // soft break. The check therefore reads LINES, not blocks — the + // first version compared whole block texts and failed a model that + // had done the task exactly right, which is the same defect class + // as running a task on a tier that cannot do it. + Check: func(doc *document, fx *fixture) checkResult { + if !containsLine(doc, "Owner: Dana Whitfield") { + return checkResult{Detail: "no line reads \"Owner: Dana Whitfield\": " + describeBlocks(doc)} + } + if strings.Contains(doc.allText(), "Priya Raman") { + return checkResult{Detail: "the old owner is still named"} + } + if !containsLine(doc, "Next review: 12 May") { + return checkResult{Detail: "collateral damage: the review line was changed"} + } + return checkResult{OK: true} + }, + }, + } +} + +// setupFixture creates one attempt's object. +func setupFixture(ctx context.Context, client *apiClient, spaceId string, t task) (*fixture, error) { + title := fixtureTitle() + id, err := client.createObject(ctx, spaceId, "page", title, t.Markdown) + if err != nil { + return nil, fmt.Errorf("create fixture for %s: %w", t.Id, err) + } + return &fixture{ObjectId: id, Title: title, Extra: map[string]string{}}, nil +} + +// titleSyllables are the pieces fixtureTitle builds a name out of: 15 +// consonants × 5 vowels, four syllables, always eight letters. +const ( + titleConsonants = "bdfgjklmnprstvz" + titleVowels = "aeiou" + titleSyllables = 4 +) + +// fixtureTitle mints one attempt's object name as a coined single-token +// codename — never a shared stem plus a nonce. +// +// The API has no object DELETE, so every attempt's fixture stays in the eval +// space forever, and search matches a query TOKEN-wise: measured against the +// live server, `find "Quarterly plan 84353d"` returned all five leftover +// "Quarterly plan …" notes, and `"Migration notes"` matched "Handover notes" +// on the word they share. Across one aborted run the same task's find went +// 1 → 2 → 3 matches, so a long run's success rate would decay for a reason +// that is the harness's, not the API's. A per-run space fixes only the +// cross-run half of that: a run makes ~17 fixtures per task itself. +// +// One coined token shares nothing with any other fixture. Two properties of +// the server's search make it exact, both measured rather than assumed: +// there is no fuzzy matching (Zafuriko and Zafurika each return only +// themselves) and there IS prefix matching (Zafurik returns both), so the +// names are fixed-length — one can be a prefix of another only by being +// equal. 75 syllables to the fourth is ~32M names. +// +// What this deliberately gives up: find becomes an unambiguous lookup. How a +// model picks among several plausible matches is a real question, and it is +// now a question a run has to ASK rather than one it answers by accident +// with whatever previous attempts left lying around. +func fixtureTitle() string { + raw := make([]byte, titleSyllables*2) + if _, err := rand.Read(raw); err != nil { + // a colliding title costs the run its find isolation, nothing else + nano := time.Now().UnixNano() + for i := range raw { + raw[i] = byte(nano >> (uint(i) * 8)) + } + } + name := make([]byte, 0, titleSyllables*2) + for i := 0; i < titleSyllables; i++ { + name = append(name, + titleConsonants[int(raw[i*2])%len(titleConsonants)], + titleVowels[int(raw[i*2+1])%len(titleVowels)]) + } + return strings.ToUpper(string(name[:1])) + string(name[1:]) +} + +// containsLine reports whether any block holds a LINE equal to want. A +// served block's text can carry soft breaks, so a whole-text comparison +// asks for a document shape the markdown importer does not produce. +func containsLine(doc *document, want string) bool { + for _, b := range doc.Blocks { + for _, line := range strings.Split(b.Text, "\n") { + if strings.TrimSpace(line) == want { + return true + } + } + } + return false +} + +// describeBlocks renders a document compactly for a failing check's detail. +func describeBlocks(doc *document) string { + var parts []string + for _, b := range doc.Blocks { + text := b.Text + if len(text) > 40 { + text = text[:40] + "…" + } + parts = append(parts, fmt.Sprintf("%s/%s:%q", b.Id, b.Type, text)) + } + return "[" + strings.Join(parts, " ") + "]" +} + +func propertyKeys(doc *document) []string { + keys := make([]string, 0, len(doc.Properties)) + for k := range doc.Properties { + keys = append(keys, k) + } + return keys +} diff --git a/cmd/apiv2eval/tasks_test.go b/cmd/apiv2eval/tasks_test.go new file mode 100644 index 0000000000..a24a9f9163 --- /dev/null +++ b/cmd/apiv2eval/tasks_test.go @@ -0,0 +1,359 @@ +package main + +import ( + "encoding/json" + "fmt" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/anyproto/anytype-heart/core/api/wrapper" +) + +func docFrom(t *testing.T, body string) *document { + t.Helper() + var doc document + require.NoError(t, json.Unmarshal([]byte(body), &doc)) + return &doc +} + +func taskById(t *testing.T, id string) task { + t.Helper() + for _, task := range tasks() { + if task.Id == id { + return task + } + } + t.Fatalf("no task %q", id) + return task{} +} + +// A check that cannot fail proves nothing, so every task is exercised on a +// document that satisfies it AND on documents that do not — including the +// near misses a model actually produces (the edit applied to the wrong +// block, the content added as plain text, the whole section rewritten). +func TestTaskChecks(t *testing.T) { + tests := []struct { + task string + name string + doc string + want bool + }{ + { + task: "edit-one-word", name: "the word is swapped and nothing else moved", want: true, + doc: `{"blocks":[{"id":"a","type":"heading2","text":"Summary"}, + {"id":"b","type":"paragraph","text":"Revenue target for Q4 is 1.2M."}, + {"id":"c","type":"heading2","text":"Owner"}, + {"id":"d","type":"paragraph","text":"The finance team reviews this monthly."}]}`, + }, + { + task: "edit-one-word", name: "untouched", want: false, + doc: `{"blocks":[{"id":"b","type":"paragraph","text":"Revenue target for Q3 is 1.2M."}, + {"id":"d","type":"paragraph","text":"The finance team reviews this monthly."}]}`, + }, + { + task: "edit-one-word", name: "the block was retyped and lost its tail", want: false, + doc: `{"blocks":[{"id":"b","type":"paragraph","text":"Revenue target for Q4"}, + {"id":"d","type":"paragraph","text":"The finance team reviews this monthly."}]}`, + }, + { + task: "edit-one-word", name: "Q4 added, Q3 left behind", want: false, + doc: `{"blocks":[{"id":"b","type":"paragraph","text":"Revenue target for Q4 is 1.2M."}, + {"id":"x","type":"paragraph","text":"Was: Revenue target for Q3 is 1.2M."}, + {"id":"d","type":"paragraph","text":"The finance team reviews this monthly."}]}`, + }, + { + task: "append-section", name: "heading plus two bullets after it", want: true, + doc: `{"blocks":[{"id":"a","type":"heading2","text":"Overview"}, + {"id":"b","type":"paragraph","text":"The migration runs in three stages."}, + {"id":"c","type":"heading2","text":"Risks"}, + {"id":"d","type":"bulletedListItem","text":"Vendor delay"}, + {"id":"e","type":"bulletedListItem","text":"Budget overrun"}]}`, + }, + { + task: "append-section", name: "added as paragraphs, not a heading and bullets", want: false, + doc: `{"blocks":[{"id":"a","type":"heading2","text":"Overview"}, + {"id":"b","type":"paragraph","text":"The migration runs in three stages."}, + {"id":"c","type":"paragraph","text":"Risks"}, + {"id":"d","type":"paragraph","text":"- Vendor delay"}, + {"id":"e","type":"paragraph","text":"- Budget overrun"}]}`, + }, + { + task: "append-section", name: "one bullet missing", want: false, + doc: `{"blocks":[{"id":"c","type":"heading2","text":"Risks"}, + {"id":"d","type":"bulletedListItem","text":"Vendor delay"}, + {"id":"b","type":"paragraph","text":"The migration runs in three stages."}]}`, + }, + { + task: "append-section", name: "the section replaced the document", want: false, + doc: `{"blocks":[{"id":"c","type":"heading2","text":"Risks"}, + {"id":"d","type":"bulletedListItem","text":"Vendor delay"}, + {"id":"e","type":"bulletedListItem","text":"Budget overrun"}]}`, + }, + { + task: "fill-table-cell", name: "one cell changed", want: true, + doc: `{"blocks":[{"id":"t","type":"table","columns":[{"id":"c1"},{"id":"c2"}],"rows":[ + {"id":"r1","isHeader":true,"cells":["Component","Status"]}, + {"id":"r2","cells":["Alpha","Done"]}, + {"id":"r3","cells":["Beta","Done"]}, + {"id":"r4","cells":["Gamma","Pending"]}]}]}`, + }, + { + task: "fill-table-cell", name: "the wrong row was filled", want: false, + doc: `{"blocks":[{"id":"t","type":"table","columns":[{"id":"c1"},{"id":"c2"}],"rows":[ + {"id":"r1","isHeader":true,"cells":["Component","Status"]}, + {"id":"r2","cells":["Alpha","Done"]}, + {"id":"r3","cells":["Beta","Pending"]}, + {"id":"r4","cells":["Gamma","Done"]}]}]}`, + }, + { + task: "fill-table-cell", name: "the table was rewritten as text", want: false, + doc: `{"blocks":[{"id":"p","type":"paragraph","text":"Alpha Done, Beta Done, Gamma Pending"}]}`, + }, + { + task: "fill-table-cell", name: "a cell block object satisfies the check too", want: true, + doc: `{"blocks":[{"id":"t","type":"table","columns":[{"id":"c1"},{"id":"c2"}],"rows":[ + {"id":"r1","isHeader":true,"cells":["Component","Status"]}, + {"id":"r2","cells":["Alpha","Done"]}, + {"id":"r3","cells":["Beta",{"type":"paragraph","text":"Done"}]}, + {"id":"r4","cells":["Gamma","Pending"]}]}]}`, + }, + { + task: "restructure-section", name: "bullets gone, paragraph in, heading kept", want: true, + doc: `{"blocks":[{"id":"h","type":"heading2","text":"Next steps"}, + {"id":"p","type":"paragraph","text":"Deferred to Q4."}]}`, + }, + { + task: "restructure-section", name: "paragraph added but the bullets survive", want: false, + doc: `{"blocks":[{"id":"h","type":"heading2","text":"Next steps"}, + {"id":"b1","type":"bulletedListItem","text":"Ship the beta"}, + {"id":"p","type":"paragraph","text":"Deferred to Q4."}]}`, + }, + { + task: "restructure-section", name: "the heading went with the bullets", want: false, + doc: `{"blocks":[{"id":"p","type":"paragraph","text":"Deferred to Q4."}]}`, + }, + { + task: "set-property", name: "description set exactly", want: true, + doc: `{"properties":{"name":"Vendor review ab12","description":"Reviewed by the ops team."},"blocks":[]}`, + }, + { + task: "set-property", name: "written into the body instead of the property", want: false, + doc: `{"properties":{"name":"Vendor review ab12"}, + "blocks":[{"id":"p","type":"paragraph","text":"Reviewed by the ops team."}]}`, + }, + { + task: "set-property", name: "close but not exact", want: false, + doc: `{"properties":{"description":"reviewed by ops"},"blocks":[]}`, + }, + { + task: "read-then-edit", name: "owner line rewritten, the rest intact", want: true, + doc: `{"blocks":[{"id":"h","type":"heading2","text":"Meeting notes"}, + {"id":"o","type":"paragraph","text":"Owner: Dana Whitfield"}, + {"id":"r","type":"paragraph","text":"Next review: 12 May"}]}`, + }, + { + // the shape the SERVER actually produces from the fixture markdown: + // two lines with no blank line between them are one paragraph with + // a soft break. The first check compared whole block texts and + // failed this document, which a model had edited exactly right. + task: "read-then-edit", name: "both lines in one block, as the importer makes it", want: true, + doc: `{"blocks":[{"id":"h","type":"heading2","text":"Meeting notes"}, + {"id":"o","type":"paragraph","text":"Owner: Dana Whitfield\nNext review: 12 May"}]}`, + }, + { + task: "read-then-edit", name: "one block, old owner still named", want: false, + doc: `{"blocks":[{"id":"o","type":"paragraph","text":"Owner: Priya Raman\nNext review: 12 May"}]}`, + }, + { + task: "read-then-edit", name: "the owner line was rewritten past recognition", want: false, + doc: `{"blocks":[{"id":"o","type":"paragraph","text":"The owner is now Dana Whitfield\nNext review: 12 May"}]}`, + }, + { + task: "read-then-edit", name: "new owner appended, old one left", want: false, + doc: `{"blocks":[{"id":"o","type":"paragraph","text":"Owner: Priya Raman"}, + {"id":"n","type":"paragraph","text":"Owner: Dana Whitfield"}, + {"id":"r","type":"paragraph","text":"Next review: 12 May"}]}`, + }, + { + task: "read-then-edit", name: "the review line was collateral damage", want: false, + doc: `{"blocks":[{"id":"o","type":"paragraph","text":"Owner: Dana Whitfield"}]}`, + }, + } + + for _, tt := range tests { + t.Run(tt.task+": "+tt.name, func(t *testing.T) { + // given + task := taskById(t, tt.task) + doc := docFrom(t, tt.doc) + + // when + got := task.Check(doc, &fixture{Title: "fixture", ObjectId: "obj1"}) + + // then + assert.Equal(t, tt.want, got.OK, "detail: %s", got.Detail) + if !tt.want { + assert.NotEmpty(t, got.Detail, "a failing check must say what it saw") + } + }) + } +} + +func TestTaskTableIsWellFormed(t *testing.T) { + seen := map[string]bool{} + for _, task := range tasks() { + t.Run(task.Id, func(t *testing.T) { + assert.False(t, seen[task.Id], "duplicate task id") + seen[task.Id] = true + assert.NotEmpty(t, task.Intent) + assert.NotEmpty(t, task.Markdown, "every task needs a fixture body") + assert.NotEmpty(t, task.Requires, "a task with no declared capability is gated by nothing") + require.NotNil(t, task.Prompt) + require.NotNil(t, task.Check) + + fx := &fixture{Title: fixtureTitle(), ObjectId: "obj1"} + prompt := task.Prompt(fx) + assert.Contains(t, prompt, fx.Title, "the prompt must name the object the model has to find") + assert.NotContains(t, prompt, "insert_blocks", "a prompt must not name the tool to use") + assert.NotContains(t, prompt, "edit_text", "a prompt must not name the tool to use") + + // the fixture body must not already satisfy the check, or the task + // would pass without the model doing anything + doc := docFromMarkdownApproximation(task.Markdown) + assert.False(t, task.Check(doc, fx).OK, "the fixture already satisfies the check") + }) + } +} + +// docFromMarkdownApproximation renders a fixture's markdown as the document +// it roughly becomes — enough to assert that the fixture does not already +// satisfy its own check. The real server parses the markdown; this only has +// to be faithful enough to fail. +func docFromMarkdownApproximation(markdown string) *document { + doc := &document{Properties: map[string]any{}} + inTable := false + table := docBlock{Id: "t", Type: "table"} + for _, line := range strings.Split(markdown, "\n") { + trimmed := strings.TrimSpace(line) + switch { + case trimmed == "": + continue + case strings.HasPrefix(trimmed, "|"): + cells := strings.Split(strings.Trim(trimmed, "|"), "|") + if strings.Contains(trimmed, "---") { + continue + } + row := docTableRow{Id: fmt.Sprintf("r%d", len(table.Rows)), IsHeader: !inTable} + for _, cell := range cells { + encoded, _ := json.Marshal(strings.TrimSpace(cell)) + row.Cells = append(row.Cells, encoded) + } + table.Rows = append(table.Rows, row) + inTable = true + case strings.HasPrefix(trimmed, "## "): + doc.Blocks = append(doc.Blocks, docBlock{Id: fmt.Sprintf("b%d", len(doc.Blocks)), Type: "heading2", Text: strings.TrimPrefix(trimmed, "## ")}) + case strings.HasPrefix(trimmed, "- "): + doc.Blocks = append(doc.Blocks, docBlock{Id: fmt.Sprintf("b%d", len(doc.Blocks)), Type: "bulletedListItem", Text: strings.TrimPrefix(trimmed, "- ")}) + default: + doc.Blocks = append(doc.Blocks, docBlock{Id: fmt.Sprintf("b%d", len(doc.Blocks)), Type: "paragraph", Text: trimmed}) + } + } + if len(table.Rows) > 0 { + doc.Blocks = append(doc.Blocks, table) + } + return doc +} + +// The gate is derived from the arm's published tool set, not from a +// hand-kept list of tiers: fill-table-cell ran on a small tier with no +// set_cell for a whole matrix, where the model recognised the limit, said so +// in plain words, and was scored as a failure six times over. +func TestCellsAreSkippedWhenTheArmPublishesNoToolForTheTask(t *testing.T) { + // given + require.NoError(t, checkTaskGating()) + arms, err := parseArms(strings.Join(allArms, ",")) + require.NoError(t, err) + + // when + cells, skipped, err := planCells([]string{"m"}, arms, tasks()) + require.NoError(t, err) + + reasons := map[cellKey]string{} + for _, s := range skipped { + reasons[cellKey{s.Model, s.Arm, s.Task}] = s.Reason + } + + // then — the two tasks the small tier has no tool for are skipped on + // every arm that serves that tier, and run everywhere else + for _, arm := range []string{armWrapperSmall, armEditTextA, armEditTextB1, armEditTextB2} { + assert.Contains(t, reasons[cellKey{"m", arm, "fill-table-cell"}], "publishes no set_cell", + "the small tier has no set_cell — the cell is not a measurement") + assert.Contains(t, reasons[cellKey{"m", arm, "restructure-section"}], "publishes no delete_block") + assert.True(t, cells[cellKey{"m", arm, "edit-one-word"}], "edit_text is served to the small tier") + } + assert.True(t, cells[cellKey{"m", armWrapperLarge, "fill-table-cell"}]) + assert.True(t, cells[cellKey{"m", armOps, "fill-table-cell"}], "the ops arm publishes set_cell") + assert.True(t, cells[cellKey{"m", armOps, "restructure-section"}]) + + // and the gate reads the tier table rather than restating it + assert.NotContains(t, wrapper.ToolNamesForTier(wrapper.TierSmall), "set_cell") + assert.NotContains(t, wrapper.ToolNamesForTier(wrapper.TierSmall), "delete_block") + assert.Contains(t, wrapper.ToolNamesForTier(wrapper.TierLarge), "set_cell") +} + +func TestEveryArmPublishesEveryToolItsCapabilitiesName(t *testing.T) { + // given — a capability that names no tool on a surface would skip cells + // silently, which reads exactly like a cell nobody wanted measured + require.NoError(t, checkTaskGating()) + + // then + for _, arm := range []armSpec{ + {name: armWrapperLarge, surface: surfaceWrapper, tier: wrapper.TierLarge}, + {name: armOps, surface: surfaceOps}, + } { + published := arm.publishedTools() + for c := range capabilityTools { + tool, err := capabilityTool(c, arm.surface) + require.NoError(t, err) + assert.Contains(t, published, tool, "%s should publish %s", arm.name, tool) + } + } +} + +func TestFixtureTitlesShareNoTokenAndNoPrefix(t *testing.T) { + // given — the API's search matches token-wise and prefix-matches the + // query, and fixtures can never be deleted, so a shared stem made find + // return one more object on every attempt of a run + seen := map[string]bool{} + + for i := 0; i < 500; i++ { + // when + title := fixtureTitle() + + // then + assert.NotContains(t, title, " ", "a title must be ONE search token") + assert.Len(t, title, titleSyllables*2, "fixed length: only an equal name can be a prefix of another") + assert.False(t, seen[title], "collision after %d titles", i) + seen[title] = true + } +} + +func TestArmParsing(t *testing.T) { + // when + arms, err := parseArms("wrapper/small,ops," + armEditTextB1) + + // then + require.NoError(t, err) + require.Len(t, arms, 3) + assert.Equal(t, wrapper.TierSmall, arms[0].tier) + assert.Equal(t, surfaceOps, arms[1].surface) + assert.Equal(t, editTextNoBlock, arms[2].variant) + assert.Equal(t, wrapper.TierSmall, arms[2].tier, "the A/B varies the surface, not the tier") + + _, err = parseArms("wrapper/medium") + require.Error(t, err) + assert.Contains(t, err.Error(), "wrapper/small") + assert.Contains(t, err.Error(), armEditTextB2) +} diff --git a/cmd/apiv2eval/toolset.go b/cmd/apiv2eval/toolset.go new file mode 100644 index 0000000000..dc8cbbf191 --- /dev/null +++ b/cmd/apiv2eval/toolset.go @@ -0,0 +1,450 @@ +package main + +// toolset.go — the two surfaces under test, both serving schemas the +// PRODUCT publishes rather than any the harness wrote: +// +// - the wrapper arm drives core/api/wrapper's real MCP server in-process +// over a pipe pair: tools/list gives the tier's schemas verbatim, and +// tools/call gives the model exactly the text (and isError framing) a +// real MCP host would show it, repair tips and all. +// - the ops arm serves the per-op schemas the API itself publishes at +// GET /v2/schemas/ops/{op} as the tools' parameters, and executes each +// call as a single-op PATCH. This is the surface the insert_blocks +// payload-id question lives on: the wrapper's add_blocks takes markdown +// and has no id channel at all, so only here can a model emit one. +// +// The one schema the harness authors is the ops arm's read_object: a GET has +// no published request-body schema to serve. It is flagged as such in the +// report. + +import ( + "bufio" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "sync" + + v2model "github.com/anyproto/anytype-heart/core/api/v2/model" + "github.com/anyproto/anytype-heart/core/api/wrapper" +) + +// toolSpec is one tool as the model sees it. +type toolSpec struct { + Name string + Description string + Parameters json.RawMessage +} + +// toolOutcome is one tool call's result as the model sees it. +type toolOutcome struct { + Text string + IsError bool +} + +// toolset is one arm's tool surface. +type toolset interface { + tools() []toolSpec + instructions() string + call(ctx context.Context, name string, args map[string]any) toolOutcome + close() error +} + +// +// ---- the wrapper arm: the product's MCP server, in process ---- +// + +// mcpToolset speaks MCP JSON-RPC to wrapper.MCPServer over a pipe pair. +type mcpToolset struct { + mu sync.Mutex + in *io.PipeWriter + out *bufio.Scanner + closer *io.PipeReader + nextId int + serveWG sync.WaitGroup + + toolList []toolSpec + instrText string +} + +// newMCPToolset starts the product's MCP server on an in-process pipe and +// completes the initialize / tools/list handshake. variant rewrites what the +// tool list PUBLISHES (see editTextVariant) and nothing else — the runner +// behind it is the same object in every arm. +func newMCPToolset(ctx context.Context, runner *wrapper.Runner, tier wrapper.Tier, variant editTextVariant) (*mcpToolset, error) { + inR, inW := io.Pipe() + outR, outW := io.Pipe() + server := wrapper.NewMCPServer(runner, tier) + ts := &mcpToolset{in: inW, closer: outR} + ts.out = bufio.NewScanner(outR) + ts.out.Buffer(make([]byte, 64<<10), 8<<20) + ts.serveWG.Add(1) + go func() { + defer ts.serveWG.Done() + err := server.Serve(context.Background(), inR, outW) + outW.CloseWithError(err) + }() + + initResp, err := ts.request(ctx, "initialize", map[string]any{ + "protocolVersion": "2025-06-18", + "capabilities": map[string]any{}, + "clientInfo": map[string]any{"name": "apiv2eval", "version": "1"}, + }) + if err != nil { + return nil, fmt.Errorf("mcp initialize: %w", err) + } + var initResult struct { + Instructions string `json:"instructions"` + } + if err := json.Unmarshal(initResp, &initResult); err != nil { + return nil, fmt.Errorf("decode mcp initialize result: %w", err) + } + ts.instrText = initResult.Instructions + + listResp, err := ts.request(ctx, "tools/list", map[string]any{}) + if err != nil { + return nil, fmt.Errorf("mcp tools/list: %w", err) + } + var list struct { + Tools []struct { + Name string `json:"name"` + Description string `json:"description"` + InputSchema json.RawMessage `json:"inputSchema"` + } `json:"tools"` + } + if err := json.Unmarshal(listResp, &list); err != nil { + return nil, fmt.Errorf("decode mcp tools/list result: %w", err) + } + for _, t := range list.Tools { + ts.toolList = append(ts.toolList, toolSpec{Name: t.Name, Description: t.Description, Parameters: t.InputSchema}) + } + varied, err := applyEditTextVariant(ts.toolList, variant) + if err != nil { + return nil, err + } + ts.toolList = varied + return ts, nil +} + +// +// ---- the published-surface variants: the edit_text A/B ---- +// + +// editTextVariant selects which edit_text definition an arm PUBLISHES. +// +// The hypothesis is §8.30's mechanism — a small model fills the fields a +// schema shows it, even when they are documented optional — measured on the +// one field these models demonstrably do emit. §8.30 removed `id` from +// insert_blocks on that argument and the 210-call probe could not confirm it +// (the models emit no payload id either way, so the control fired). `block` +// is different: gemma4:e2b supplied edit_text's documented-optional `block` +// on 3 of 3 attempts, reading the whole document first to obtain a value +// locateBlock would have derived from the snippet. +// +// The variants rewrite the tools/list ENTRY and nothing else. The same +// Runner, the same server-side locator (§8.43 — resolution moved down from +// the wrapper's locateBlock) and the same validateArgs table execute every +// call, so a `block` a model sends under B1 is still accepted and still +// works — the server's behaviour must be identical across arms or a +// difference between them is not attributable to the surface. What the model +// did with the field it was or was not shown is counted from the record +// (signals.EditTextWithBlock), never enforced by refusing it. +type editTextVariant string + +const ( + // editTextAsShipped is arm A: the definition the product serves today. + editTextAsShipped editTextVariant = "" + // editTextNoBlock is arm B1: `block` removed from the published + // arguments — from the schema and from the description that names it. + // Snippet-only location. + editTextNoBlock editTextVariant = "no-block" + // editTextProse is arm B2: `block` still published, with the instruction + // not to read first leading the description. + editTextProse editTextVariant = "prose" +) + +// editTextBlockSentence is the clause B1 removes from the shipped +// description. It is MATCHED, not assumed: if the product rewords it, the +// arm fails loudly rather than quietly publishing arm A's surface under +// B1's name and reporting the difference as a null result. +const editTextBlockSentence = "block is optional — when omitted, the snippet itself must pin down exactly one block. " + +// editTextNoReadFirst is B2's added lead, in the imperative-plus-reason +// register the manifest already uses ("Call this first — property keys and +// select option names must match exactly."). +const editTextNoReadFirst = "Do not read the object first — the find snippet locates the block on its own. " + +// applyEditTextVariant returns the tool list an arm publishes. +func applyEditTextVariant(specs []toolSpec, variant editTextVariant) ([]toolSpec, error) { + if variant == editTextAsShipped { + return specs, nil + } + out := make([]toolSpec, len(specs)) + copy(out, specs) + idx := -1 + for i, spec := range out { + if spec.Name == "edit_text" { + idx = i + break + } + } + if idx < 0 { + return nil, fmt.Errorf("the %q variant varies edit_text, which this arm does not publish", variant) + } + spec := out[idx] + switch variant { + case editTextProse: + spec.Description = editTextNoReadFirst + spec.Description + case editTextNoBlock: + trimmed := strings.Replace(spec.Description, editTextBlockSentence, "", 1) + if trimmed == spec.Description { + return nil, fmt.Errorf("edit_text's description no longer contains %q — the %q arm would publish the baseline surface under another name", + editTextBlockSentence, variant) + } + spec.Description = trimmed + params, err := removeSchemaProperty(spec.Parameters, "block") + if err != nil { + return nil, fmt.Errorf("build the %q edit_text schema: %w", variant, err) + } + spec.Parameters = params + default: + return nil, fmt.Errorf("unknown edit_text variant %q — variants: %q, %q, %q", + variant, editTextAsShipped, editTextNoBlock, editTextProse) + } + out[idx] = spec + return out, nil +} + +// removeSchemaProperty deletes one property from a published tool schema. +// A property that is not there is an error, not a no-op: the arm exists to +// publish a DIFFERENT surface, and silently publishing the same one is the +// failure mode that makes an A/B unreadable. The product renders its schemas +// from a Go map, so this re-marshal reproduces the same key order. +func removeSchemaProperty(schema json.RawMessage, name string) (json.RawMessage, error) { + var decoded map[string]any + if err := json.Unmarshal(schema, &decoded); err != nil { + return nil, fmt.Errorf("decode the published schema: %w", err) + } + properties, _ := decoded["properties"].(map[string]any) + if _, ok := properties[name]; !ok { + return nil, fmt.Errorf("the published schema has no %q property", name) + } + delete(properties, name) + if required, ok := decoded["required"].([]any); ok { + kept := make([]any, 0, len(required)) + for _, entry := range required { + if s, _ := entry.(string); s != name { + kept = append(kept, entry) + } + } + decoded["required"] = kept + } + data, err := json.Marshal(decoded) + if err != nil { + return nil, fmt.Errorf("encode the varied schema: %w", err) + } + return data, nil +} + +// request sends one JSON-RPC request and reads its response. +func (t *mcpToolset) request(ctx context.Context, method string, params any) (json.RawMessage, error) { + t.mu.Lock() + defer t.mu.Unlock() + t.nextId++ + msg := map[string]any{"jsonrpc": "2.0", "id": t.nextId, "method": method} + if params != nil { + msg["params"] = params + } + line, err := json.Marshal(msg) + if err != nil { + return nil, fmt.Errorf("encode mcp request: %w", err) + } + if _, err := t.in.Write(append(line, '\n')); err != nil { + return nil, fmt.Errorf("write mcp request: %w", err) + } + if !t.out.Scan() { + if err := t.out.Err(); err != nil { + return nil, fmt.Errorf("read mcp response: %w", err) + } + return nil, fmt.Errorf("mcp server closed the connection") + } + var resp struct { + Result json.RawMessage `json:"result"` + Error *struct { + Code int `json:"code"` + Message string `json:"message"` + } `json:"error"` + } + if err := json.Unmarshal(t.out.Bytes(), &resp); err != nil { + return nil, fmt.Errorf("decode mcp response: %w", err) + } + if resp.Error != nil { + return nil, fmt.Errorf("mcp error %d: %s", resp.Error.Code, resp.Error.Message) + } + return resp.Result, nil +} + +func (t *mcpToolset) tools() []toolSpec { return t.toolList } +func (t *mcpToolset) instructions() string { return t.instrText } + +func (t *mcpToolset) call(ctx context.Context, name string, args map[string]any) toolOutcome { + raw, err := t.request(ctx, "tools/call", map[string]any{"name": name, "arguments": args}) + if err != nil { + // a protocol error IS what the host would report to the model — an + // unknown tool name lands here, with the tier's tool list as the tip + return toolOutcome{Text: err.Error(), IsError: true} + } + var result struct { + Content []struct { + Text string `json:"text"` + } `json:"content"` + IsError bool `json:"isError"` + } + if err := json.Unmarshal(raw, &result); err != nil { + return toolOutcome{Text: fmt.Sprintf("decode tools/call result: %v", err), IsError: true} + } + var parts []string + for _, c := range result.Content { + parts = append(parts, c.Text) + } + return toolOutcome{Text: strings.Join(parts, "\n"), IsError: result.IsError} +} + +func (t *mcpToolset) close() error { + t.in.Close() + t.serveWG.Wait() + t.closer.Close() + return nil +} + +// +// ---- the ops arm: the published per-op schemas, executed as PATCH ---- +// + +// opsArmOps is the op set the ops arm serves — the document-editing ops a +// realistic edit reaches for, kept under the small-model tool cliff. The +// view and collection ops are out of scope for these tasks. +var opsArmOps = []string{ + "insert_blocks", + "replace_text", + "replace_subtree", + "update_block", + "move_block", + "delete_block", + "set_cell", + "set_properties", +} + +// readObjectSchema is the ONE harness-authored schema in the run: a GET has +// no published request-body schema to serve. +const readObjectSchema = `{"type":"object","additionalProperties":false,"properties":{"outline":{"type":"boolean","description":"true = structure only ({indent, id, type}); default false = the whole document"}}}` + +// opsToolset serves the published op schemas against one bound object. +type opsToolset struct { + client *apiClient + spaceId string + objectId string + specs []toolSpec +} + +func newOpsToolset(ctx context.Context, client *apiClient, spaceId, objectId string) (*opsToolset, error) { + ts := &opsToolset{client: client, spaceId: spaceId, objectId: objectId} + ts.specs = append(ts.specs, toolSpec{ + Name: "read_object", + Description: "Read the object being edited. Block ids come from here — use them exactly as served.", + Parameters: json.RawMessage(readObjectSchema), + }) + for _, op := range opsArmOps { + schema, example, err := client.opSchema(ctx, op) + if err != nil { + return nil, err + } + // the served example is a whole PATCH body, {"ops":[{…}]}, while the + // schema beside it describes ONE op — the example is not an instance + // of its own schema. The arm pairs the schema with the op inside, so + // a long run measures the schema rather than that mismatch; the + // mismatch itself is measured by -probe -probe-example (probe.go). + ts.specs = append(ts.specs, toolSpec{ + Name: op, + Description: fmt.Sprintf("PATCH op %q on the object being edited. Example: %s", op, unwrapOpsExample(example)), + Parameters: schema, + }) + } + return ts, nil +} + +func (t *opsToolset) tools() []toolSpec { return t.specs } + +func (t *opsToolset) instructions() string { + return "You edit ONE Anytype object through its HTTP API. Every tool but read_object is a single PATCH op applied to that object; " + + "its parameters are the API's own published schema for that op — follow it exactly. " + + "read_object returns the document: blocks are a flat array in order with an integer indent and an id. " + + "Use block ids exactly as a read served them. Errors name the field to fix; fix that field and retry once." +} + +func (t *opsToolset) call(ctx context.Context, name string, args map[string]any) toolOutcome { + if name == "read_object" { + var query url.Values + if outline, _ := args["outline"].(bool); outline { + query = url.Values{"outline": {"true"}} + } + path := "/v2/spaces/" + url.PathEscape(t.spaceId) + "/objects/" + url.PathEscape(t.objectId) + raw, err := t.client.call(ctx, http.MethodGet, path, query, nil, nil) + if err != nil { + return toolOutcome{Text: apiErrorText(err), IsError: true} + } + return toolOutcome{Text: string(raw)} + } + // The tool NAME already says which op it is, so the const is set from it + // — absent, wrong or right. This arm asks about the PAYLOAD; letting a + // mis-written discriminator 400 every call would answer a different + // question, and one that only exists because the arm splits the ops into + // separate tools (a raw HTTP caller has no tool name and must write the + // field). What the model wrote is preserved in the call record and + // counted separately — see signals.OpConstAbsent / OpConstWrong. + args["op"] = name + result, err := t.client.patchOps(ctx, t.spaceId, t.objectId, []any{args}) + if err != nil { + return toolOutcome{Text: apiErrorText(err), IsError: true} + } + return toolOutcome{Text: editReceipt(result)} +} + +func (t *opsToolset) close() error { return nil } + +// apiErrorText renders a server refusal the way a caller reading the HTTP +// response would: the C6 message followed by its path-addressed issues and +// hints. This is the raw surface — no wrapper vocabulary translation. +func apiErrorText(err error) string { + var ae *apiError + if !errors.As(err, &ae) { + return err.Error() + } + var b strings.Builder + fmt.Fprintf(&b, "%d %s: %s", ae.Status, ae.Code, ae.Message) + for _, issue := range ae.Issues { + b.WriteString("\n ") + if issue.Path != "" { + b.WriteString(issue.Path) + b.WriteString(": ") + } + b.WriteString(issue.Message) + if issue.Hint != "" { + b.WriteString(" (" + issue.Hint + ")") + } + } + return b.String() +} + +// editReceipt renders a successful PATCH the way the API answers it. +func editReceipt(result *v2model.EditResult) string { + data, err := json.Marshal(result) + if err != nil { + return "ok" + } + return "ok " + string(data) +} diff --git a/core/account.go b/core/account.go index b630bb9439..945106c97e 100644 --- a/core/account.go +++ b/core/account.go @@ -260,15 +260,31 @@ func (mw *Middleware) AccountChangeJsonApiAddr(ctx context.Context, req *pb.RpcA } } -func (mw *Middleware) AccountLocalLinkNewChallenge(ctx context.Context, request *pb.RpcAccountLocalLinkNewChallengeRequest) *pb.RpcAccountLocalLinkNewChallengeResponse { - info := getClientInfo(ctx) - info.Name = request.AppName - challengeId, err := mw.applicationService.LinkLocalStartNewChallenge(request.Scope, &info) - code := mapErrorCode(err, +// accountLocalLinkNewChallengeErrorCode is AccountLocalLinkNewChallenge's +// error mapping, extracted so the mapping itself is pinned by test: the +// challenge flow's §11.7 issuance guards (empty / over-long app name) join +// with application.ErrBadInput, and without the ErrBadInput row — which its +// sibling CreateApp always had — a pairing client saw code 1 UNKNOWN_ERROR +// ("something went wrong") instead of BAD_INPUT ("app name is required") +// for a permanent input mistake (review H2). +func accountLocalLinkNewChallengeErrorCode(err error) pb.RpcAccountLocalLinkNewChallengeResponseErrorCode { + return mapErrorCode(err, errToCode(session.ErrTooManyChallengeRequests, pb.RpcAccountLocalLinkNewChallengeResponseError_TOO_MANY_REQUESTS), errToCode(session.ErrChallengeAttemptsExceeded, pb.RpcAccountLocalLinkNewChallengeResponseError_TOO_MANY_REQUESTS), + // same rejected-scope error, same code as CreateApp — the two guards + // are a deliberate pair + errToCode(session.ErrInvalidScope, pb.RpcAccountLocalLinkNewChallengeResponseError_BAD_INPUT), + errToCode(walletComp.ErrInvalidGrant, pb.RpcAccountLocalLinkNewChallengeResponseError_BAD_INPUT), + errToCode(application.ErrBadInput, pb.RpcAccountLocalLinkNewChallengeResponseError_BAD_INPUT), errToCode(application.ErrApplicationIsNotRunning, pb.RpcAccountLocalLinkNewChallengeResponseError_ACCOUNT_IS_NOT_RUNNING), ) +} + +func (mw *Middleware) AccountLocalLinkNewChallenge(ctx context.Context, request *pb.RpcAccountLocalLinkNewChallengeRequest) *pb.RpcAccountLocalLinkNewChallengeResponse { + info := getClientInfo(ctx) + info.Name = request.AppName + challengeId, err := mw.applicationService.LinkLocalStartNewChallenge(request.Scope, &info, request.RequestedGrant) + code := accountLocalLinkNewChallengeErrorCode(err) return &pb.RpcAccountLocalLinkNewChallengeResponse{ ChallengeId: challengeId, @@ -301,6 +317,9 @@ func (mw *Middleware) AccountLocalLinkSolveChallenge(_ context.Context, req *pb. func (mw *Middleware) AccountLocalLinkCreateApp(_ context.Context, req *pb.RpcAccountLocalLinkCreateAppRequest) *pb.RpcAccountLocalLinkCreateAppResponse { appKey, err := mw.applicationService.LinkLocalCreateApp(req) code := mapErrorCode(err, + errToCode(session.ErrInvalidScope, pb.RpcAccountLocalLinkCreateAppResponseError_BAD_INPUT), + errToCode(walletComp.ErrInvalidGrant, pb.RpcAccountLocalLinkCreateAppResponseError_BAD_INPUT), + errToCode(application.ErrBadInput, pb.RpcAccountLocalLinkCreateAppResponseError_BAD_INPUT), errToCode(application.ErrApplicationIsNotRunning, pb.RpcAccountLocalLinkCreateAppResponseError_ACCOUNT_IS_NOT_RUNNING), ) return &pb.RpcAccountLocalLinkCreateAppResponse{ @@ -312,6 +331,22 @@ func (mw *Middleware) AccountLocalLinkCreateApp(_ context.Context, req *pb.RpcAc } } +func (mw *Middleware) AccountLocalLinkUpdateApp(_ context.Context, req *pb.RpcAccountLocalLinkUpdateAppRequest) *pb.RpcAccountLocalLinkUpdateAppResponse { + err := mw.applicationService.LinkLocalUpdateApp(req) + code := mapErrorCode(err, + errToCode(walletComp.ErrAppLinkNotFound, pb.RpcAccountLocalLinkUpdateAppResponseError_NOT_FOUND), + errToCode(walletComp.ErrInvalidGrant, pb.RpcAccountLocalLinkUpdateAppResponseError_BAD_INPUT), + errToCode(application.ErrBadInput, pb.RpcAccountLocalLinkUpdateAppResponseError_BAD_INPUT), + errToCode(application.ErrApplicationIsNotRunning, pb.RpcAccountLocalLinkUpdateAppResponseError_ACCOUNT_IS_NOT_RUNNING), + ) + return &pb.RpcAccountLocalLinkUpdateAppResponse{ + Error: &pb.RpcAccountLocalLinkUpdateAppResponseError{ + Code: code, + Description: getErrorDescription(err), + }, + } +} + func (mw *Middleware) AccountLocalLinkListApps(_ context.Context, req *pb.RpcAccountLocalLinkListAppsRequest) *pb.RpcAccountLocalLinkListAppsResponse { apps, err := mw.applicationService.LinkLocalListApps() code := mapErrorCode(err, diff --git a/core/account_test.go b/core/account_test.go new file mode 100644 index 0000000000..e27f600f8a --- /dev/null +++ b/core/account_test.go @@ -0,0 +1,51 @@ +package core + +import ( + "errors" + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/anyproto/anytype-heart/core/application" + "github.com/anyproto/anytype-heart/core/session" + "github.com/anyproto/anytype-heart/pb" +) + +// TestAccountLocalLinkNewChallengeErrorCode pins the challenge RPC's error +// mapping (review H2). The load-bearing row is application.ErrBadInput → +// BAD_INPUT: the §11.7 issuance guards (empty / over-long app name) join +// with that sentinel, and before the row existed the RPC answered code 1 +// UNKNOWN_ERROR — a pairing client branching on the code showed "something +// went wrong" instead of "app name is required" for a permanent input +// mistake, while its sibling CreateApp mapped the same error correctly. +func TestAccountLocalLinkNewChallengeErrorCode(t *testing.T) { + t.Run("the issuance guards' ErrBadInput maps to BAD_INPUT — the H2 row", func(t *testing.T) { + // the exact error shapes LinkLocalStartNewChallenge produces + for _, err := range []error{ + errors.Join(application.ErrBadInput, errors.New("app name is required")), + errors.Join(application.ErrBadInput, errors.New("app name exceeds 128 bytes")), + } { + assert.Equal(t, pb.RpcAccountLocalLinkNewChallengeResponseError_BAD_INPUT, + accountLocalLinkNewChallengeErrorCode(err), "error %v", err) + } + }) + + t.Run("the sibling rows still map", func(t *testing.T) { + assert.Equal(t, pb.RpcAccountLocalLinkNewChallengeResponseError_TOO_MANY_REQUESTS, + accountLocalLinkNewChallengeErrorCode(session.ErrTooManyChallengeRequests)) + assert.Equal(t, pb.RpcAccountLocalLinkNewChallengeResponseError_BAD_INPUT, + accountLocalLinkNewChallengeErrorCode(session.ErrInvalidScope)) + assert.Equal(t, pb.RpcAccountLocalLinkNewChallengeResponseError_ACCOUNT_IS_NOT_RUNNING, + accountLocalLinkNewChallengeErrorCode(application.ErrApplicationIsNotRunning)) + }) + + t.Run("an unrecognized error stays UNKNOWN", func(t *testing.T) { + assert.Equal(t, pb.RpcAccountLocalLinkNewChallengeResponseError_UNKNOWN_ERROR, + accountLocalLinkNewChallengeErrorCode(errors.New("boom"))) + }) + + t.Run("nil is NULL", func(t *testing.T) { + assert.Equal(t, pb.RpcAccountLocalLinkNewChallengeResponseError_NULL, + accountLocalLinkNewChallengeErrorCode(nil)) + }) +} diff --git a/core/api/APIV2.md b/core/api/APIV2.md index d8c24fd6f2..1e03356228 100644 --- a/core/api/APIV2.md +++ b/core/api/APIV2.md @@ -1,10 +1,41 @@ # Anytype Local API v2 — specification and phased plan -Status: **draft v0.3** · 2026-07-23 · GO-7383 follow-on -Depends on: AnyBlock JSON v1 flat (`pkg/lib/anyblockjson/SPEC.md` v0.6). -Evidence base: `docs/AgentApiV2Research.md` (+ Addendum A) and -`pkg/lib/anyblockjson/FLAT.md` — decisions cite sections there instead of -re-arguing. v1 (`/v1`) stays untouched for the deprecation window. +Status: **draft v0.4** · 2026-07-31 · GO-7383 follow-on +Depends on: AnyBlock JSON v1 flat (`pkg/lib/anyblockjson/SPEC.md` v0.7). +Evidence base: `docs/AgentApiV2Research.md` (+ Addendum A) — decisions cite +sections there instead of re-arguing. The flat-encoding rationale they drew +on now lives in `SPEC.md`; the implementation brief it came from was a build +artefact and has been removed. v1 (`/v1`) stays untouched for the +deprecation window. + +Changes from v0.3.5 (this refresh): three read-only reviews checked the +Phase-4/5 plan and the cross-cutting text against the shipped Phase-0–3 +surface; this version applies their findings. Phases 0–3 now read as fact +(resolver wiring, the referential validation layer and the `Options` +id-compaction split moved from build items to built; §8.2's +post-op-validation paragraph corrected — the whole-document net is ON by +default and rejecting, per review B′3; the idempotency hash and etag +comparison passages corrected to the shipped formulas). **Phase 4 was +replanned against the current primitives**: collections gained a read/query +path, the search body follows C2/C10 (`sorts`, query-param pagination), the +primary example is single-form, the former one-line "design deltas" are now +explicit rules (key scope without a type, a system-key allowlist, read-only +option resolution, per-space global semantics, the empty-date warning, +`type` as a filter pseudo-key), stored-view execution substitutes the SPEC +§6.2 dynamic placeholders, search is declared a read (exempt from C8/C9), +and the internal build-vs-reuse inventory is named. **Phase 5's §7 was +aligned with the shipped op set**: create-missing option names per R9, +`add`/`remove` on `set_properties`, a `check_item` tool over `update_block`, +markdown decided as an `insert_blocks` payload alternative, the reference +and editing channels qualified (full-read relabeling; D′1 markup caveat), +and the hard dependency order stated. §3/§4 refreshed so every gate is +still decidable (B1/B2 reworded — `replace_text`/`set_cell` and both filter +forms ship regardless). The SPEC §6.2.1 contradiction is **resolved**: the +filter grammar + parser ship now as a library +(`pkg/lib/anyblockjson/filterstring`) consumed by the API; only the +*document* view field `filter` stays reserved post-v1 (SPEC v0.7). One +Phase-1 route — `DELETE /objects` (archive) — was found never registered +and is re-marked [build], due before Phase 5. Changes from v0.2: applied the small-model (3–4B) review (`core/api/APIV2_REVIEW_SMALLMODEL.md`). The small-model contract is now a @@ -13,14 +44,14 @@ full REST API, identical to the Phase-5 CLI verb-set, exposed as CLI verbs and an on-device function-calling/MCP manifest. It picks agent-friendly *channels* the raw REST body can't (markdown-in for authoring, anchor-in for editing, enumerated handles for reference), which closes the review's three -structural findings. Consequently `replaceText` and `setCell` become +structural findings. Consequently `replace_text` and `set_cell` become **launch primitives** (the wrapper depends on them), the filter-string parser becomes a launch dependency, and the primary worked examples switch to single-op / single-filter forms. See §7 and the reweighted §2/§3/§4. Changes from v0.1 (carried): the 3-lens review (`core/api/APIV2_REVIEW.md`, R1–R15) — block ids full on edit reads (R1); `revision` → advisory `etag` -(R2); `inside` indent + `updateBlock` (R3/R4); post-op validity normative +(R2); `inside` indent + `update_block` (R3/R4); post-op validity normative (R5); build-items marked (R6); `type` everywhere (R7); C13 strict schemas (R8); `/validate` split (R9); sets path (R10); files/spaces/members/archive (R11); read matrix (R12); benchmarks (R13); op gaps (R14); errors (R15). @@ -38,14 +69,14 @@ repair loop with path-addressed errors. | # | Convention | Rationale (research ref) | |---|---|---| | C1 | Base path `/v2`, localhost, bearer auth and `Anytype-Version` date header as in v1. | migration §4.8 | -| C2 | **One vocabulary: the format's.** camelCase, property **keys**, option **names** — everywhere, both directions. The object-type field is **`type`** (a type key) on every surface: envelope, rows, search, shortcuts. No id/key duality, no snake_case. Object ids remain ids. | v1's top agent trap (§2.1) | -| C3 | **Compact JSON always** (no pretty-printing). | free 38–46% (§3.6) | -| C4 | **Object ids are never compacted** — they are written in full, on every shape. The refs legend that used to shorten them is deleted from the format (SPEC §9a, v0.20): this API measured a **net token LOSS of 0.9–11.5% per document** from it, and it trapped write-back — an agent editing an object-valued property through a label has to keep the legend in step, and one that regenerates the document without it silently re-points every reference. The format's freeze review measured the same loss from the other end (a 200-item collection **+32.7%**). What is compacted is **block ids**: `?ids=compact` (the default read) relabels them to 5-char doc-local labels, which is safe precisely because that relabeling carries **no legend** — a label is a placeholder inside its own document, and write endpoints resolve one by unique-suffix match against the live object (SPEC §9a wiring allowance). `?ids=full` opts out and is the shape whose bytes re-import to the same document (the export/backup shape). Never require echoing a full CID. | ~24×/id, −89% id errors (§3.6); id round-trip contract (R1) | +| C2 | **One vocabulary, and it is the transport's: `snake_case`** (revised §8.46 — the original read "the format's, camelCase", which two later decisions had already contradicted). Every name **v2 itself owns** is snake_case, in every layer and both directions: path segments and path params (`/v2/spaces/{space_id}/objects/{object_id}`), query params (`dry_run`, `ids`), response and request body fields (`space_id`, `author_id`, `diff_stats`, `created_blocks`, `last_state_id`), and the PATCH op names (`set_properties`, `replace_text`, `insert_blocks`, …). Addressing is still **by name, never by id**: property **keys** and option **names**, no id/key duality, and the object-type field is **`type`** (a type key) on every surface — envelope, rows, search, shortcuts. Object ids remain ids. **The version lives in the path (`/v2`), never in a name** — no `v2_` prefix on a schema, an operationId, an op or a field. **Boundary:** the AnyBlock document is the FORMAT's vocabulary, not v2's — whatever SPEC spells inside `blocks`/`properties` crosses through unchanged and v2 never re-spells it (the query surface's `mimeType`/`size` field aliases are the format's names too, and follow it). | v1's top agent trap (§2.1); the Wave 1.3 property slugs (ADDRESSING §7.5a-4) | +| C3 | **Compact JSON always** (no pretty-printing) — **all the way down, not just the envelope**. `anyblockjson.Marshal` returns the format's canonical byte form, which is two-space **indented** (SPEC §4), and the v2 envelope re-embeds those bytes verbatim; until Wave 0.1 every object read was therefore compact on top and pretty-printed underneath, costing a measured 16–26 %. *(Built: `encodeEnvelope` compacts each embedded value — the serving layer, so the format's canonical form and its `Export ∘ Import` byte-stability are untouched; §8.24.)* | free 38–46% (§3.6); 16–26% (TOKENS §1.1) | +| C4 | **Two document shapes, one id axis** (revised Wave 0.2, hardened §8.26; TOKENS §1.2/§10). `?ids=compact` (**the default — the *edit* shape**): **machine-minted** block/row/column/view ids — 24-hex bson and view UUIDs, `isMintedLocalId` — relabel to their 5-char suffixes (legend-less, **lossy**); every id that could carry meaning (`dataview`, `title`, readable imported ids) keeps its full spelling and is **reserved**, so no label can alias a served id. `?ids=full` (**the *export* shape**): full ids everywhere — the **backup/export** read (§3(b)), and the read to clone from when a POST should reuse the source's real ids. Object refs are **full inline on every shape**: the `refs` legend was a measured net loss **on the measured corpus** (85–90 % of refs used once; §1.2's own model has it winning only at ≥2× reuse) and its indirection trapped write-back, so no shape serves one — but legend **resolution on input stays total** (SPEC §9a), so a document arriving with a legend still resolves. Every write channel resolves a block/view/row/column id by exact id **or unique suffix** (`matchBlockRef`), which is what makes the lossy edit shape addressable — **in payload slots as well as reference slots since §8.29**, so no channel takes an id **literally**, which is what made the compact shape a trap (§8.26). *(§8.27 claimed this was already true once PUT was gone. It was not: PATCH resolved `update_block.id`, `replace_subtree.id`, the targeting refs and the table/view refs, but handed `replace_subtree.blocks[].id`, `update_block.set.{rows,columns,views}[].id` and `set_cell.value[].id` to the format importer verbatim — reproduced as permanent id corruption on the documented read-then-echo loop.)* A payload id resolving to nothing is refused, not minted over; omitting it is how new content is authored. Never require echoing a full CID. *(Built: `Options.CompactBlockLabels` composed by `objectReadPlan`; `Options.CompactObjectRefs` remains a format-package option no API shape sets. Wave 2 renames the two values to `?mode=edit\|full` with no change of bytes.)* **Outline exception (T7)**: the outline fixes the axis — short labels — and ignores `?ids=`. | ~24×/id, −89% id errors (§3.6); block labels −19…−22% on minted-id documents, the legend a net **loss** of 0.9–11.5% on the measured corpus (TOKENS §1.2, live-measured); id round-trip contract (R1) | | C5 | Minimal rows: list/search responses carry `id, name, type` + requested property values. **Never embed type objects.** `fields=` expands. | v1's N× multiplier (§2.1) | -| C6 | Error shape everywhere: `{status, code, message, issues:[{path, message, hint}]}` — path-addressed, naming allowed values. Required codes include: `validation_failed`, `version_unsupported` (surfaces SPEC §10's "produced by a newer version" verbatim, naming both versions), `idempotency_conflict` (same key, different body), `etag_mismatch`, `ambiguous_input` (e.g. both `filter` and `filters` supplied). Error text is API surface; test it. | repair loop (§3.2, §4.6); R15 | -| C7 | Every object read returns **`etag`** (short opaque token, ≤8 chars, derived from tree heads — NOT the object's `revision` property, which stays in `properties`) plus an `ETag` header. Mutations accept **`If-Match` header only** (the AnyBlock body has no envelope slot for it). **Advisory by default**: without `If-Match`, ops apply last-write-wins and `diffStats` reports the outcome; with it, mismatch → 409 `etag_mismatch` carrying the current etag. Note: the etag advances on background sync, not only on agent edits — strict If-Match will 409 on sync noise; block-scoped preconditions (ops apply iff the *addressed* blocks are unchanged) are the deferred v2.x refinement. | R2; optimistic concurrency (§3.2) | -| C8 | `Idempotency-Key` honored on all POSTs; replay with the same key returns the stored result; same key with a different body → 409 `idempotency_conflict`. Response always returns created ids. | agent auto-retry (§3.7); R15 | -| C9 | `?dry_run=true` on every mutation → would-be diff summary + issues, nothing committed. | highest-leverage affordance (§3.7) | +| C6 | Error shape everywhere: `{status, code, message, issues:[{path, message, hint}]}` — path-addressed, naming allowed values. Required codes include: `validation_failed`, `version_unsupported` (surfaces SPEC §10's "produced by a newer version" verbatim, naming both versions), `idempotency_conflict` (same key, different body), `etag_mismatch`, `ambiguous_input` (e.g. both `filter` and `filters` supplied), `forbidden` (403 — an operation the caller's identity may not perform, e.g. editing another member's chat message; added by the Phase-6 review; also the `/v2` key-scope gate's refusal of a non-JsonAPI key, which answers in the shared v1 envelope — §8.9). Error text is API surface; test it. | repair loop (§3.2, §4.6); R15 | +| C7 | Every object read returns **`etag`** (short opaque token, ≤8 chars, derived from tree heads — NOT the object's `revision` property, which stays in `properties`) plus an `ETag` header. Mutations accept **`If-Match` header only** (the AnyBlock body has no envelope slot for it). **Advisory by default**: without `If-Match`, ops apply last-write-wins and `diff_stats` reports the outcome; with it, mismatch → 409 `etag_mismatch` carrying the current etag. Note: the etag advances on background sync, not only on agent edits — strict If-Match will 409 on sync noise; block-scoped preconditions (ops apply iff the *addressed* blocks are unchanged) are the deferred v2.x refinement. | R2; optimistic concurrency (§3.2) | +| C8 | `Idempotency-Key` honored on all mutations (POST, PATCH, DELETE — v0.3.5/Phase 6; was POST-only); replay with the same key returns the stored result; same key with a different body → 409 `idempotency_conflict`. Response always returns created ids. | agent auto-retry (§3.7); R15 | +| C9 | `?dry_run=true` on every mutation → would-be diff summary + issues, nothing committed. The response's `dry_run` echo is spelled exactly like the query parameter it answers. *(This was recorded as a C2 carve-out while C2 said camelCase; since §8.46 it is simply the rule, and the carve-out is gone.)* | highest-leverage affordance (§3.7) | | C10 | Pagination on **every** list surface — objects, search, and the discovery lists (types, properties, **options**): default `limit=25`, `has_more`, truncation messages steer ("312 matches — narrow with filter…"). Options lists take a `prefix=` filter (tag-like properties can hold thousands of options). | Linear/AXI (§3.4, §3.7); R-minor | | C11 | Reads never fail on unknown *content*; anything a representation cannot express is listed in `warnings` (array of the C6 issue shape, warning-grade). Writes never pass through a lossy representation. | ADF disaster (§3.3) | | C12 | Every endpoint documents **one worked example + its JSON Schema**, embedded in OpenAPI and fetchable (§5 discovery). | examples 72→90% (§3.4) | @@ -71,9 +102,8 @@ that does not exist yet and must be built (not assumed). against a scratch space, scores **apply-success, corruption (round-trip backtranslation, DELEGATE-52 method), output tokens, turns**. Task set: append paragraph · edit one word · toggle a checkbox · - restructure a section · fill a table cell (expressed at launch as - whole-`table` `replaceBlock` — R4) · create task with properties · build - a set with filter. Model tiers: small (3–8B local), mid (Haiku-class), + restructure a section · fill a table cell (`set_cell`) · create task with + properties · build a set with filter. Model tiers: small (3–8B local), mid (Haiku-class), frontier. **The small tier runs under grammar-constrained decoding** (XGrammar-class) — without that floor, 3–8B loops produce null data and gates are undecidable (R13; §3.5 evidence). @@ -81,22 +111,22 @@ that does not exist yet and must be built (not assumed). ### Phase 1 — read ``` -GET /v2/spaces/{spaceId}/objects/{objectId} +GET /v2/spaces/{space_id}/objects/{object_id} ?include=properties,blocks # subset; default both ?outline=true # block skeleton, see below ?block={blockId} # subtree only (contiguous indent-run) - ?ids=compact|full # BLOCK ids (C4); default compact. + ?ids=compact|full # document shape (C4); default compact (edit) # object ids are always full — never compacted ?format=anyblock|md # md read-only, with warnings (C11) -GET /v2/spaces/{spaceId}/objects # minimal rows (C5) -DELETE /v2/spaces/{spaceId}/objects/{objectId} # archive (v1 parity); ?permanent=true later +GET /v2/spaces/{space_id}/objects # minimal rows (C5) +DELETE /v2/spaces/{space_id}/objects/{object_id} # archive, OWN OUTPUT ONLY (creator provenance, §8.42); ?permanent=true later GET /v2/spaces # spaces list (read) -GET /v2/spaces/{spaceId}/members # members list (read) — agents need member ids for assignee/creator values -GET /v2/spaces/{spaceId}/types # keys + names (paginated, C10) -GET /v2/spaces/{spaceId}/types/{type} # the kind:"objectType" AnyBlock document -GET /v2/spaces/{spaceId}/types/{type}/schema?flavor=json-schema|table|example [build] -GET /v2/spaces/{spaceId}/properties # key, name, format (paginated) -GET /v2/spaces/{spaceId}/properties/{key}/options # option names (+color), paginated + prefix= +GET /v2/spaces/{space_id}/members # members list (read) — agents need member ids for assignee/creator values +GET /v2/spaces/{space_id}/types # keys + names (paginated, C10) +GET /v2/spaces/{space_id}/types/{type} # the kind:"objectType" AnyBlock document +GET /v2/spaces/{space_id}/types/{type}/schema?flavor=json-schema|table|example [build] +GET /v2/spaces/{space_id}/properties # key, name, format (paginated) +GET /v2/spaces/{space_id}/properties/{key}/options # option names (+color), paginated + prefix= ``` - **`outline=true` returns the full block skeleton**: every block's @@ -107,18 +137,31 @@ GET /v2/spaces/{spaceId}/properties/{key}/options # option names (+color), pa - **Param legality** (R12): `outline` and `block` are mutually exclusive with each other and with `format=md`; `outline` implies blocks (an accompanying `include=properties` adds the properties map; `include` - without blocks suppresses `blocks` entirely); `ids` affects any shape - that contains object ids; illegal combinations → 400 `ambiguous_input` - naming the conflicting params. The outline shape uses compact block - labels (read-only shape, C4). + without blocks suppresses `blocks` entirely); `ids` selects the whole + document shape (C4: `compact` = the edit read, `full` = the export + read); illegal combinations → 400 `ambiguous_input` naming the + conflicting params. The outline shape uses compact block labels but + **full object refs** (read-only shape; the C4 outline exception — + `ids` is ignored there), which since Wave 0.2 is also what the default + and subtree reads emit, so a block id never changes spelling between + an outline, a `?block=` and a default read. - The object response is the flat AnyBlock document + `etag` (+ `warnings`). - `types/{type}/schema` **[build]**: the derived artifact (SPEC §2a `GenerateSchema` — *planned there, not implemented*; this endpoint is its first consumer). `table` flavor = prompt-ready property table with live option names — requires an objectstore join per select property - (options live on option objects, not in `typeProperties`) (R6). Still - Phase 1: it is the highest-leverage accuracy lever (§3.4). + (options live on option objects, not in `typeProperties`) (R6). **As + shipped, the route is a 501 `not_implemented` stub** (no flavor parsing) + steering to `GET types/{type}`; the `GenerateSchema` artifact + + store-backed option join stays an open §3 build item, **due before the + wrapper's `describe` tool (Phase 5)**. It remains the highest-leverage + accuracy lever (§3.4). +- **`DELETE /objects` (archive) is an open build item**: the route was + specced with Phase 1 but never registered — v2 DELETE exists only for + types and properties; object archive is v1-only today. §6's v1-parity + note depends on it; build it **before Phase 5** so the wrapper/CLI can + carry an object-archive verb (§2 Phase 5). - Implementation note: read via the **live smartblock state** → snapshot → `anyblockjson.Marshal` (not `ObjectShow`, whose ObjectView is the wrong type; not the store snapshot, which lags). Derive `etag` from @@ -127,16 +170,16 @@ GET /v2/spaces/{spaceId}/properties/{key}/options # option names (+color), pa ### Phase 2 — create (one-shot) ``` -POST /v2/spaces/{spaceId}/objects # body: AnyBlock document (ids optional — id-less input, SPEC §9) +POST /v2/spaces/{space_id}/objects # body: AnyBlock document (ids optional — id-less input, SPEC §9) # or shortcut {type, name, properties, markdown} -POST /v2/spaces/{spaceId}/types # kind:"objectType" doc — typeProperties creates missing properties -POST /v2/spaces/{spaceId}/properties # {key?, name, format, options?:[{name,color?}]} -POST /v2/spaces/{spaceId}/sets # {name, type, filters|filter, sorts?, views?} -POST /v2/spaces/{spaceId}/collections # {name, items?} -POST /v2/spaces/{spaceId}/templates # AnyBlock doc with templateFor → generic object-create path -POST /v2/spaces/{spaceId}/files # upload (multipart or URL) → file object id -PATCH/DELETE /v2/spaces/{spaceId}/types/{type} # update (type doc semantics) / archive -PATCH/DELETE /v2/spaces/{spaceId}/properties/{key} # update / archive +POST /v2/spaces/{space_id}/types # kind:"objectType" doc — typeProperties creates missing properties +POST /v2/spaces/{space_id}/properties # {key?, name, format, options?:[{name,color?}]} +POST /v2/spaces/{space_id}/sets # {name, type, filters|filter, sorts?, views?} +POST /v2/spaces/{space_id}/collections # {name, items?} +POST /v2/spaces/{space_id}/templates # AnyBlock doc with templateFor → generic object-create path +POST /v2/spaces/{space_id}/files # upload (multipart or URL) → file object id +PATCH/DELETE /v2/spaces/{space_id}/types/{type} # update (type doc semantics) / archive +PATCH/DELETE /v2/spaces/{space_id}/properties/{key} # update / archive ``` - `POST /objects` body discriminator (R7): presence of `version` or @@ -152,13 +195,14 @@ PATCH/DELETE /v2/spaces/{spaceId}/properties/{key} # update / archive - **Templates**: no create-from-body RPC exists; `POST /templates` targets the generic AnyBlock create path (Template kind + `templateFor`), which the importer already supports (R-note). -- **Resolver wiring is the substance of this phase [build]**: the +- **Resolver wiring was the substance of this phase** (shipped: + `core/api/v2/service/resolver.go`, `creatingResolvers`): the create-missing property/option bridging from `anyblockjson`'s `PropertyResolver`/`OptionResolver` to objectstore + - `ObjectCreateRelation`/`ObjectCreateRelationOption` does not exist yet - (R-note). The **referential validation layer** (R9) lands here: a set - filter naming a property the type lacks → error listing the type's - actual keys. + `ObjectCreateRelation`/`ObjectCreateRelationOption`. The **referential + validation layer** (R9) landed here too: a set filter naming a property + the type lacks errors listing the type's actual keys (policy as built: + §8.1 "Create-vs-reject policy"). - Every kind: schema + worked example (C12/C13); `Idempotency-Key`; `dry_run`. @@ -169,77 +213,113 @@ Two modes at launch, one gated addition, additive extensions later. **Normative rule first (R5):** the post-op document must satisfy the format's semantic checks (SPEC §12, V1–V5 — monotonicity, leaf containment, row→column, bounds, id uniqueness). Any violation rejects the **whole -PATCH** with path-addressed errors (`ops[i]` + block path). `moveBlock` -into the moved block's own subtree is a cycle → error. `replaceBlock` / -`updateBlock` changing a parent's type to a leaf type while descendants -exist → error naming the descendant count. +PATCH** with path-addressed errors (`ops[i]` + block path). `move_block` +into the moved block's own subtree is a cycle → error. `update_block` +changing a parent's type to a leaf type while descendants exist → error +naming the descendant count. -**(a) `PATCH /v2/spaces/{spaceId}/objects/{objectId}` — batched ops (default path)** +**(a) `PATCH /v2/spaces/{space_id}/objects/{object_id}` — batched ops (default path)** ```json { "ops": [ - { "op": "setProperties", "set": { "status": ["Done"] }, "unset": ["oldKey"] }, - { "op": "updateBlock", "id": "b5", "set": { "checked": true } }, - { "op": "replaceBlock", "id": "b3", "block": { "type": "paragraph", "text": "new **text**" } }, - { "op": "replaceSubtree","id": "b7", "blocks": [ { "type": "bulletedListItem", "text": "a" }, + { "op": "set_properties", "set": { "status": ["Done"] }, "unset": ["oldKey"] }, + { "op": "update_block", "id": "b5", "set": { "checked": true } }, + { "op": "update_block", "id": "b3", "set": { "text": "new **text**" } }, + { "op": "replace_subtree","id": "b7", "blocks": [ { "type": "bulletedListItem", "text": "a" }, { "indent": 1, "type": "paragraph", "text": "b" } ] }, - { "op": "insertBlocks", "after": "b3", "blocks": [ { "type": "checkbox", "text": "todo" } ] }, - { "op": "moveBlock", "id": "b9", "inside": "b2", "position": "last" }, - { "op": "deleteBlock", "id": "b4", "recursive": true } + { "op": "insert_blocks", "after": "b3", "blocks": [ { "type": "checkbox", "text": "todo" } ] }, + { "op": "move_block", "id": "b9", "inside": "b2", "position": "last" }, + { "op": "delete_block", "id": "b4", "recursive": true } ] } ``` - Closed op set, id-addressed, atomic (one `state.Apply` per request), no positional/index/offset addressing anywhere (§3.1–3.2). -- **`updateBlock` (R4)**: merge semantics — only the fields in `set` - change; everything else (including `text`) is untouched. The op for - checkbox toggles, color/align changes, language switches. - **`replaceBlock` replaces the whole block** (absent `text` = empty text - per SPEC §4 — resend `text` or use `updateBlock`); descendants kept. - `replaceSubtree` swaps block + descendants for the payload run. +- **`update_block` (R4)**: THE one block-update op (v0.3.5 — `replaceBlock` + removed). Merge semantics — only the fields in `set` change; everything + else (including `text`) is untouched; explicit `null` clears a field. The + op for checkbox toggles, color/align changes, language switches, retypes + and text rewrites alike. `replace_subtree` swaps block + descendants for + the payload run. - **Relative indent in payloads** (R3): for `after`/`before` and - `replaceSubtree`, payload `indent: 0` = the anchor's level. For + `replace_subtree`, payload `indent: 0` = the anchor's level. For `inside`, payload `indent: 0` = **the container's child level** (anchor + 1). Worked examples: - sibling-insert — `{"op":"insertBlocks","after":"b3","blocks":[{"type":"paragraph","text":"same level as b3"}]}`; - child-insert — `{"op":"insertBlocks","inside":"b3","position":"last","blocks":[{"type":"paragraph","text":"child of b3"},{"indent":1,"type":"paragraph","text":"grandchild"}]}`. -- **`moveBlock`** takes the same targeting as `insertBlocks`: + sibling-insert — `{"op":"insert_blocks","after":"b3","blocks":[{"type":"paragraph","text":"same level as b3"}]}`; + child-insert — `{"op":"insert_blocks","inside":"b3","position":"last","blocks":[{"type":"paragraph","text":"child of b3"},{"indent":1,"type":"paragraph","text":"grandchild"}]}`. +- **`move_block`** takes the same targeting as `insert_blocks`: `after`/`before`/`inside`+`position: first|last` — so reorder-to-slot, indent (`inside` previous sibling), and outdent (`after` the parent) are all expressible (R14). -- **`deleteBlock`**: `recursive` defaults to false; deleting a block that +- **Root targeting (v0.3.5)**: omitting all of `after`/`before`/`inside` on + `insert_blocks` and `move_block` appends at the **end of the document root**. + This is the ops-path into an empty object: SPEC §7 keeps title/description + out of the document, so a fresh object has zero addressable blocks and an + anchor-required contract left PUT as the only way to give it content — the + corruption vector the design steers agents away from. It also backs the §7 + wrapper's `add_blocks(object, after?, markdown)` omitted-`after` case. + Payload `indent: 0` = the document's top level; `position` names an end of + the document when nothing else is targeted — `first` the start, `last` (or + absent) the end (§8.32; the original shape had no root-prepend and refused + `position` here at all). +- **`delete_block`**: `recursive` defaults to false; deleting a block that has descendants without `recursive:true` → error naming the descendant - count (R14). -- **`setProperties`** (R14): `set` writes presence — `"k": []` means + count and the resolved block id (R14). +- **`match` — the id alternative on `update_block` and `delete_block`** + (Wave 2.1b, §8.45): an exact substring of the block's text, which must + appear in exactly ONE block or the op refuses — the same rule + `replace_text`'s `find` follows (§8.43), resolved per-op against the live + document view under the object lock. + `{"op":"update_block","match":"Draft timeline","set":{"checked":true}}`. + Give `id` or `match`, **never both** — the combination is refused rather + than ranked, and giving neither is refused too. Repeats *within* the one + matched block are fine: `match` addresses a block, not an occurrence. +- **`set_properties`** (R14): `set` writes presence — `"k": []` means present-but-empty (SPEC §3 presence-is-meaningful); `unset` removes presence. Output-only properties (SPEC §4a) are rejected with a path-addressed error. -- Collection ops: `addItems` / `removeItems` (member ids). +- **`set_properties` `add`/`remove` (v0.3.5)**: per-key list edits for + list-shaped formats only (select, multiSelect, objects, files — SPEC §3). + `{"op":"set_properties","add":{"tags":["urgent"]},"remove":{"assignee":["bafy…"]}}` + — `add` appends entries without duplicating existing ones; `remove` + deletes matching entries and is a no-op when absent (never creates + presence, never creates the option it names). Scalar-format keys are + rejected with a path-addressed error naming the format. A key may appear + in at most one of `set`/`unset`/`add`/`remove` per op. Rationale: + appending one tag to a 40-entry multiSelect used to require read → + whole-array rewrite → write — the corruption pattern in miniature, plus a + token tax; collections already had `add_items`/`remove_items`. +- Collection ops: `add_items` / `remove_items` (member ids). - Block-id references accept full ids (canonical) and unique-suffix labels (lenient, C4). - Response: new `etag`, created-block id map **keyed by payload position** (`ops[3].blocks[0] → "b1a2…"`; client-supplied ids are echoed as-is) - (R14), `diffStats`. -- **`diffStats` schema**: `{blocksAdded, blocksRemoved, blocksChanged, - blocksMoved, propertiesChanged}` (integers). + (R14), `diff_stats`. +- **`diff_stats` schema**: `{blocks_added, blocks_removed, blocks_changed, + blocks_moved, properties_changed}` (integers). - Implementation: build child state from live state, apply ops via `simple.Block`/`state.State` mutations, one `sb.Apply` — the pattern Block* RPC handlers use internally (research §2.3; feasibility-verified). -**(b) `PUT /v2/spaces/{spaceId}/objects/{objectId}` — full-document replace (escape hatch)** +**(b) There is no full-document replace — removed §8.27** + +`PUT /v2/spaces/{space_id}/objects/{object_id}` existed through the Phase-3 +hardening and is **gone**, with its whole pipeline: the route, the handler, +`PutObject`/`putPipeline`/`normalizePutBody`/`checkPutBlockIds`, the +`ObjectMutator.ResetObject` port and the `preserveEditorOwnedState` repair +its reset machinery needed. **PATCH is the edit surface.** The principle it +leaves behind is §8.27: *snapshots are for creates, edits are ops.* -Body = full AnyBlock doc; etag via `If-Match` header only. Server -diff-applies via `Unmarshal → NewDocFromSnapshot → SetParent → -ResetToVersion` — minimal CRDT changes **iff block ids round-trip from the -GET** (which C4's full-block-ids default now guarantees for the natural -loop). Response includes `diffStats`, making an accidental full rewrite -visible (the DELEGATE-52 signature). System kinds excluded per -`canUpdateObject`. Docs steer agents to PATCH. +A `?block=` subtree read is still marked `"subtree": true` and still no +write path accepts it (create names it by path). `?ids=full` survives as +the **backup/export shape** (C4) and as the id vocabulary a clone-from-read +POST should use — not as "the PUT read". -**(c) `replaceText` — str_replace scoped to one block's `text` (LAUNCH)** +**(c) `replace_text` — str_replace scoped to one block's `text` (LAUNCH)** ```json -{ "op": "replaceText", "id": "b2", "find": "Q3", "replace": "Q4" } +{ "op": "replace_text", "id": "b2", "find": "Q3", "replace": "Q4" } +{ "op": "replace_text", "find": "Q3 report", "replace": "Q4 report" } ``` Exact-match within one block, must match exactly once, Anthropic-style @@ -250,19 +330,30 @@ review showed deferring it forces the commonest edit (change-one-word) through whole-block verbatim reproduction — the documented 3B collapse mode. The server does the replace deterministically; the model supplies only the short anchor. B1 now only measures whether large models *also* -prefer it over `updateBlock`. +prefer it over `update_block`. -**(d) `setCell` — scoped table-cell write (LAUNCH)** +**`id` is optional (Wave 2.1a, §8.43): `find` doubles as the locator.** +Omitted, the find text must appear in exactly ONE block or the op refuses +— zero matches 404 with the outline steer, several matching blocks are +`ambiguous_input` listing ≤8 candidate block ids with context, several +occurrences within the one matched block get the existing more-context +refusal (`replace_all`'s territory). Resolution runs per-op against the +applier's live document view under the object lock, so op *i* locates +against op *i−1*'s edits, and a dry run resolves identically (C9, +advisory). `id` itself is unchanged — the locator is an additive +alternative, never a change to what `id` means. + +**(d) `set_cell` — scoped table-cell write (LAUNCH)** ```json -{ "op": "setCell", "tableId": "t1", "row": "r2", "col": "c1", "value": "done" } +{ "op": "set_cell", "table_id": "t1", "row": "r2", "col": "c1", "value": "done" } ``` `value` is a string (paragraph-cell shorthand), `null` (clear), or a block object (SPEC §6.1 cell forms). **In the launch op set** (moved from -deferred): it backs the `set_cell` wrapper tool, and whole-`table` -`replaceBlock` for a one-cell change is the same verbatim-collapse trap as -(c). Flat, non-recursive — trivially grammar-constrainable. +deferred): it backs the `set_cell` wrapper tool, and a whole-`table` +rewrite for a one-cell change is the same verbatim-collapse trap as (c). +Flat, non-recursive — trivially grammar-constrainable. **(e) Deferred, additive later** (closed op set, versioned): dataview view ops, `replaceProperties` full-map swap, cross-object batch, block-scoped @@ -271,78 +362,285 @@ preconditions (C7 note). ### Phase 4 — query ``` -POST /v2/spaces/{spaceId}/search (+ POST /v2/search global) -{ "query": "…", "type": "task", - "filter": "done = false AND (dueDate < currentWeek() OR dueDate IS EMPTY)", +POST /v2/spaces/{space_id}/search (+ POST /v2/search global) +GET /v2/spaces/{space_id}/sets/{set_id}/objects?view={viewId}&fields=… +GET /v2/spaces/{space_id}/sets/{set_id}/views +GET /v2/spaces/{space_id}/collections/{collection_id}/objects?fields=… +GET /v2/spaces/{space_id}/collections/{collection_id}/views +``` + +Primary worked example (single-filter form — the small-model form, C12): + +```json +{ "query": "report", "type": "task", + "filter": "done = false AND (dueDate < currentWeek() OR dueDate IS EMPTY)", + "sorts": [ { "property": "due_date", "direction": "asc" } ], + "fields": ["name", "dueDate", "status"] } +``` + +Secondary example — programmatic composition (the structured array; +mid/frontier and round-trip flows, mirroring §5's secondary multi-op +illustration): + +```json +{ "type": "task", "filters": [ { "property": "done", "condition": "equal", "value": false } ], - "sort": [ { "property": "dueDate", "direction": "asc" } ], - "fields": ["name","dueDate","status"], "limit": 25 } -GET /v2/spaces/{spaceId}/sets/{setId}/objects?view={viewId}&fields=… -GET /v2/spaces/{spaceId}/sets/{setId}/views + "sorts": [ { "property": "due_date", "direction": "asc" } ] } ``` - `filter` (compact string) and `filters` (structured array) are mutually exclusive; **both supplied → 400 `ambiguous_input`** ("provide `filter` - or `filters`, not both") (R15). One internal tree. -- **The filter string is a build item [build]** (R6): SPEC §6.2.1 reserves - the grammar as a *post-v1, dataview-scoped* extension — no parser - exists, and search scope adds design deltas (the string uses RFC 3339 - dates / preset functions; the structured form uses unix numbers — the - §6.2.1 mapping applies). Position-addressed parse errors with - did-you-mean, validated against the type's real property keys and - option names. + or `filters`, not both") (R15). Both forms land on **one internal tree** + (the SPEC §6.2 filter node). +- **Request-shape conventions** (fixes v0.3.x drift): the sort field is + **`sorts`** — the SPEC §6.2 view name and the shipped POST /sets name + (research §4.5 introduced the singular; one concept two names is exactly + the duality C2 bans). Pagination is the **C10 query params** + (`offset`/`limit`, default 25, `has_more`) like every shipped v2 list — + no body `limit` (body-vs-query duality with no offset story); a body + `limit` is rejected as an unknown field by the strict request schema. +- **Search is a read** (POST only because the request needs a body): exempt + from `Idempotency-Key` (responses are never stored or replayed — + registration is per-route, so the idempotency middleware is simply not + attached) and from `dry_run` — a supplied `dry_run` is **ignored** (a + read is its own dry run; erroring would punish a harmless habit). +- **The filter-string parser (built — §8.4)** (R6): `pkg/lib/anyblockjson/ + filterstring` — the SPEC §6.2.1 grammar (scope split there: the + grammar + parser ship as a library; only the *document* view field stays + reserved post-v1). Parse → the §6.2 structured tree; offset-addressed + parse errors naming the offending token and position, with did-you-mean. + The string uses RFC 3339 dates / preset functions; the structured form + uses unix numbers — the §6.2.1 mapping applies. The `POST /sets` + wiring (replacing the §8.1 501), through the same R9 referential layer, + shipped with it. +- **Validation & resolution rules** (previously a one-line "design deltas" + note; now decided): + 1. **Key scope.** With a top-level `type`, filter/sort/field keys + validate against the type's recommended keys + `name` (the shipped R9 + sets rule, `typePropertyKeys`) plus the system allowlist below. + Without `type`, and on global search, keys validate against the + **space's property keys** (per space, for global). Unknown keys → + path-addressed did-you-mean. + 2. **System-key allowlist.** `createdDate`, `lastModifiedDate`, + `creator`, `lastOpenedDate` — §3-output-only/system keys that appear + in no type's recommended lists yet back bread-and-butter queries + (`lastModifiedDate > yesterday()`; v2 ListObjects itself sorts by + lastModifiedDate). Always part of the query-surface reference set — + for search AND for set filters/sorts (widening the shipped R9 sets + rule is part of this phase's wiring). + 3. **Option names resolve READ-ONLY on the query path.** SPEC §3's + create-missing is write/import behavior; a query must never mint the + very option it names (the §8.3 `remove` precedent). Unresolved names → + did-you-mean error, never a silent no-match. + 4. **Global search resolves per space** (v1 GlobalSearch precedent): + type keys and option names resolve inside each space's loop + iteration; a name that resolves in only some spaces queries those + spaces and carries a C6 warning naming the spaces where it did not + resolve. **Honest totals**: results merge per-space queries by the + requested sort; `has_more` is true when any space reported more; + `total` is the sum of per-space store counts — do NOT copy v1's + `total = len(fetched)` approximation. + 5. **Empty-date hazard surfaces.** SPEC §6.2: an unguarded `less`/ + `lessOrEqual` date comparison matches undated objects (`dueDate < + currentWeek()` matches objects with no dueDate). Document import + warns; the search path must too — the same warning text rides the C6 + `warnings` channel on the response. + 6. **`type` is a filterable pseudo-key.** The top-level `type` stays a + single type key (the small-model form, C2). Multi-type queries use + the filter channel: `type IN ("task", "bug")` — resolved key→id + server-side like any reference. A top-level `type` and a `type` + filter compose by AND (same channel, two convenience levels — no + ambiguity error). +- **Stored-view execution (`?view=`) substitutes dynamic placeholders.** + SPEC §6.2's `_filter_template__` values are client-substituted and + opaque to the middleware — a query evaluated server-side against the + literal string matches nothing (v1 GetObjectsInList has exactly this + silent-empty-result bug; do not copy it). The handler substitutes + `_filter_template_2_` → the caller's participant id + (`_participant__` — the same identity §7.3's `@me` + needs) and `_filter_template_1_` → the hosting object id before building + the store query; any other placeholder degrades to a C6 warning on the + response, never a silent no-match. +- **Sets AND collections both get a read path.** Phases 2–3 shipped a full + collection write surface (POST /collections, `add_items`/`remove_items`) + with no read/query endpoint — a collection's members were readable only + as the raw `items` id array on GET object (unpaginated bare ids), a + regression vs v1's `/lists/{listId}`. The GET routes above cover both: + `sets/{id}/*` requires a set (its dataview source drives the query), + `collections/{id}/*` requires a collection (membership rows = the store + slice, in its order); one handler branches on layout exactly as v1's + GetObjectsInList does, and a wrong-layout target is a 400 naming the + other route. Rows follow C5. - **Small-model form is settled** (C13): the structured `filters` array is recursive and not constrained-decodable, so the string is the documented default for small models; the array serves round-trip and programmatic - composition. -- Sort by any property key. **[B3]** `resultFormat=rows` gated as before. + composition (both ship — B2 only tunes steering, §4). +- **Engine exists; translation is the build.** `database.Query` already + expresses the whole surface: full-text via `TextQuery`, any-key filters + with `QuickOption` date presets and `NestedFilters`, any-key + `SortRequest` with `Format`/`EmptyPlacement`/`IncludeTime`/`NoCollate` + (v1's closed sort enum is purely an API-layer artifact). Also shipped + and reusable: read-only option resolution (the Phase-3 `remove` path), + `typePropertyKeys` + did-you-mean (`v2_refs.go`), `fields` row shaping + (ListObjects + `MarshalPropertyValue`). The translation layer — the + filter-string parser, the exported filter/sort fragment codec + (`anyblockjson.UnmarshalFilters`/`UnmarshalSorts`, the one tree both + request forms land on), the view-execution resolver over the **direct + store-query path** (explicitly NOT v1's shared-subId + `ObjectSearchSubscribe` hack — the constant `subId = "json-api-internal"` + is racy under concurrent requests), and the global-search merge — is + **built** (§8.4); only the [B3] rows encoder stays gated. +- Sort by any property key. **[B3]** `resultFormat=rows` gated as before + (now runnable — the harness prerequisite, Phase 3a, is met). ### Phase 5 — the task-tool wrapper (CLI + skill + on-device manifest) +**Built** — `core/api/wrapper` (the tool table + manifest + runner) and +`cmd/anytype` (the verb-set) + `cmd/anytype/SKILL.md`; decisions as built +in §8.6. The dependency items below read as the plan they were; their +dispositions live in §3 (Built / still-open) and §8.6. + The Phase-5 deliverable is the **task-tool wrapper** (§7): the curated -~9-tool layer over `/v2`, delivered as (a) CLI verbs (`anytype find | read | -describe | create | set-properties | add-blocks | edit-text | set-cell | -move | delete`) with AXI/Chow output conventions and SKILL.md three-tier -packaging for coding-agent harnesses, and (b) a function-calling/MCP -manifest of the same tools for on-device small models. Both are thin over -the same server primitives; bulk work via scripts. (Evidence §3.7; §7 for -the tool contract.) +~10-tool layer over `/v2`, delivered as (a) CLI verbs (`anytype find | read | +describe | create | set-properties | add-blocks | edit-text | check-item | +set-cell | move-block | delete-block`) with AXI/Chow output conventions and +SKILL.md three-tier packaging for coding-agent harnesses, and (b) a +function-calling/MCP manifest of the same tools for on-device small models. +Both are thin over the same server primitives; bulk work via scripts. +(Evidence §3.7; §7 for the tool contract.) + +- **Hard dependency order.** The wrapper is code-complete only after: + (1) **Phase-4 search + the filter-string parser** — backs `find`, the + true blocker (no degraded form beyond ListObjects paging); (2) + **`GenerateSchema` + the store-backed option join** — backs `describe`, + a 501 stub today; an interim degraded describe can be assembled + wrapper-side from `GET /types/{t}` + `GET /properties/{key}/options` so + wrapper development can start; (3) the **markdown→flat-blocks parser** + as an `insert_blocks` `markdown` payload alternative — backs `add_blocks` + and upgrades the create shortcut off the two-change-set paste path + (§8.1). Everything else — handle state, full-read relabeling, `@me`, + relative dates, the GBNF artifacts, the D′1 escape decision — is §7.4 + wrapper-layer work that should not block starting. +- **CLI verb naming**: `move-block`/`delete-block`, matching the tool + names — a plain `delete` would be read by coding agents as *object* + deletion, which no v2 surface offers today (`DELETE /objects` is the + open Phase-1 build item, due before this phase; when it ships, a plain + `archive` verb may take the object meaning — until then object archive + stays out of the wrapper). ## 3. Decisions ledger **Decided**: id-addressed closed op set, no RFC 6902, no index/offset -addressing · `updateBlock` merge op + `replaceText` + `setCell` in the -launch op set (they back wrapper tools — §7/S1) · PUT-with-server-diff as -escape hatch (excluded from the small-model wrapper), full-block-id -round-trip default, diffStats · flat AnyBlock as the only content -representation on the REST write path; **markdown-in on the wrapper's -`add_blocks`** channel; markdown read-only on REST · compact object ids + -full block ids on REST reads / **short handles on wrapper reads** · one -vocabulary incl. `type` (C2) · per-endpoint schema + worked example, -strict-mode-compatible (C12/C13) · path-addressed errors + /validate + -dry_run + idempotency · etag advisory by default · filter string as the -small-model filter form · atomic composite creates (sets via initial-state -dataview) · **the small-model contract is the task-tool wrapper (§7), not a -REST mode**. - -**Benchmark-gated**: B1 `replaceText`/`setCell` value for *large* models -(they are already launch primitives for the wrapper) · B2 structured-filters -value for mid/frontier programmatic composition (small-model primacy is -settled — R8) · B3 tabular result format · B4 wrapper-tool prompt/skill -guidance. +addressing · `update_block` merge op + `replace_text` + `set_cell` in the +launch op set (they back wrapper tools — §7/S1) · **no full-document +replace at all** (PUT shipped through Phase 3 and was removed — §8.27: +snapshots are for creates, edits are ops), full-block-id round-trip +default, diff_stats · flat AnyBlock as the primary content +representation on the REST write path, plus **one markdown-in alternative: +an `insert_blocks` `markdown` payload** (mutually exclusive with `blocks`, +same targeting incl. root-append — the server parses; it backs the +wrapper's `add_blocks` channel and, once landed, the create shortcut); +markdown read-only otherwise · compact object ids + full block ids on REST +reads / **short handles on wrapper reads** · one vocabulary incl. `type` +(C2) · per-endpoint schema + worked example, strict-mode-compatible +(C12/C13) · path-addressed errors + /validate + dry_run + idempotency · +etag advisory by default · filter string as the small-model filter form +(parser home: `pkg/lib/anyblockjson/filterstring` — it backs `find` in the +wrapper, Phase-4 search, and the POST /sets `filter` field; SPEC §6.2.1 +scope split, v0.7) · atomic composite creates (sets via initial-state +dataview) · **search is a read** — exempt from Idempotency-Key and +dry_run (Phase 4) · **`type` as a filter pseudo-key**; top-level `type` +stays a single key · stored-view execution substitutes the §6.2 dynamic +placeholders (template_2 → caller participant, template_1 → host object; +others degrade to C6 warnings) · `@me` identity served by +`GET /members/me` server-side; sentinel + relative-date math in the +wrapper handler (§7.3) · **the small-model contract is the task-tool +wrapper (§7), not a REST mode**. + +**Benchmark-gated**: B1 — whether *large*-model docs/steering prefer +`replace_text`/`set_cell` over `update_block` (all are launch ops; nothing +ships or unships on B1) · B2 — which filter form the docs/steering +recommend per tier (both forms ship regardless; small-model primacy of the +string is settled — R8) · B3 tabular result format · B4 wrapper-tool +prompt/skill guidance. **Deferred**: dataview/view ops · cross-object batch · block-scoped preconditions · conflict rebase · events/subscriptions · core-profile -strict schema (FLAT.md §7.3) · `?permanent=true` hard delete. +strict schema · `?permanent=true` hard delete. + +**Named build items** (open today; budget them): + +- **`resultFormat=rows` encoder**. [B3-gated] +- **`GenerateSchema` + store-backed option join** — the + `types/{type}/schema` route is a 501 stub today. Phase 5 shipped + `describe` in its sanctioned degraded form (wrapper-side composition, + §8.6), so this item no longer blocks the wrapper — landing it collapses + the wrapper's composition to one GET. +- ~~**`DELETE /v2/spaces/{space_id}/objects/{object_id}` (archive)**~~ — + **BUILT 2026-08-14** (plan 3.3, APIV2_OBJECT_DELETE.md, §8.42): + registered, own-output-only via creator provenance, fail-closed on + everything created before it shipped. The wrapper-tool question + (§7.2) is now unblocked but not exercised here. +- **the D′1 escape decision** for `edit_text`/`replace_text` (§7.1) — still + open; the tool description and SKILL.md carry the markup caveat. +- **md-export loss detector** (converter/md has no warning channel). -**Named build items** (exist nowhere today; budget them): filter-string -parser (search-scoped — now a launch dependency, backs `find`/`set` in the -wrapper) · `GenerateSchema` + store-backed option join (backs `describe`) · -resolver wiring (create-missing properties/options) · referential -validation layer · md-export loss detector (converter/md has no warning -channel) · **markdown→flat-blocks parser for `add_blocks`** (the wrapper's -authoring channel) · **handle↔CID resolver** (the wrapper's reference -channel) · `anyblockjson.Options` id-compaction split (C4). +**Built** (previously listed as build items; moved out so no one +re-budgets them): resolver wiring (create-missing properties/options — +`v2_resolver.go`, Phase 2) · referential validation layer (did-you-mean, +§8.1 policy, Phase 2) · `anyblockjson.Options` id-compaction split +(`CompactObjectRefs`/`CompactBlockLabels`, `CompactIds` shorthand — C4) · +scalar→array coercion for list-shaped formats +(`anyblockjson.UnmarshalPropertyValue`, on every write path) · +**filter-string parser** (`pkg/lib/anyblockjson/filterstring`, Phase 4 — +§8.4) · **exported filter/sort fragment codec** +(`anyblockjson.UnmarshalFilters`/`UnmarshalSorts`, Phase 4) · +**view-execution resolver** (direct store query over setOf / the +collection store slice, with placeholder substitution — Phase 4, §8.4) · +**global-search per-space loop + merge** with honest totals (Phase 4, +§8.4) · **POST /sets `filter` wiring** (the §8.1 501 is gone) · the +Phase-4 discovery additions (`search` kind; the grammar on the `filters` +kind) and the R9 sets-rule system-key widening · **markdown→flat-blocks +parser** (`anyblockjson.ParseMarkdownBlocks` + the `insert_blocks` +`markdown` payload + the single-change-set create fold — Phase 5, §8.6) · +**the task-tool wrapper** (`core/api/wrapper`: the 12-tool table, manifest, +schemas, per-tool GBNF + the filter-string GBNF, handle/label session +state, ambiguity retry, idempotency machinery, `@me` + relative dates, +option pre-validation, degraded `describe` — Phase 5, §8.6) · **the CLI +verb-set** (`cmd/anytype`, generated from the same table) · **the MCP +server** (`anytype mcp`, stdio, tier-filtered over the same table — +§8.20) · +**`GET /v2/spaces/{space_id}/members/me`** (server-side identity) · +**the Phase-6 chat surface** (§8.7: v2 chat DTOs + the inline-markup +bridge both directions, `GET/POST /chats` as C5 rows over a store query, +`GET /messages` with the state+message_count passthrough + has_more +cursors, message POST/PATCH/DELETE + the reactions toggle with C8/C9, +`POST /read` requiring `{up_to, last_state_id}`, the five chat discovery +kinds, and the C8 DELETE widening — now uniform across every v2 DELETE) +· **the Phase-6 review hardening** (§8.7, 2026-08-06: the last_state_id +silent-no-op closed, chat RPC error classification incl. the new C6 +`forbidden` code, text/attachment caps enforced + schema drift tests, +delete/toggle existence checks + file-GC warnings, RFC 3339 chat dates, +the reactions/reacted_by split, `blocks_text`, and the chat handler test +layer) · **the Phase-7 periphery** (§8.8, 2026-08-06: the space surface — +`GET /v2/spaces/{space_id}` as an RPC-free tech-space-view read, +`POST /v2/spaces` as ONE WorkspaceCreate call, `PATCH` with the +at-least-one-field contract, C8 on both mutations; the search +file-layout opt-in keyed off the type channel — positive type leaves +and the top-level type widen the row scope to `ObjectAndFileLayouts` +on both request forms — plus the `mimeType`/`size` aliases; the `space` +discovery kind) · **the Phase-7 review hardening** (§8.8, 2026-08-06: +per-space alias shadowing + the aliases live in filters/sorts, the +negation-scoped opt-in condition set incl. `allIn`, the live-space +predicate on GET-one/list + `description` on the list row, workspace +RPC error classification, the 4096 caps enforced, the no-space-id +500, the keyed POST /v2/spaces replay pin) · **key-scoping P1c: +whoami + legacy-key signals** (§8.11, 2026-08-06: `GET /v2/auth/whoami` +with the explicit `grant.scoped` boolean, per-entry space permissions +and grant-intersected names; the `Anytype-Key-Status`/`Anytype-Notice`/ +`Link rel="deprecation"` signal — deliberately not RFC 9745 +`Deprecation`/`Sunset` — with the body echo and the rate-limited usage +log; the gitleaks/TruffleHog rules in `docs/secret-scanning/`). ## 4. Benchmark program @@ -350,17 +648,25 @@ All on the Phase-0 harness; small tiers run under constrained decoding (R13). Metrics: apply-success, corruption (round-trip backtranslation), output tokens, turns; per model tier. -- **B1 — edit primitives** (gates 3c): arms = launch ops · launch ops + - `replaceText`. **PUT-only runs as the corruption baseline**, anchoring - the DELEGATE-52 comparison — it is not a gate arm (PUT's existence is - decided). Decision rule: `replaceText` ships iff it improves small-model - apply-success or corruption on the one-word/sentence-edit tasks without - regressing the rest. -- **B2 — structured filters for composition**: does the array ever beat - the string for mid/frontier programmatic flows (round-trip, multi-step - composition)? Scored by execution semantics, never string equality. +- **B1 — edit-primitive steering** (tunes documentation; ships nothing — + `replace_text`/`set_cell` are launch ops per §2(c)/(d), so B1 no longer + gates them): arms = **update_block-only** (`replace_text`/`set_cell` + withheld from the prompt) · **full launch op set**. The DELEGATE-52 + corruption baseline was to be a **PUT-only** arm; with PUT removed + (§8.27) that arm is a *simulated* whole-document rewrite (read the + document, regenerate it, `replace_subtree` the root run) — it never was a + gate arm. Decision rule: whether the + REST docs and B4 guidance point *large* models at `replace_text`/`set_cell` + for text/cell edits or leave them on `update_block` (small-model steering + is settled — the wrapper channels). +- **B2 — filter-form steering**: both forms ship regardless (the array is + load-bearing for sets creation and round-trip; the string is the settled + small-model form — R8), so B2 decides which form the docs/steering + recommend for mid/frontier programmatic flows (round-trip, multi-step + composition). Scored by execution semantics, never string equality. - **B3 — result format** (gates `resultFormat=rows`): reading-accuracy + - tokens, compact JSON vs rows over 10/100/1000-row results. + tokens, compact JSON vs rows over 10/100/1000-row results. Now runnable — + the harness prerequisite (Phase 3a) is met. - **B4 — creation guidance** (tunes prompt/SKILL.md guidance — *not* the C12 endpoint docs, which always ship schema + example): which in-context combination (example-only / schema+example / constrained core-profile) @@ -369,26 +675,46 @@ output tokens, turns; per model tier. ## 5. Discovery ``` -GET /v2/schemas # index: kinds, endpoints, examples -GET /v2/schemas/{kind} # JSON Schema + worked example (object|type|property|set|collection|template|filters) +GET /v2/schemas # index: kinds, endpoints, examples, ops list (§8.2) +GET /v2/schemas/{kind} # JSON Schema + worked example + # shipped kinds: object · shortcut · type · template · + # property · set · collection · file · filters + # Phase 4 adds: search GET /v2/schemas/ops/{op} # per-op tiny strict schema + single-op minimal example ``` Per-op fetch keeps the smallest consumers at the smallest schema surface -(§3.5 capability cliff); the 6-op composite example remains as a secondary -"multiple ops in one request" illustration. All generation-facing schemas -follow C13. The per-type artifact lives in Phase 1. +(§3.5 capability cliff); the multi-op composite example (currently 7 ops, +§2a) remains as a secondary "multiple ops in one request" illustration. All +generation-facing schemas follow C13. **Phase-4 discovery**: a `search` +kind (strict request schema; its `filters` property references the +documented recursive exception), and the compact filter-string **grammar** +gets its discovery slot — served on the existing `filters` kind (one +concept, one slot, C2): that kind's response carries the structured-array +schema AND the string grammar (EBNF + examples), the same artifact the +Phase-5 GBNF conversion consumes (this assigns the C13 "filter string" +discovery slot, previously unassigned). The per-type artifact's route +shipped with Phase 1 as a 501 stub; the `GenerateSchema` artifact is an +open §3 build item. ## 6. Rollout -1. Phase 0+1 behind an experimental flag; OpenAPI generated from day one. -2. Phase 2, then 3a/3b. **v1-parity note**: full parity additionally - requires the Phase 1–2 surface additions of R11 (files, spaces/members - reads, archive); §6's earlier "superset" claim is scoped to the object - surface *including those*. +1. As built: `/v2` ships **ungated** alongside v1 on the same localhost + server — no experimental flag exists (`V2Deps` is always fully + populated; the only gating is nil-dependency skips for degraded test + construction). Whether a flag is wanted before the first public release + is an **open rollout task**, not a shipped fact. OpenAPI generated from + day one. +2. Phase 2, then 3a/3b — shipped. **v1-parity note**: full parity + additionally requires the Phase 1–2 surface additions of R11 — files + and spaces/members reads shipped; **object archive (`DELETE /objects`) + is the one R11 item still outstanding** (§2 Phase 1 [build]); §6's + earlier "superset" claim is scoped to the object surface *including + those*. 3. Benchmarks run once the harness + Phase 3a exist; gated items land in minor releases (additive to the closed op set). -4. Phase 4, Phase 5. v1 deprecation clock starts when the CLI ships. +4. Phase 4, Phase 5 — shipped (`cmd/anytype` is the CLI, §8.6). The v1 + deprecation clock starts when the CLI ships in a release build. Each phase's exit criterion: its endpoints pass the harness's task set at parity-or-better vs the v1 baseline flow for the same task (fewer calls, @@ -410,11 +736,15 @@ evidence actively recommends (Anthropic writing-tools-for-agents; Linear; Notion markdown-for-agents). **One artifact, two deliveries.** The wrapper is a single tool manifest -mapping ~9 task tools to `/v2` primitives; it is exposed as **CLI verbs** +mapping ~10 task tools to `/v2` primitives; it is exposed as **CLI verbs** (coding-agent harnesses; the CLI-over-MCP finding) and as an **on-device function-calling / MCP manifest** (3B models). It is the Phase-5 deliverable. Full REST + wrapper = two surfaces sharing one AnyBlock format, one -validation contract, one error contract. +validation contract, one error contract. The two deliveries share the +manifest but NOT a state story: `find`'s enumerated handles outlive a tool +call, which a long-lived MCP process holds in memory while the +process-per-invocation CLI must persist — §7.4 states where handle state +lives. ### 7.1 The three channels that close the review's structural findings @@ -422,58 +752,6197 @@ The wrapper's leverage is that a tool argument can be a friendlier channel than the REST body: - **Authoring channel = markdown** (not AnyBlock JSON). `add_blocks` takes a - markdown string; the server parses it to flat blocks. Removes inline- - markup-in-JSON escaping (the top 3B failure) and the relative-indent - arithmetic (markdown indentation → server computes `indent`). [closes S2] + markdown string; the server parses it to flat blocks. **Decided (v0.4): + the parser's home is an `insert_blocks` `markdown` payload alternative** — + mutually exclusive with `blocks`, same targeting incl. root-append — so + markdown-in rides the whole op pipeline (validation, `created_blocks`, + `diff_stats`, dry-run, idempotency) and the CLI vendors nothing; this is + the "real server-side primitive" §7.3 item 1 demands (the create + shortcut's two-change-set BlockPaste stopgap, §8.1, cannot back it). + Removes inline-markup-in-JSON escaping (the top 3B failure) and the + relative-indent arithmetic (markdown indentation → server computes + `indent`). [closes S2] - **Editing channel = anchor + deterministic server edit.** `edit_text` takes `find`/`replace`; the server does the string replace in code and - applies it via the `replaceText` primitive. The model supplies a short + applies it via the `replace_text` primitive. The model supplies a short anchor, never reproduces the block. [closes S1's change-one-word collapse] + **Caveat — anchors and replacements are markup SOURCE**: `replace_text` + matches the block's §8 markup text and re-parses the result, so a + replacement containing `*`, `[` or mention syntax mints real marks — + JSON-escaping is removed, markup-awareness is NOT (the open D′1 debt, + a **named Phase-5 dependency for the small tier**; until it lands the + tool description must say find/replace text is treated as markup). The + tool also deliberately omits `replace_text`'s `replace_all` — single-match + only for the small tier; the CLI may expose `--all` for larger consumers. - **Reference channel = enumerated handles.** `find` returns `1,2,3`; `read` - exposes short block labels; the wrapper resolves handles/labels → CIDs - server-side. The model never sees or emits a 24-hex id. [closes S3] + exposes short block labels **in outline mode — full body-bearing reads + carry full 24-hex block ids (C4/T7), so the wrapper relabels them + itself** (labels are unique suffixes; every write path resolves suffixes + unconditionally via `matchBlockRef`, so labels pass straight through + writes). The wrapper resolves handles/labels → CIDs, with the §7.4 + ambiguity retry. The model never sees or emits a 24-hex id. [closes S3] -### 7.2 Tool set (~9; flat, grammar-constrainable args) +### 7.2 Tool set (12 as built; flat, grammar-constrainable args) | Tool | Args (flat) | Backing primitive | Channel notes | |---|---|---|---| -| `find` | `space, query?, type?, filter?, limit?` | Phase 4 search | filter = string form; results are enumerated handles + minimal fields | -| `read` | `object, mode=full\|outline` | Phase 1 read | returns short block labels; `full` includes text | -| `describe` | `type` | Phase 1 `types/{type}/schema?flavor=table` | the accuracy lever, **called before create/set** (folds A1 into the flow) | -| `create` | `space, type, name, properties?, markdown?` | Phase 2 create | `type`/options validated with did-you-mean; no silent create-missing (A2) | -| `set_properties` | `object, {key: value}` | `setProperties` op | server coerces scalar→array, `@me`, relative dates | -| `add_blocks` | `object, after?\|under?, markdown` | `insertBlocks` op | **markdown channel**; server parses → flat blocks | -| `edit_text` | `object, block, find, replace` | `replaceText` op | **anchor channel**; deterministic server replace | -| `set_cell` | `table, row, col, value` | `setCell` op | flat cell write | -| `move_block` / `delete_block` | `object, block, after?\|under?` / `object, block, recursive?` | `moveBlock`/`deleteBlock` ops | handle-addressed | - -Excluded from the wrapper: PUT full-document replace (the DELEGATE-52 -corruption vector — REST-only, large models), multi-op batch (single tool +| `spaces` | `limit?` | Phase 4 `GET /v2/spaces` | the bootstrap tool (added post-review): every trace needs a space id and nothing else in the set could produce one — `name — id` rows, no handles | +| `find` | `space, query?, type?, filter?, limit?` | Phase 4 search **[build — the true §7 blocker]** | filter = string form; results are enumerated handles + minimal fields. *Since §8.33: with none of `query`/`type`/`filter` the call matched nothing, so it LISTS the space instead — unnumbered, assigning no handles, because handle 1 of a listing is not the object anyone asked for* | +| `read` | `object, mode=full\|outline` | Phase 1 read | outline returns short block labels; `full` carries full block ids the wrapper relabels (§7.1/§7.4) | +| `describe` | `space, type` | Phase 1 `types/{type}/schema?flavor=table` **[build — 501 stub today]** | the accuracy lever, **called before create/set** (folds A1 into the flow); interim degraded form assembled wrapper-side (§2 Phase 5); every backing GET is space-scoped, so the tool takes `space` too. *Since §8.33 it reports what is SETTABLE — the type's recommended lists were neither a superset nor a subset of that, and hid `name` and `description`* | +| `create` | `space, type, name, properties?, markdown?` | Phase 2 create | type and property keys validated with did-you-mean; **select option names create-missing by default (R9/§8.1)** — the small-tier pre-validation guard is wrapper-side (§7.4); markdown caveats until the parser lands (below) | +| `set_properties` | `object, set?{key: value}, add?{key: […]}, remove?{key: […]}` | `set_properties` op incl. per-key `add`/`remove` (§8.3) | mirrors the op so a one-tag append never rewrites the whole array (the op's entire rationale — reintroducing the read→rewrite→write trap at the wrapper layer would defeat it); `add` on a non-empty select errors, steering to `set`; scalar→array coercion is server-side | +| `check_item` | `object, block, checked` | `update_block` op | the one block-field tool: checkbox **blocks** are a common note shape and `update_block` is THE block-update op post-§8.3; other block-field updates (color/align/language/retype) stay excluded — SKILL.md steers task completion to properties (the E4 recipe) | +| `add_blocks` | `object, after?\|under?, markdown` | `insert_blocks` op, `markdown` payload **[build]** | **markdown channel**; server parses → flat blocks (§7.1) | +| `edit_text` | `object, block, find, replace` | `replace_text` op | **anchor channel**; deterministic server replace; find/replace text is markup source until D′1 lands (§7.1); an EMPTY `replace` deletes the found text (Required means present, not non-empty — §8.6) | +| `set_cell` | `object, table, row, col, value` | `set_cell` op | flat cell write (as built the tool takes `object` too — the REST op addresses a table within one object, and a table-only reference would need a hidden cross-object table registry; §8.6); row/col take the labels full read mints (rows and columns relabel like blocks); an EMPTY `value` clears the cell (null on the wire) | +| `move_block` / `delete_block` | `object, block, after?\|under?` / `object, block, recursive?` | `move_block`/`delete_block` ops | handle-addressed | + +Excluded from the wrapper: whole-document replace (the DELEGATE-52 +corruption vector — and since §8.27 excluded from the REST surface too, +so this line records a gap that closed from the other end), multi-op +batch (single tool call per intent), the structured `filters` array (recursive, not constrained-decodable — string only), relative-indent authoring (markdown -channel replaces it). +channel replaces it), block-field updates beyond `checked` (deliberate +curation, recorded so the gap is not read as an artifact of the +replaceBlock-era table), object archive (no v2 route yet — §2 Phase 1 +[build]), set building (`POST /sets` is the REST path; the "build a set +with filter" eval task runs on the REST surface, not the wrapper). + +**Create-with-markdown caveats — DISSOLVED (Phase 5 as built).** The +markdown→flat-blocks parser landed (`anyblockjson.ParseMarkdownBlocks`) and +the create shortcut folds markdown into the single-change-set create +snapshot: dry runs validate the markdown too, a failure builds nothing, and +the C8 cache replays safely. The historical two-operation paste path +(create snapshot, then v1 BlockPaste — §8.1) is gone; this paragraph +survives only so the old caveats are not re-derived. ### 7.3 What the wrapper does NOT let us skip 1. **The bounded server primitives must exist** — `edit_text`/`set_cell` are - safe only because `replaceText`/`setCell` are real server-side scoped ops + safe only because `replace_text`/`set_cell` are real server-side scoped ops (the S1 launch reweighting in §3). A wrapper that implemented them as - GET+regenerate+PUT would reintroduce corruption. + GET+regenerate+write-the-whole-document would reintroduce corruption — + which is the same argument §8.27 later applied to the REST PUT itself. 2. **Constrained decoding still required, now tractable** — on-device function-calling (Ollama/llama.cpp GBNF) needs the *tool* schemas grammar-emittable; C13 applies to the small flat tool args here instead of the recursive block tree. The wrapper serves a GBNF/CFG artifact per tool - (a Phase-5 build item), including the filter-string grammar for `find`. -3. **Server conveniences owned by the handler** — `@me` (+ `GET /members/me`), - relative-date resolution on property values, scalar→array coercion, - validate-not-create-missing, and If-Match/Idempotency-Key management - (the model authors none of these; the wrapper does). + (a Phase-5 build item), including the filter-string grammar for `find` — + **sequenced after the Phase-4 parser pins the grammar** (§6.2.1's design + is the source; no GBNF or JSON-Schema→grammar seam exists anywhere yet). +3. **Conveniences, placed** — scalar→array coercion is **already served** + (`anyblockjson.UnmarshalPropertyValue` wraps scalars of list-shaped + formats; every write path routes through it — not wrapper work). Still + to build, placement decided: `GET /v2/spaces/{space_id}/members/me` + **server-side** (only the server knows the account identity — the same + identity Phase 4's placeholder substitution uses); `@me` sentinel + resolution and relative-date math **in the wrapper handler** (simplest, + matches this section's framing — the REST value path stays literal). + Option-name accuracy for the small tier is a **wrapper-side + pre-validation pass** (§7.4) — the REST primitives create missing + select option names by design (R9/§8.1), so the A2 guard the wrapper + wants must sit in front of them, not be assumed of them. + If-Match/Idempotency-Key management stays wrapper-owned (the model + authors none of these). ### 7.4 New build items for the wrapper -Beyond §3's list: markdown→flat-blocks parser (`add_blocks` authoring -channel) · handle↔CID resolver (reference channel) · per-tool GBNF/CFG -artifacts · `@me` self-resolution · relative-date input resolution. These -join `replaceText`/`setCell` (now launch ops) and the filter-string parser -(now a launch dependency) as the small-model launch set. Benchmark **B4** -tunes the wrapper's tool descriptions / SKILL guidance per model tier. +Beyond §3's list: + +- **markdown→flat-blocks parser** — the `insert_blocks` `markdown` payload + alternative (§7.1; also dissolves the create-shortcut caveats in §7.2). +- **handle↔CID resolver, fully stated**: (a) *handle state outlives a CLI + invocation* — persisted in a session file (scratch dir, keyed by space), + written by `find`, read by the id-taking verbs, invalidated/renumbered + by each new `find`; the MCP delivery keeps the same table in memory. + (Alternative considered: stateless short unique object-id prefixes + resolved server-side like block suffixes — a simpler state story, but + handles stop being small stable integers and prefixes grow with the + space; revisit if the session file proves fragile under concurrent + harnesses.) (b) *wrapper-side relabeling of full-read block ids* — + **RETIRED (§8.26)**: the server labels reads itself since Wave 0.2, and + the wrapper's `relabelDoc`+label-map turned out worse than dead weight — + its 24-hex predicate matched nothing in a server-labeled document (the + ambiguity retry's label source went dead), while a STALE map surviving + in the CLI session file rewrote a just-read label into an outdated full + id. Refs now pass through verbatim; the minted-shape predicate the + wrapper pioneered became the server's relabel rule. (c) *suffix + pass-through* as the write mechanism (`matchBlockRef` resolves unique + suffixes on every op) — now the ONLY mechanism. (d) *the ambiguity + retry, scoped honestly (post-§8.26)* — refs go to the server verbatim, + so a 400 `ambiguous_input` means the ref did not resolve against the + document the server saw; the wrapper re-reads the object and retries + once when the ref uniquely tails one of the re-read's own SERVED ids, + which self-heals exactly the concurrent-modification race (the + collision the server saw is gone by the re-read). A persistent + ambiguity is unresolvable in principle — the wrapper cannot know which + block the model meant — and surfaces the server's error. Because the + rewrite lands after the Idempotency-Key is minted, `LastWrite` records + it (`PriorHash`+`Rewrites`) so an identical re-run replays under the + same key instead of re-applying. +- **wrapper-side option-name pre-validation** (the A2 guard for the small + tier): `GET /properties/{key}/options` + did-you-mean before + `create`/`set_properties`, stated as wrapper logic the REST primitive + does not perform (R9/§8.1 create-missing stands on REST). +- **per-tool GBNF/CFG artifacts** — after the Phase-4 parser pins the + filter grammar; keep the served op/tool schemas' `$ref`/`$defs` style + within what the chosen GBNF converter supports, and assert + convertibility by test to keep C13 honest. +- **`@me` self-resolution** (+ server-side `GET /members/me`) · + **relative-date input resolution** (placement per §7.3 item 3). +- **the D′1 escape decision** for `edit_text` (§7.1 — escape `replace` for + text-bearing blocks, or plain-text find/replace with offset-shifted + marks; a named Phase-5 dependency for the small tier). + +These join `replace_text`/`set_cell` (launch ops) and the filter-string +parser (a launch dependency) as the small-model launch set. Benchmark +**B4** tunes the wrapper's tool descriptions / SKILL guidance per model +tier. + +## 8. Implementation notes (v0.3.1 — closes Phase-0/1 gaps for a fresh build) + +Concrete decisions a fresh implementer needs, so the design ambiguities that +would stall Phase 0/1 are resolved (genuine format/design ambiguities still +surface as spec bugs per the package's tradition). + +**Server & auth.** `/v2` **extends the existing `core/api` gin server** — a +new route group under the same middleware stack (Recovery / Metadata / +Logger / Pagination / Cache / Auth / RateLimit / Analytics) and the same +layering as v1 (handlers in `core/api/handler`, services in +`core/api/service`, DTOs in `core/api/model`, routes in +`core/api/server/router.go`; follow `core/api/CLAUDE.md` — fixture pattern, +mockery, error wrapping). **Auth is shared with v1**: `/v2` reuses the v1 +bearer token, api-key, and challenge endpoints and the Auth middleware — no +new authentication surface. One `/v2`-only *authorization* addition landed +later: a group-level key-scope gate — only `JsonAPI`- and `Full`-scoped +keys may use `/v2`; a `Limited` key gets 403 here while staying served on +`/v1` (§8.9). Pagination is offset/limit as in v1. + +**etag (C7), concrete.** The agent-facing token = first 8 hex of +`sha256(sorted object tree heads)` (heads via `sb.GetDocInfo().Heads` in +the adapter, captured under the same locked read as the snapshot). Returned +as the `ETag` header and the envelope `etag`. `If-Match` comparison runs +server-side against the **full** head-set hash; **the 8-char display form +is accepted as its prefix**, so the etag a GET returned can be sent back +verbatim in `If-Match` (quoted, `W/`-prefixed and bare forms are all +normalized — RFC 7232). It is NOT the `revision` relation. Advisory by +default (C7): absent `If-Match` ⇒ last-write-wins. + +**Read path (C5/Phase 1), concrete.** Read via the **live smartblock state** +→ snapshot → `anyblockjson.Marshal` with objectstore-backed +format/option/property resolvers — NOT `ObjectShow` (its `ObjectView` is the +wrong type) and NOT the store snapshot (it lags live edits/sync). Derive the +`etag` from that same state read so token and content are consistent. The +per-object read is exactly `cmd/anyblockroundtrip`'s snapshot→Marshal path, +minus the round-trip. + +**`?block=` subtree, concrete.** Indents stay **absolute** (not rebased to +the anchor) so a block's id and depth are identical whether read in full or +as a subtree — an agent can cache and cross-reference either way. The block +reference resolves by **exact id or unique suffix** (§9a): a compact outline +label (the id's last few chars) round-trips to `?block=`; a suffix matching +more than one id is a 400 steering to the full id; zero matches is a 404. +`?block=` implies blocks (an accompanying `include=properties` adds the +properties map rather than emptying the read). + +**`/validate` result semantics, concrete.** `POST /v2/validate` returns +**200 with `{issues, warnings}`** even for an invalid document — the issues +are the repair-loop's food, never a 4xx. A document produced by a newer +format version surfaces as a `/version` issue with a hint (SPEC §10), not a +transport error. (The `version_unsupported` HTTP error exists for Phase-2 +*body* rejection on create/replace, where an unparseable version must fail +the write.) + +**C11 read warnings, concrete.** A read never fails on content the format +can't represent: unmapped block types and over-deep nesting degrade to +`warnings[]` on the envelope (the `anyblockjson.Options.OnWarning` sink); +canonical export leaves the sink nil and still errors. The markdown export +path has no loss channel yet, so `format=md` carries no warnings (build +item: md-export loss detector). + +**Idempotency store (C8).** In-process, keyed by `(space, Idempotency-Key)` → +`(body-hash, stored-result)`, with an in-flight reservation so concurrent +same-key requests never double-execute (the second blocks then replays). +Same key + same body-hash ⇒ replay stored result; same key + different +body-hash ⇒ 409 `idempotency_conflict`. Only successful (2xx) responses are +cached; a failed/panicked request releases the reservation so a retry +re-executes. TTL is an impl detail (a bounded LRU is fine); persistence +across restart is not required for v2.0. + +**Eval-harness ordering (correction to §2 Phase 0).** Phase 0 delivers the +**scoring primitives + plumbing + `/validate`**, NOT the full agent-loop +runner (which has nothing to score until an edit path exists). Scoring +primitives: (a) the **corruption metric** — DELEGATE-52 backtranslation: apply +a forward edit instruction and its inverse in sequence, then measure residual +drift against the untouched document using the state-diff / text-multiset +comparator already in `cmd/anyblockroundtrip`; (b) token and turn counters; +(c) the task fixtures. The **agent-loop runner pairs with Phase 3a** — the +first point at which benchmark B1 has competing methods to score. Scratch +space = an ephemeral test account/space provisioned as +`cmd/anyblockroundtrip` does (data-dir copy). The small-tier model runner +MUST support grammar-constrained decoding (GBNF via Ollama/llama.cpp), per +the constrained-decoding requirement (C13, R13). + +**First-increment scope (fresh build).** Phase 0 plumbing (C6 error contract +incl. the named codes; C7 etag; C8 idempotency store; C9 dry-run scaffold) + +`POST /v2/validate` (exposes `anyblockjson.Validate`, structural/format- +semantic only) + Phase 1 read endpoints (`GET object` with +`include`/`outline`/`block`/`ids`/`format`; `GET objects`/`types`/ +`properties`/`spaces`/`members` lists) + the Phase-0 scoring primitives above. +Exit criterion: Phase 1 reads pass the fixture task set at parity-or-better +vs the v1 GET flow (fewer tokens for the same content), and the scoring +primitives run against the fixtures. Phases 2–7 are out of this increment. + +### 8.1 Phase-2 implementation notes (v0.3.2 — decisions as built) + +**The create path, decided.** One mechanism for every create: +`anyblockjson.Unmarshal` → `state.NewDocFromSnapshot` → +`objectcreator.CreateSmartBlockFromState` (a new `apicore.ObjectCreator` +port + `core/api` adapter, the same pattern as Phase 0's `ObjectReader`). +The whole document lands as the object's **initial state — one change set**, +which is what makes composite creates (a set with its dataview) honestly +atomic (R10). Rejected alternatives: the import engine's +`common/objectcreator.Create` (needs the whole import DataObject machinery — +id remapping, payload pre-creation, file syncers — the wrong altitude for a +single-object API create) and create-empty-then-`ResetToVersion` (two change +sets, plus the `canUpdateObject` exclusions; that diff-apply shape was +Phase 3b's PUT, and it left the API entirely with it — §8.27). §7 structural blocks (title/description) stay +absent per SPEC §7 — the editor regenerates them at first open. Bundled +types/relations a document references are installed on the way (mirrors the +import path's `installBundledRelationsAndTypes`); the `origin` detail is +stamped `api`. The create response's `etag` comes from an immediate Phase-1 +read-back (best effort — failure degrades to a warning, never a 5xx after a +successful create). + +**Create-vs-reject policy (R9), explicit.** Select/multiSelect option +*names* → **create-missing** everywhere they appear as values (SPEC §3); +`typeProperties` keys on POST/PATCH types → **create-missing** (SPEC §2a); +property keys in an object's `properties` map → **reject** with a +path-addressed did-you-mean listing the space's actual keys; type keys +(`type`, `templateFor`, a set's `type`) → **reject** with did-you-mean; +set filter/sort/view property keys → **reject** unless among the queried +type's recommended keys (+ `name`), listing the type's actual keys. +Did-you-mean ranks by prefix/containment and edit distance ≤ 2. Created +side effects (real or would-be, on dry runs) are reported under `created` +in the response. + +**Types.** POST /types routes through the `ObjectCreateObjectType` RPC (it +owns unique-key derivation, recommended-relation filling/install, layout +detail, orderId, bundled-template install) with details built from the +unmarshaled type document; `typeProperties` resolves through the +create-missing resolver first. `recommendedLayout` accepts the layout +**name** (`"todo"` — the §2a worked example's shape) as well as the stored +number; note the anyblockjson exporter currently passes the stored int64 +through verbatim, so the SPEC §2a example and export disagree (flagged as a +SPEC bug, resolution pending). A type document carrying `blocks` (its +dataview) is **rejected explicitly** for now — the editor generates default +views at first open (the case SPEC §2a already blesses); silently dropping +the views would violate C11's write rule. Deferral, not a decision. + +**Sets.** The synthesized set document pins the dataview block id to +`"dataview"` (`template.DataviewBlockId`) — any other id makes the editor +add a second, default dataview at first open. The compact `filter` string +returns 501 `not_implemented` steering to `filters` (the parser is the +Phase-4 build item); `filter`+`filters` → 400 `ambiguous_input` (C6); +`views` is mutually exclusive with top-level `filters`/`sorts`. + +**Shortcut markdown.** `{type,name,properties,markdown}` creates from the +synthesized document, then lands `markdown` via the v1 block-paste path — +the one place a create takes two change sets, accepted as a convenience +until the markdown→flat-blocks parser (Phase-5 build item) exists; dry runs +validate type/properties only and say so in `warnings`. + +**Idempotency hash identifies the whole request** (corrected in v0.4 to the +shipped formula): `(space, key)` → `sha256(method ‖ 0x00 ‖ path ‖ 0x00 ‖ +rawQuery ‖ 0x00 ‖ body)`. The query inclusion means a cached `?dry_run=true` +result never replays as its later real twin; the method+path inclusion is +equally load-bearing and agent-visible — one key reused across two +byte-identical PATCHes to *different objects* (the object id lives in the +path) 409s instead of silently replaying the first object's success. Same +key with any differing part is a 409 `idempotency_conflict`, per C8's +"different body" rule read as "different request". + +**Discovery (§5) as shipped.** `GET /v2/schemas` + `/v2/schemas/{kind}` for +kinds `object · shortcut · type · template · property · set · collection · +file · filters`. AnyBlock-document kinds serve the format's embedded schema +verbatim; the rest are hand-written strict (C13) schemas; `filters` is the +documented recursive exception. Every served example passes +`anyblockjson.Validate` (enforced by test). `/v2/schemas/ops/{op}` shipped +with Phase 3 (§8.2). + +### 8.2 Phase-3 implementation notes (v0.3.4 — decisions as built) + +**The edit pipeline, redecided (v0.3.4 — supersedes v0.3.3's +document-level apply).** PATCH ops are **operations**: they apply to a +child `*state.State` of the live object and the adapter commits with ONE +ordinary `sb.Apply(st)` — exactly the Block* RPC handler model. The +v0.3.3 pipeline (marshal → mutate flat JSON → Unmarshal → +`ResetToVersion`) reset onto the live object a snapshot the format +deliberately does not fully carry (no RelationLinks, no structural +blocks, no resolvedLayout, no extra type keys) and applied it with +`NoHistory`/`DoSnapshot`/`NoRestrictions` — the Tier-A cluster of the +Phase-3 review. The state route fixes those **by construction**: a child +state inherits everything the live doc owns, Apply runs per-block +restriction checks, records undo, fires hooks/events, and emits the +minimal id-matched change diff with no forced full snapshot. The flat +document is still rendered under the lock, but only as the **read-only +view** the ops address (refs, suffixes, indent arithmetic, error texts — +the same shape agents read) and as the diff_stats input; payload blocks +are interpreted by the format package at **fragment granularity** +(`anyblockjson.UnmarshalBlocks`/`UnmarshalBlock`/`UnmarshalPropertyValue` ++ the inline codec), so only the format package ever parses AnyBlock +JSON. + +**The mutation port (v0.3.4; single-method since §8.27).** +`apicore.ObjectMutator` has ONE method. +`MutateObject(ctx, space_id, objectId, needs, apply func(edit ObjectEdit) error)` +is PATCH: the adapter locks the object, checks the object-level +Blocks/Details restrictions on the axes the batch touches, hands `apply` +an `ObjectEdit{SbType, Heads, State}` (State = `sb.NewState()`), runs the +bundled-revision guard (never downgrade `revision`; untouched keys are +simply inherited now), and commits with a plain `sb.Apply`. The +`ResetObject` sibling that carried the v0.3.3 reset-to-version machinery +(and `preserveEditorOwnedState` with it) went out with PUT — the child +state inherits everything that repair had to reconstruct, so nothing +replaced it. Dry runs never touch the mutator: the same op applier runs +on a private `state.NewDocFromSnapshot` of a plain read. + +**Ops → state, exact.** set_properties → `st.SetDetail`/`RemoveDetail` + +`st.AddRelationLinks` for the key (mandatory — a value without its link +is wiped on replay, the A1 class); values decode via +`UnmarshalPropertyValue` (dates, option names, ref lists — §3 rules). +update_block → merge on the block's exported JSON form → `UnmarshalBlock` +with the forced id → set in place, live ChildrenIds kept (non-table). +replace_subtree → fragment run → +unlink old subtree, splice the run's top blocks at the old position (id +reuse from the replaced subtree is allowed). insert_blocks → +`st.InsertTo` (after→Bottom, before→Top, inside last→Inner, inside +first→InnerFirst). move_block → `st.Unlink` + `st.InsertTo` (children +ride along). delete_block → `st.Unlink` (apply-side normalization drops +the orphaned subtree). replace_text → find/replace on the block's +document text (markup source; literal for code/embed §8.4) → +`ParseInlineText` back to text+marks. set_cell → edit the cell on the +table's document form, re-import the one table block (rows/columns/ +derived cell ids round-trip, so untouched cells land unchanged; the +internal wrapper blocks are re-minted — accepted churn, strictly less +than v0.3.3's whole-document reimport). add_items/remove_items → +`st.GetStoreSlice("objects")`/`UpdateStoreSlice`. + +**R5 post-op validity: fragment pre-checks + the restored whole-document +net** (corrected in v0.4 — the previous text described a dropped-checks +draft with an opt-in log-only net that never shipped; the shipped design +is review B′3's, see §8.3). V1 monotonicity is structural — a state tree +has no indent arithmetic to get wrong — plus the unchanged payload-run +monotonicity pre-check. Fragment payloads are validated by wrapping the +run in a minimal synthetic page document and running the format's +document validation (so the §5 shape checks apply verbatim); a failure +rejects the whole PATCH under the unchanged message "the ops would +produce an invalid document — no op was applied", with fragment-relative +block paths. Structural block types +(`title`/`description`/`featuredProperties`) are rejected explicitly in +payloads (the whole-document import would have absorbed them silently), +and no primary-dataview pinning happens on fragments. Id uniqueness — +v0.3.3's V5 net — is an explicit check against the live state and the +PATCH's own claims, with op-shaped `ops[i].blocks[j].id` paths (an +improvement over the old document paths); it also covers ids the format +keeps out of the document (table internals, structural blocks), which +the old net could not see. The op-shaped pre-checks (cycle, leaf +containment, delete-without-recursive, leaf-anchor) are unchanged, +running against the view. **On top of the pre-checks, the R5 +whole-document net is ON by default and rejecting**: +`anyblockjson.Validate` runs on the marshaled would-be after-document — +nearly free, since the after-document is already marshaled for +diff_stats — restoring the invariants no single fragment can see (V3 +row→column containment, the document-wide id domain, the absolute +nesting bound). A failure rejects the whole PATCH under the same +message. `ANYTYPE_API_V2_SKIP_EDIT_VALIDATE=1` is the **debug-only +disable** (for a suspected false rejection); there is no log-only mode. + +**Create-missing runs before the lock (v0.3.4, review B6/A6).** The only +create surface in PATCH payloads is set_properties select/multiSelect +option names; a lenient pre-pass resolves (and creates) them before +`MutateObject`, so no create-RPC ever runs while holding the edited +object's lock. In-lock resolution hits the resolver cache. Trade-off, +documented — and narrowed in v0.3.5 (review A′1, §8.3): the prewarm now +runs only after the object read and the precondition checks pass, so the +leak is confined to ops that fail **validation** later; a PATCH to a +nonexistent/restricted object or with a stale If-Match no longer creates +the options it named (v0.3.3 leaked the same way for post-Unmarshal +failures). + +**diff_stats stay the canonical document diff.** Considered and rejected: +deriving them from `st.GetChanges()` — the change list only exists after +a real Apply, so dry runs (which never Apply) would diverge from real +runs, breaking C9's dry≡real contract. The before/after documents are +rendered under the lock anyway (the view), so the numbers are unchanged — +and with the Tier-A churn gone from the emitted changes, the diff is no +longer blind to anything real (C9's concern). + +**Untouched state is untouched (v0.3.4).** Because nothing round-trips +the whole document anymore: relation links, structural blocks, +resolvedLayout, extra type keys, hidden legacy children (link/bookmark/ +content-less blocks), big integers in untouched blocks' fields, stored +option ids — all simply inherited by the child state. The C11 marshal +guard remains on PATCH (a loss warning on the live state still refuses +the edit — the view itself would be lossy), and B8's float64 concern is +narrowed to the one block an op re-imports. + +**C11 write-safety guard.** If the internal marshal of the live state +reports any loss warning (unmapped block, over-deep nesting), the PATCH is +refused (422) — otherwise the write-back would silently drop the +unrepresentable content, exactly what C11 forbids. PUT used to skip this +guard (a full replace being explicitly destructive) and surface the +warnings instead; with PUT gone (§8.27) the guard has no exemption left, +and the 422's advice is now "edit it in the app" rather than "replace it +wholesale". + +**diff_stats.** Canonical-before vs canonical-after document diff (the after +side is the applied snapshot re-marshaled, so import/export normalization +cancels): added/removed by block-id set; changed = same id, different +content (block JSON minus indent/id); moved = parent changed OR the nearest +*common* preceding sibling changed (pure insertions don't mark their +followers moved). `properties_changed` counts differing keys. +`add_items`/`remove_items` changes are not counted (no diff_stats field for +membership; deliberate — the schema is closed at the five integers). + +**Relative indent (R3), exact.** Payload `indent` 0 = the anchor's level +(after/before/replace_subtree) or the container's child level (inside); +payload runs must start at 0 and obey +1 monotonicity internally, checked +with `ops[i].blocks[j].indent` paths before the document-level net. +`insert_blocks` inserts after the anchor's whole subtree for `after`; +`inside` defaults `position` to `last`; `position` with `after`/`before` is +an error; `position` with NO targeting field names an end of the document +(§8.32). `move_block` moves the whole subtree and re-bases its indents. + +**Small op decisions.** `update_block`: `set` is merge; explicit `null` +clears a field; `id`/`indent` in `set` are rejected (steering to move_block); +the addressed id and indent survive a retype. `replace_subtree` +mints fresh ids for id-less payload blocks (the old subtree's ids die with +it). Every payload block — minted or client-supplied — lands in +`created_blocks` keyed `ops[i].blocks[j]`. `replace_text` requires a +text-bearing type and a non-empty `find`; error texts are the Anthropic +shapes ("no match found…", "found N matches — provide more context…"). +`set_cell` resolves row/col ids with the same unique-suffix leniency as block +refs and accepts all §6.1 cell forms (string, null, block object, array); +invalid inner shapes fall to the R5 net. `add_items`/`remove_items` require a +collection (type `collection` or a collection-layout type), dedupe/no-op +respectively, and do not existence-check member ids (v1 parity). +`set_properties`: §4a output-only keys rejected (`isFavorite` stays +authorable per SPEC §3), unknown keys rejected with did-you-mean (Phase-2 +policy), select option names create-missing and ride `created`, `unset` of +an absent key is a no-op, a key in both `set` and `unset` is an error. + +**PUT — REMOVED (§8.27).** As built it stripped the envelope +`etag`/`warnings` a GET body carried, rejected a non-matching envelope +`id`, kept the live object's type when `type` was absent, and ran the R9 +referential layer like create (with a corpse-key tolerance so a GET→PUT of +the same bytes round-tripped). All of that left with the surface; the +envelope-stripping half survives on the create path (`normalizeCreateBody`, +so a pasted read body clones), the corpse tolerance did not — a PATCH names +only the properties it edits. `canUpdateObject` mirrored as smartblock-type +exclusions (relation, relation option, file object, participant) remains, +on PATCH. + +**Structural blocks.** As with Phase-2 creates, SPEC §7 structural blocks +(title/description) are absent from the format, so an edit re-lands the +document without them; name/description content lives in `properties` and +survives, and the editor regenerates the header blocks (same §7 contract). + +**Per-op discovery (§5) as shipped.** `GET /v2/schemas/ops/{op}` for the +launch ops; each schema is C13-strict and self-contained, with a shared +payload-block definition covering the realistic edit fields +(`additionalProperties:false` — the full block inventory stays at +`/v2/schemas/object`, which the def points to; the block-type vocabulary +itself is published in the def since §8.32). Every example is a full +single-op PATCH body (enforced by test) — *changed in §8.32: the example is +now the op object itself, an instance of the schema served beside it.* The +index (`GET /v2/schemas`) grew an `ops` list. + +### 8.3 Phase-3 revisions (v0.3.5 — pre-release design review, decisions as built) + +Six contract changes from the modification-surface design review, taken +while the API is unreleased and breaking changes are cheap. + +**Root targeting for `insert_blocks`/`move_block`.** Omitting all of +`after`/`before`/`inside` appends at the end of the document root (state: +`InsertTo("", Block_Inner)`). Chosen shape: the omitted-anchor form, not an +explicit `at: "start"|"end"` field — fewer fields for a small model, and it +is exactly the §7 wrapper's omitted-`after` case. *Revised in §8.32:* +`position` is no longer refused here — with no targeting field it picks the +end of the document, so `first` is the root-PREPEND this paragraph declined +to build (anchored before the first document block, never at the state root, +which carries the §7 header as its first child). This closes the structural +hole where an empty object (SPEC §7: no title/description blocks in the +document) had zero addressable anchors and PUT was then the only way to give +it content — which is also why removing PUT (§8.27) cost the surface nothing +here. More than one targeting field is now "at most one of after, before, +inside is allowed" (was "exactly one … is required" — reworded because zero +is legal now). +Payload indents stay R3-relative: at root, indent 0 = document top level. + +**`replaceBlock` removed (BREAKING, deliberate — the API is unreleased).** +Four routes to changing a block's text (update_block/replaceBlock/ +replace_subtree/replace_text) was the surface's largest disambiguation load, +and `replaceBlock`'s silent text-wipe (a checkbox toggle via replaceBlock +losing the text) was the documented small-model trap; BlockNote and Tiptap +each ship ONE block-update op. `update_block {id, set}` — merge with +explicit-null-clears — expresses everything replaceBlock did except the +wipe (a full wipe is `set` naming every field, `null`ing the rest, or +`replace_subtree`). The op set is 10 ops. An agent that sends `replaceBlock` +gets the unknown-op error with a hint that names update_block's semantics +before listing the allowed ops. All other error texts are unchanged. + +**`set_properties` per-key `add`/`remove`.** Only for list-shaped formats +(select/multiSelect/objects/files); scalar-format keys are rejected +path-addressed, naming the format. `add` resolves entries with the same +create-missing option-name semantics as `set` — including in the pre-lock +prewarm, which scans `add` alongside `set` so no create-RPC runs under the +object lock. `remove` resolves entries READ-ONLY (store-backed resolver): +a remove must never mint the very option it names; unresolved names match +nothing. `remove` of the last entry leaves the key present-but-empty +(`unset` removes presence); `remove` of an absent key stays absent. SPEC §3 +presence semantics unchanged: `set: []` still means present-but-empty. Key +validation matches `set` (output-only rejected, unknown keys did-you-mean); +a key in more than one of set/unset/add/remove is a path-addressed error. +The empty-op error is now "set_properties needs at least one of set, unset, +add, remove". + +**Idempotency-Key covers PATCH (C8 widened).** The store, request hash, +in-flight reservation and replay were POST-only wiring; agents auto-retry +on timeout, and PATCH is where a blind retry does damage (a retried +successful `insert_blocks` duplicates blocks; a retried `delete_block` 404s +misleadingly). The middleware acts on every mutation METHOD — POST, PATCH, +PUT, DELETE — and is registered on the object PATCH route and the +types/properties PATCH routes. (PUT stays in the method set although +§8.27 left v2 with no PUT route: the switch classifies methods, so a +future mutation method is covered by construction.) Semantics unchanged: same key + same request (the §8.1 hash — +method ‖ path ‖ query ‖ body) ⇒ replay; any differing part ⇒ 409 +`idempotency_conflict`; only 2xx results are cached. + +**The R5 whole-document net restored (review B′3).** The state pipeline's +fragment validation cannot see invariants that span the spliced result — +V3 row→column containment, the document-wide id domain, the absolute +nesting bound — so the whole-document `anyblockjson.Validate` runs on the +marshaled would-be after-document **by default** and **rejects** the PATCH +on failure (same agent-facing message; nearly free — the after-document is +already marshaled for diff_stats). `ANYTYPE_API_V2_SKIP_EDIT_VALIDATE=1` is +the debug-only disable. §8.2's post-op-validity paragraph is corrected +accordingly (it previously described a dropped-checks draft with an opt-in +log-only net that never shipped). + +**Edit-path ordering and dry-run parity (reviews A′1/C′3).** +`apicore.ObjectRead` carries the per-axis restriction verdicts +(`BlocksRefused`/`DetailsRefused` — per-op since surface review M1) — +captured under the same locked read as the snapshot and heads — checked +before the prewarm and on dry runs, so a dry run reaches the same 403 the +adapter would return (C9's dry≡real contract now covers restrictions; the +refusal really is a 403 since surface review M2a — see §8.12). And the create-missing prewarm runs only AFTER the object +read and the precondition checks pass (read → preconditions → prewarm → +lock): a PATCH to a nonexistent or restricted object, or with a stale +If-Match, no longer creates the options it named — §8.2's documented +option-leak trade-off is thereby narrowed to validation failures only. + +### 8.4 Phase-4 implementation notes (decisions as built) + +**The parser** (`pkg/lib/anyblockjson/filterstring`). Recursive-descent +over the §6.2.1 grammar; emits the §6.2 structured filters ARRAY as +canonical JSON — the literal convergence point: the API feeds either the +client's structured array or the parser's output through the same +`anyblockjson.UnmarshalFilters` call, so there is exactly one +JSON-tree→model translation. Decisions a reader of §6.2.1 needs: keywords +match case-insensitively (canonical rendering stays uppercase — small +models write `and`); the keywords are reserved words, rejected as property +keys; RFC 3339 → unix conversion happens at parse time and only for keys +whose format resolves to `date` through the wired resolver (a date-looking +string on a text property stays a string; a non-RFC-3339 string on a date +property is a parse error steering to the preset functions); a preset +function on a non-date key is a parse error naming the actual format; the +counting presets require a whole non-negative operand and keep it as +`value`; presets are excluded from value lists; set literals require `=` / +`!=` (a list after an ordering operator errors). Reference sets are wired +per call site: `KnownKeys` (offset-addressed unknown-key error + +did-you-mean), `KnownOptions` (read-only option names — the QUERY path +wires it; the SETS-CREATE path deliberately does not, because a set create +is a write where option names create-missing per R9/§8.1). Every error is +`*filterstring.Error{Offset, Token, Message, Hint}`; the API maps it to +one C6 issue at `/filter` carrying the offset text. The EBNF the parser +pins is exported (`filterstring.EBNF` + `Examples`) and served on the +`filters` discovery kind (§5) via new `grammar`/`grammar_examples` fields +on the schema-entry payload — every served example is +asserted-parseable by test. + +**The fragment codec** (`anyblockjson.UnmarshalFilters`/`UnmarshalSorts`). +Validates the enum vocabulary (conditions, datePresets, directions, +emptyPlacement, operators) with `/filters/i/…`-`/sorts/i/…` paths, then +reuses the document path's per-view semantic checks (counting-preset +operand, placeholder-on-non-object rule, and the unguarded-date-comparison +WARNING — the same text, riding `Options.OnWarning`, which the search +handler forwards onto the response `warnings`; for the string form the +issue path is remapped to `/filter`). Conversion runs through the same +importer the whole-document dataview path uses, so option-name→id +resolution and format rehydration behave identically on both request +forms. + +**Search execution.** One `searchPlan` per space: reference set (rules +1–2, plus `type` as a pseudo-key), both filter forms → one model tree, +read-only option pre-validation (rule 3 — structured form path-addressed +here, string form offset-addressed in the parser), `type`-leaf key→id +resolution AFTER the shared codec (both forms converge before it), and an +**effective sort list**: explicit sorts win; a full-text query without a +score sort gets `_final_score desc` appended as tiebreak (which also +stops the engine from PREPENDING its own score sort — explicit sorts stay +primary under full-text); no sorts and no query defaults to +`lastModifiedDate desc` (the ListObjects order). Non-text queries run +`QueryAndCount` (store-side paging + honest total); full-text queries +materialize the (candidate-bounded) result set and page in memory — the +engine's `QueryAndCount` cannot do fulltext, and `total = len(matches)` +of the materialized set is the honest count within the engine's +documented candidate budget. Base row scope mirrors ListObjects (object +layouts, no templates, no hidden). Global search merges per-space pages +by the effective sort list with a value comparator (no locale collation — +an accepted approximation of the store's order; ties break by space id +then object id for determinism); per-space failures skip the space with a +"space X was skipped: …" warning, and only when NO space resolves does +the first per-space error become the response. Global rows carry +`space_id` (addressing info for the follow-up read — a deliberate C5 +extension). `V2ListResponse` gained `warnings` (C6-shaped, deduped). +The request schema is strict: an unknown body field 400s, and +`limit`/`offset` in the body get the C10 steering hint. Search routes +carry no idempotency middleware (asserted by a router test: the same +keyed authorized search executed twice runs twice and never returns an +`Idempotency-Replayed` header — the earlier 401-based assertion was +vacuous, since group auth aborts before any route middleware) and the +handlers never read the dry-run flag. + +**Sets/collections reads.** Layout is read from the live snapshot's +`resolvedLayout` (the same locked read as the content); the wrong-layout +400 names the other route verbatim. A set's `setOf` resolves like +dataview sources (unique keys, type ids, relation ids → `type In` / +`NotEmpty`, OR-combined); an EMPTY or unresolvable `setOf` is an explicit +400 ("queries nothing"), never an unscoped full-space query. A collection +without stored-view sorts reads in store-slice order: the matching +members are fetched in one `id In` query and reordered to the slice +in memory (honest `total` = matching members; dangling ids drop out); a +stored view's sorts override membership order via the store-side path. +`?view=` resolves by exact id or unique suffix (the C4 leniency); the +0/2+ errors list the view ids / steer to the full id. `?fields=` is +validated like search's `/fields` (rule 1 over the space's property keys + +the system allowlist, 400 + did-you-mean at path `fields`) — a typoed key +must never degrade to rows that silently carry no properties. Placeholder +substitution runs on the per-read snapshot copy (never live state): +`_filter_template_2_` → `domain.NewParticipantId(space, account)` — the +account identity rides `V2Deps.AccountId`, probed from the account +component (the `apicore.AccountService` port stays GetInfo-only); an +unresolvable placeholder (unknown index, or a missing account identity) +DROPS its leaf and warns — evaluated literally it would match nothing, +which is v1's silent-empty-result bug. Groups whose children all drop are +dropped. The views read renders the live dataview block through +`MarshalBlockSubtree` (no compaction — a fragment has no refs legend), so +views come back in the §6.2 vocabulary with option names resolved. + +### 8.5 Phase-4 review fixes (decisions as built) + +Changes landed after the four-lens Phase-4 review; everything here is +agent-visible surface or a resource bound. + +**Parser bounds and lexing** (`filterstring`). The input is capped at +4096 bytes (the schema's advertised `maxLength`) and parenthesis nesting +at 32 (the §4 document bound) — before these, a paren-bomb `filter` +overflowed the goroutine stack, a runtime FATAL that gin.Recovery cannot +catch. The lexer decodes full runes: non-ASCII property keys (`café`, +`дата`) are ordinary identifiers, and "unexpected character" names the +rune the caller wrote, never a stray byte. Date presets are rejected on +the conditions the engine's `transformDateFilter` would silently drop +(everything but `= > < >= <=` — notably `!=`), addressed at the preset +name token; the counting presets bound their operand to `[0, 36500]`. +Unterminated-string errors echo at most 32 runes. New steering: a single +quote hints the double-quote form; a reserved-word key and a known key +the syntax cannot spell (hyphen/space/keyword collision) both steer to +the structured `filters` array. Option-name validation SKIPS when the +store cannot list a property's options (`propertyOptionNames` reports +ok=false) instead of asserting "no such option" about unread data. + +**Structured-form date values.** `validateStructuredFilters` rejects a +string value on a date-formatted property, path-addressed at +`/filters/i/value`, spelling out the conversion (`the structured form +takes unix seconds (1785542400), not "2026-08-01"`) and steering to the +filter string / a `datePreset`. Before, the string survived to the store, +compared string-against-int64 and silently matched nothing (`less` +inverted through the quick-option transform matched everything) — the +exact rule-3 hazard, on the form the parser could not protect. A +convergence test now asserts both request forms compile to the identical +filter tree for dates, presets, set literals, booleans and the `type` +pseudo-key. + +**Full-text pagination.** The full-text store query carries +`Limit: offset+limit+1` so the engine's candidate-budget escalation sees +the requested page — with `Limit: 0` the budget froze at the 100-doc +floor: `total` capped near 100 and the page after ~page 4 came back empty +with `has_more: false`, silently ending enumeration. The +1 record makes +the reported `total` exact when the store had fewer matches and a lower +bound (`has_more: true`) when it clipped; truncation beyond the engine's +2000-doc candidate hard limit remains the documented approximation. + +**Global search bounds.** `offset` is capped at 2000 (400 with steering +to the space-scoped search — the merge materializes offset+limit rows +per space). An unknown `fields` entry no longer drops a space from +results and `total` (fields are display, not scope): the space is +queried and a warning notes the omitted column; filter/sort keys keep +the skip-with-warning semantics. Reference sets are computed lazily — a +bare `{"query": …}` fan-out no longer pays a full relation listing per +space. `spaceRefs` filters space views to live ones (v1 ListSpaces' +status predicate), so a removing/deleted space no longer gets an index +minted as a search side effect. The search handlers cap the body at +1 MiB (413 `request_too_large`) — the search routes carry no idempotency +middleware, so nothing else bounded the read. + +**Sort granularity.** A date-formatted user sort that omits +`includeTime` defaults to second granularity (matching the default +`lastModifiedDate` sort), so ordering no longer changes with the +presence of the full-text tiebreak (the store's `isSingleDateSort` +compensation only fired on single-entry sort lists). An explicit +`includeTime: false` is honored. + +**Sets/collections.** `?fields=` is validated (see §8.4). The collection +membership reorder is O(n log n) with one details read per record (the +insertion sort was O(n²) over the whole membership). POST /sets answers +a `type` filter leaf — which discovery's shared grammar example invites — +with a targeted 400 (`a set is already scoped to type "chore" — drop the +type filter`) instead of "unknown property key". Issue-path convention, +now uniform: JSON pointers (`/filters/0/value`) address the request +BODY; bare names (`view`, `fields`, `offset`, `dry_run`) address query +parameters. + +**Warnings semantics.** Response `warnings` are advisory — they never +require a retry. The unguarded-date warning is suppressed when an OR +sibling carries `IS EMPTY` on the same property (the filter's own text +declares the empties intended), so the canonical worked example no +longer warns on every execution. + +**Discovery.** The `search` and `set` kinds no longer embed the +recursive structured `filters` array (an array without `items` breaks +every constrained decoder — the C13 exception would otherwise have +swallowed the whole kind); their `filter` string description points at +kind `filters` for the escape hatch, which the endpoints still accept. +The `filters` kind documents that date values are unix seconds. The EBNF +defines `identifier`/`number` and states keyword case-insensitivity +in-grammar; a test pins every parser-accepted token to the served text. + +### 8.6 Phase-5 implementation notes (decisions as built) + +Phase 5 shipped the §7 task-tool wrapper: one tool table in +`core/api/wrapper` delivered as the CLI verb-set (`cmd/anytype`) and the +machine-readable function-calling manifest — plus the three server +primitives it rides (the markdown payload, the create fold, `/members/me`). + +**Packaging (judged, recorded).** The manifest is Go data +(`wrapper.Tools()` — name, description, typed args, one C12 example), not +a standalone JSON file: it references op names, routes and error texts +that must move in lockstep with the handlers, and the CLI imports the +table so verb set == tool set *by construction* (a test asserts the +executors map and the table agree; another asserts every example +validates against its own args). `wrapper.BuildManifest()` renders the +JSON delivery: per tool `{name, description, parameters (strict C13 +schema), example, gbnf}` + the filter grammar artifact; `anytype tools` +prints it. The tools call the API **over localhost HTTP**, not in-process: +the CLI is out-of-process by nature, and HTTP keeps one enforcement point +— auth, write rate limit, and crucially the C8 idempotency store live in +server middleware, which in-process service calls would silently bypass. +Two surfaces, not three: the wrapper stays a client of `/v2`. An MCP +server binary was NOT built in this phase — it landed later as the tiered +`anytype mcp` verb (§8.20), the long-lived host that constructs the same +Runner with the in-memory session store. + +**The markdown channel (server-side, as §7.1 decided).** The parser is +`anyblockjson.ParseMarkdownBlocks` — in the format package root, the +block-level sibling of the §8 inline codec: it slices markdown into a §4 +flat run and passes inline text through VERBATIM as §8 markup source, so +authoring and reading stay on one dialect (goldmark/anymark was rejected +for exactly that reason — its inline semantics are not §8's). It never +fails: unknown constructs degrade to paragraphs, over-deep indents clamp +by the §4 lenient rule — AND by the two containment rules the +1 clamp +alone would break (post-review fixes): a line after a §5 leaf block +(divider, table) stays its sibling, and the F4 depth bound of 32 caps +every level — so a run always imports (tested through `UnmarshalBlocks` +over every block type the parser can emit, plus a fuzz target). +`insert_blocks` gained the `markdown` payload (mutually exclusive with +`blocks`, same targeting incl. root-append; the op schema's `required` +dropped to `op` with exactly-one enforced server-side, like the targeting +exclusivity). The parsed run is CAPPED at 256 blocks per op — the blocks +channel's own maxItems, shared so the byte-bounded markdown channel +cannot smuggle ~350k blocks per MiB — and at 2048 on the create shortcut; +the bounded parse stops early, and the error names the limit. +`created_blocks` keys read `ops[i].markdown[j]` — j = the parsed position, +the honest analogue of the blocks[j] payload position. The create +shortcut folds parsed markdown into the create snapshot: ONE change set; +the §7.2 caveats paragraph is historical. Create-shortcut validation +issues arising from markdown-derived blocks readdress `/blocks/` to +`/markdown[]` (the caller never wrote a blocks array — C6), and +whitespace-only markdown on create is the same `markdown produced no +blocks` error the op path gives, not a silent empty-object 200. Scope +bounds (deterministic over clever, in the parser's file comment): ATX +headings only (`---` is always a divider), one quote level, +2-spaces-or-tab list nesting, tables need the separator row (a closing +fence marker tolerates ≤3 leading spaces; fence info strings are +constrained to a language-ish token), no image→file-block mapping (file +ids come from POST /files). + +**The reference channel.** `find` numbers rows 1..N and persists +`{space, handles}`; every find renumbers (§7.4) and prunes labels of +objects no longer referenced. Full `read` relabels 24-hex block ids — +and table ROW and COLUMN ids, the same bson-hex shape `set_cell` takes +(post-review; uniqueness is computed over the whole pool) — to +shortest-unique-SUFFIX labels (min 5 chars — the same uniqueness rule as +the server's `matchBlockRef`, pinned to it by test, so labels pass +through writes even unresolved) by textual replacement over the +canonical document (key order survives), retaining label→full-id per +object. Writes resolve labels client-side when retained; the **ambiguity +retry** re-reads and retries ONCE with the SAME Idempotency-Key when the +ref resolves uniquely against the current document — §7.4(d) states its +honest scope (the concurrent-modification race); a persistent ambiguity +surfaces the server's error untouched. Every mutation receipt names its +resolved target (`ok — "Groceries": 1 changed`), so a find that +renumbered handles between composing and running a call is visible in +the transcript. Handle state lives in a session file for the CLI +(`os.UserCacheDir()/anytype-cli/session.json`, `ANYTYPE_CLI_SESSION` +overrides; corrupt files start fresh, never brick; saves are atomic via +temp-file rename) and in memory for long-lived hosts — the `Store` +interface is the seam; `Run` serializes on a Runner mutex and +`MemoryStore` hands out deep copies, so concurrent tool calls in a +long-lived host cannot race the session maps. + +**Idempotency (the §7.3 machinery, placement decided; identity corrected +post-review).** Every mutation mints a random key; transport errors and +429/502/503/504 resend the exact body with the same key (max 3 attempts, +1s/2s backoff — the server's write budget is 1 req/s — and an exhausted +retryable status surfaces the server's LAST error body, not a bare +status). The reuse identity is the RESOLVED request — sha256 over method ++ path + encoded query + marshalled body, the server's own C8 identity — +NOT the tool name + raw args (the as-first-built form: it made a dry run +and its real twin, or one call re-addressed by a re-find, share a key, +which the C8 store answers with 409 `idempotency_conflict`). An identical +resolved request repeated within 60s reuses the previous key +(`Session.LastWrite`) — a regenerated-retry or a harness re-run after a +timeout OR FAILURE (the session, key included, is saved on the error path +too), and C8 replays it; after the window an identical request is +presumed intentional and applies fresh. A successful ambiguity retry +re-stamps the key onto the rewritten request so a later +client-side-resolved re-run still replays. A pure body-hash key (the +review's sketch) was rejected: it would make every intentional repeat +replay forever. The task tools never send If-Match (C7 advisory — sync +noise 409s a small model cannot answer); the CLI exposes `--if-match` +for scripts. + +**Conveniences, as placed by §7.3.** `@me` resolves through the new +`GET /v2/spaces/{space_id}/members/me` (participant id is deterministic — +served even before the participant object is indexed; no account identity +→ 404 steering to the members list), cached per space in the session; in +`find` filters the quoted `"@me"` value substitutes textually, and in +property VALUES it substitutes on object-FORMAT keys only (post-review — +a description literally containing "@me" is data, not an identity). +Relative dates resolve wrapper-side on date-FORMAT keys only (the wrapper +loads the space's property formats ONCE per tool call — set+add+remove +share the fetch): `today`/`tomorrow`/`yesterday`, weekday names (next +occurrence, today included), `±Nd` — to RFC 3339 local midnight; anything +else passes through literally for the server to judge. The **A2 option +guard** pre-validates select/multiSelect names in `create`/ +`set_properties` `set`+`add` (never `remove` — the op cannot create) +against the live options with a did-you-mean; `--create-missing` is the +deliberate CLI escape to the REST R9 semantics. `describe` marks a FAILED +option listing per property (`optionsUnavailable`, rendered as "could not +be listed — run describe again") instead of showing an optionless select +that invites an invented name. + +**Tool-set deviations from the §7.2 table (recorded there too).** +`spaces` was added post-review as the 12th tool (bootstrap: nothing else +could produce a space id; still under the 15-tool cliff). `set_cell` +takes `object` — the REST op addresses a table within one object; a bare +table handle would need hidden cross-object state. The wrapper's `under` +maps to the ops' `inside` (+`position: last`), and server error texts +are translated BACK to the tool vocabulary before the model sees them +(`inside`→`under`, `id`→`block`, `table_id`→`table`, the `ops[0].` prefix +stripped, `?outline=true` hints become `read mode=outline`) — without +this the server's own repair hint names a field the tool rejects. +`edit_text` deliberately has no `replace_all`; its `replace` and +`set_cell`'s `value` are required-but-may-be-EMPTY (`AllowEmpty`: +deleting a phrase and clearing a cell are first-tier intents; empty +`value` sends the op's documented null-clears), and the wrapper's error +text distinguishes a missing arg from an empty one. `describe` takes +`space, type` (every backing GET is space-scoped) and ships in the +§2-sanctioned degraded form: `GET /types/{type}` + live option lists, +composed wrapper-side (25 options per property, truncation marked); the +`GenerateSchema` §3 item stays open and collapses this to one GET when it +lands. `find` has no `fields` arg (C5 minimal rows only) and search +carries no idempotency key (a read). The wrapper's route templates are +exported (`RouteTemplates`) and asserted against the gin router in the +server suite, so a renamed /v2 route fails loudly instead of 404ing every +tool in production. + +**GBNF (§7.4, kept honest by test).** Per-tool grammars are GENERATED +from the Arg table: the argument object with required args first in +declared order, then each optional arg as an independently omittable +`("," pair)?` group — a pinned key order, which is what constrained +decoding wants (an optional-only tool like `spaces` emits a nested +optional chain so no comma dangles). The served C12 example is +pre-rendered JSON in that SAME order (post-review: a Go map serialized +alphabetically, so 9 of 11 examples were not in the language of the +grammar shipped beside them). The filter-string GBNF is transcribed from +the pinned `filterstring.EBNF`, constraining to the canonical surface +(uppercase keywords, camelCase presets, ASCII keys; the parser stays +lenient); its leaf REQUIRES whitespace between a key and a word-led +condition (post-review — `titleEXISTS` was grammar-legal but +parser-illegal; `key` still admits reserved words, a documented GBNF +limitation). It is served as a SEPARATE artifact beside find's grammar: +composing a DSL into a JSON-string production would require re-escaping +every DSL quote through the JSON encoding — a transformation GBNF cannot +express; the seam is documented on the artifact. A GBNF well-formedness +checker (rule syntax, terminated literals/classes, balanced groups, no +undefined references, root present) runs over every served grammar in +tests, over broken grammars to prove it catches breakage — and a +test-only backtracking GBNF MATCHER asserts every served example against +its own served grammar and the filter grammar against +`filterstring.Parse` (examples in, the pre-fix false positives out). + +**SKILL.md** lives at `cmd/anytype/SKILL.md` (frontmatter description → +body → references three-tier): the spaces→find→describe→read loop, the +E4 intent→verb recipes (complete-a-task steers to `set-properties`, NOT +`check-item`; delete-a-phrase and clear-a-cell via the empty-string +forms), filter-string examples, and the caveats (D′1 markup source, +options-never-created, handle renumbering + receipts naming the target, +no batches, safe retries incl. after failure). B4 tunes this text per +tier once the benchmark runs. + +### 8.7 Phase-6 implementation notes (chats — decisions as built) + +Phase 6 gave chats their /v2 home (the completeness decision, 2026-08-06: +a v2 client never types /v1 for its task loop — but never the same shape +at a new URL). The phase's motivating finding held under verification: +`ChatGetMessages` returns `chatState` and `message_count` and the v1 +service throws both away (`service/chat.go:100` reads only +`resp.Messages`), and NO v1 response carries a state id (the DTO omits +it; the SSE converter has no ChatStateUpdate case, +`model/chat.go:279-314`) — so the `ReadChatMessagesRequest.last_state_id` +race guard was unreachable by construction. v2 passes both through and +`POST read` forwards the guard. + +**Endpoints** (`server/router.go registerV2ChatRoutes`; handlers +`v2/handler/chat.go`, service `v2/service/chat.go`, DTOs +`v2/model/chat.go`): + +``` +GET /v2/spaces/{space_id}/chats # C5 rows {id,name} +POST /v2/spaces/{space_id}/chats # {name} → row +GET /v2/spaces/{space_id}/chats/{chat_id}/messages # ?after&before&limit&reactions +POST /v2/spaces/{space_id}/chats/{chat_id}/messages # {text, reply_to?, attachments?} → {id} +PATCH /v2/spaces/{space_id}/chats/{chat_id}/messages/{message_id} # {text} — text-only merge +DELETE /v2/spaces/{space_id}/chats/{chat_id}/messages/{message_id} +POST /v2/spaces/{space_id}/chats/{chat_id}/messages/{message_id}/reactions # {emoji} → {added} +POST /v2/spaces/{space_id}/chats/{chat_id}/read # {up_to, last_state_id, scope?} +``` + +**The three reshapes, as built.** (1) *State passthrough*: the messages +read returns `{messages, state, message_count, has_more, next_after?, +next_before?}`; `state` carries +`unread_messages/unread_mentions/oldest_unread_order/oldest_unread_mention_order/ +unread_reaction_order/last_state_id`; `message_count` is the chat's LIFETIME +total, not the range size — `has_more` (fetched via limit+1, the +ListChats pattern) plus the boundary cursors are the range signal, so an +agent never infers "more" from `len==limit` (C10's spirit). A poll is a +`limit=1` read; the exit flow "summarize what's new and mark it read" is +two calls (GET messages → POST read `{up_to, last_state_id}`). (2) *Inline +markup both directions*: read renders `text` via +`anyblockjson.RenderInlineText` (mentions as `` +tags); write parses via `ParseInlineText` — the D′1 caveat is documented +on both write endpoints; offset mark arrays never cross the API. `style` +is dropped on read and not accepted on write (a fresh message is always +`paragraph`; an edit preserves the stored style). Block-composed content +(desktop quotes, rich pastes — a message may be VALID with empty text +and only blocks, `chatmodel.Validate`) surfaces read-only as +`blocks_text`: text-bearing blocks rendered as §8 markup, newline-joined; +link/embed blocks stay invisible (recorded deferral). (3) *C5 rows + +compact reactions*: chat rows are `{id, name}` (Q3 as recommended — +counter-free list; computing list-wide counters means opening every +chat, the GO-7302 cost; `ListChats` is a pure store query over +`ChatLayouts` and its test would fail on ANY RPC call). Reactions are +ALWAYS the counts map `{"👍":2}` (Q4 as recommended); +`?reactions=full` adds `reacted_by` — **participant-id** lists (one +vocabulary with `author_id`, C2), never raw identities — in its OWN slot, +so neither field ever changes type (C2: the review killed the original +polymorphic `reactions` slot, which no strict response schema could +describe). + +**C7 exemption, stated.** No chat response carries an etag and no chat +mutation reads If-Match: order ids and `last_state_id` are the chat's +native concurrency vocabulary. Documented on every chat endpoint (the +deliberate exemption class search's C8/C9 one established). + +**C8 widened to DELETE (additive contract change, recorded loudly).** +The surfaces doc demands idempotency on *every* chat mutation; C8's +method set was POST/PATCH/PUT (PUT's route later went away — §8.27 — but +the method stays in the classifier). `ensureIdempotency` now also acts on +DELETE — and after the Phase-6 review, EVERY registered v2 DELETE +carries the middleware: chat message, type and property alike. (As +first built, only the chat delete was keyed; the review called the +route-dependent behavior an invisible contract — an agent that +dutifully keys every mutation, as the Phase-5 wrapper does, must not +get replay on one DELETE and silently none on another. The router test +pins all three.) A blindly retried v2 delete used to 404 misleadingly; +now it replays. + +**Decisions the spec left open.** + +- **`up_to` AND `last_state_id` are BOTH REQUIRED for scopes + messages/mentions.** The RPC's range query bounds with + `orderId <= beforeOrderId` AND `stateId <= last_state_id` + (`chatrepository/repository.go:387-392`), and every stored message + carries a non-empty bson state id (`chathandler.go:116`) — so an + empty value for EITHER selects nothing and returns success, the + silent no-op trap (v1's `read_all` path sends exactly that shape; the + phase's first build required only `up_to` and the review reproduced + markedCount=0 with a 200 when `last_state_id` was omitted — the same + trap one field over). Both values ride the same GET messages response + (the newest order + `state.last_state_id`), so requiring both costs the + agent nothing; the missing-field 400 is path-addressed to each. + Server-side filling was REJECTED: a guard resolved at POST time is + newer than the client's read and would mark late-arriving unseen + messages as read — silently weakening the exact race the guard + exists to close. There is deliberately no "mark all" form. (The RPC + gives v2 no marked count to return — `RpcChatReadMessagesResponse` is + empty and `chats.Service.ReadMessages` discards it — so a + zero-effect read still cannot be surfaced in the response; requiring + both bounds closes every known silent path instead.) +- **The reactions scope is all-or-nothing.** `ChatReadReactions` + *ignores* its `orderId` (`core/chats.go:325` calls + `ReadReaction(ctx, chatObjectId)` without it), so the planned + "`up_to` → read reactions" forwarding is impossible today; scope + `reactions` rejects `up_to`/`last_state_id` with path-addressed 400s + rather than pretending a bound exists. +- **Chat RPC failures are classified in v2 — the RPC codes are dead.** + `core/chats.go` maps no chat errors (`mapErrorCode` with no + `errToCode` entries returns UNKNOWN_ERROR for everything), so the + BAD_INPUT→400 branch never fires and, as first built, every caller + mistake was a retry-looping 500. Three layers now close it: (a) v2 + pre-validates what it owns — parsed text length against + `chatmodel.MaxMessageLength` (8000 UTF-16 units) as a path-addressed + 400 on `/text`, and the attachment cap (32, the schema's maxItems) on + `/attachments`; (b) both DELETE and the reaction toggle run the + existence read on the COMMITTING path too, so a missing message 404s + identically to its dry run (the store handler treats deleting a + missing doc as success — without the check the real DELETE answered + 200 for a deletion that never happened, C9 broken); (c) + `v2ChatRpcError` classifies the remaining RPC descriptions: + `validate: …` → 400 validation_failed, `not found` → 404, and the two + foreign-message refusals → **403 `forbidden`** (a NEW C6 code, + additive — C6's list is non-exhaustive "include"). The foreign-message + arms match through the producers' exported sentinels + (`chatobject.ErrModifyForeignMessage` — the EDIT wording, + "can't modify someone else's message" — and + `chatobject.ErrDeleteForeignMessage`, "can't delete not own message"), + so a middleware rewording updates the classifier at compile time; as + first shipped only the delete prose was matched and a foreign EDIT + fell through to a retry-looping 500 (surface review M2b, fixed with a + test that feeds the string the edit path really produces). Anything + else stays 500 with the description carried. +- **PATCH message is a read-merge.** The middleware edit replaces the + whole message content — chatmodel `content` = + `{message, attachments, blocks}` (`chatmodel.go:441-444`) — so a naive + `{text}`-only forward would WIPE attachments on every text edit. The + service reads the message first and carries style, attachments and + blocks through unchanged (also giving edit/delete dry runs their + existence check for free). Markup caveat recorded: an Emoji MARK is + materialized into its literal emoji on read + (`RenderInlineText`/materializeEmoji), and the re-parse does not + re-mint it — a read→PATCH round trip turns the mark into plain emoji + text (visually identical, mark gone). Same D′1 family as the marks + re-derivation; pinned by the non-BMP round-trip test. +- **Attachments are bare object ids** (the surfaces-doc shape), at most + **32 per message** — enforced before any store lookup, so the strict + schema's maxItems is true (unbounded, each id was one synchronous + index lookup and one permanently replicated CRDT entry); the + attachment kind is inferred from the target's layout — `image` → + image, other file layouts → file, anything else → link. An unknown id + is a path-addressed 400 steering to POST /v2/files (attaching a + nonexistent object would send a broken message; the indexing race is + an actionable retry, not a silent default). +- **DELETE names its irreversible side effect.** The middleware + permanently deletes (skipBin — no bin) attachment/link targets + orphaned by the delete, asynchronously, AFTER the API replies + (`chats/service.go ArchiveOrphansOnLinksRemoval`). Both the dry run + and the real receipt carry C6 warnings naming the attachment ids at + risk, and the endpoint description says so — this is also where the + previously dead `V2ChatMessageResult.Warnings` field earns its keep. +- **Author enrichment is store-backed, not the v1 cache.** The plan said + "reusing the participant cache" — that cache is the v1 service's + cross-space *subscription* cache, which V2Service deliberately does + not own. Participant objects are indexed under their deterministic ids + (`domain.NewParticipantId`), so the v2 service resolves names straight + from the space index (memoized per read); an unindexed participant + degrades to an empty name exactly like a v1 cache miss. +- **`ensureChat` guard.** Every chat-scoped route first resolves the id + in the store: unknown → 404 steering to GET /chats; a non-chat layout + → targeted 400 (the sets/collections wrong-layout precedent) — the + chat RPCs' own failure for a bad target is opaque. +- **Message DTO shape**: `{id, order, author?, author_id?, at, edited_at?, + text, blocks_text?, reply_to?, reactions?, reacted_by?, attachments?, + pinned?}` — `edited_at` only when the message was edited; sync/read + flags deliberately absent (agent noise). `at`/`edited_at` are + **RFC 3339 UTC strings** — the review fixed the original unix-epoch + ints: one date shape across v2 (AnyBlock dates, search filters, file + addedAt all render RFC 3339), and Phase 8's SSE stream reuses these + DTOs. Cursor pagination only: `?offset=` on the messages read is a + 400 steering to the cursors (a silently honored offset would fake + offset paging the RPC does not do). Cursor asymmetry documented on + the endpoint: only `?after` alone walks forward (the repository's one + ASC sort); `?before`, no cursor, or BOTH bounds anchor at the newest + end and page backward via `next_before` — after+before does NOT + advance a forward cursor through the window. +- **Reaction dry run needs an identity.** `V2Service.accountId` may be + empty (documented degraded mode); with no identity nothing matches + the stored reactions and the predicted `added` would be wrong + whenever the caller already reacted — so `added` became `*bool`, + omitted with a C6 warning in that case (mirroring the + `v2_discovery.go` guard) instead of asserting a coin flip. +- **POST /chats requires a non-empty name** (the row is `{id,name}`; an + unnamed chat is unaddressable) and is a thin `ObjectCreate` with the + `chatDerived` type — NOT the Phase-2 snapshot path, which has never + been exercised for store-backed smartblocks. +- **Dry runs** (C9): create/message/read validate everything and send + nothing; edit/delete stop after the existence check; the reactions + toggle reads the message and reports the would-be `added` from the + caller's current reaction. + +**Discovery (§5).** Five kinds — `chat`, `chatMessage`, +`chatMessageEdit`, `chatReaction`, `chatRead` — strict (C13), each with +a worked example asserted against its own schema by test (the review +added the edit/reaction kinds: the handlers decode strictly, so a +guessed field name was an avoidable 400). `chatMessage` is the +authoring surface (markup-source text, bare-id attachments); its +`text.maxLength` is **8000** — `chatmodel.MaxMessageLength`, UTF-16 +code units — pinned to the constant by a drift test after the original +build advertised 65536 (8× the store cap, steering schema-obedient +models into rejections). + +**Deliberately deferred / reused from v1.** The SSE stream stays on v1 +this phase — Phase 8 mounts it under /v2 carrying these DTOs (they were +built for reuse: the converter is `apimodel.V2ChatMessageFromProto` + +`V2ChatStateFromProto`, and a v2 stream must ALSO forward +ChatStateUpdate, closing the converter gap this phase documented). +Per-chat FT search (`…/messages/search`) stays on v1 (named exception). +Not ported: v1's GET single message (not in the surface — PATCH/DELETE +address by id, reads page by cursor), editing attachments on PATCH +(v1 keeps that), message pinning writes, and `read_all` (see `up_to`). +Link/embed message blocks have no `blocks_text` rendering (only +text-bearing blocks appear); a full `blocks` array is deferred until a +consumer needs it. Open (needs a real account to verify): the +space-level chat is created name-less (`AddChatDerivedObject` sets only +a uniqueKey), so it may render as `{id, name:""}` in the C5 list — a +space-name fallback or `isSpaceChat` flag is the candidate fix once +runtime-verified, incl. whether the object is indexed unhidden. + +**Tests that pin the phase** (each fails if its behavior reverts): +state+message_count passthrough incl. `last_state_id` +(`v2/service/chat_test.go`), the markup bridge both directions + the +round-trip (`v2/model/chat_test.go`), reactions counts/full as +participant ids, the no-chat-opens list (any RPC fails the mock), the +edit merge preserving attachments, `up_to` required / reactions-scope +bounds rejected, last_state_id forwarding on POST read, C8 wiring on all +six chat mutations incl. the DELETE replay +(`server/v2_router_test.go`, `api/v2/middleware_test.go`), and the +chat discovery kinds' strictness (`v2/service/schemas_test.go`). + +### 8.8 Phase-7 implementation notes (periphery — decisions as built) + +Phase 7 closed the periphery (APIV2_SURFACES.md §8 Phase 7): the space +surface, and the query surface's file-layout blindness — the latter a +live bug in shipped Phase-4 code, fixed first. The §8 item 2 +(`GET /members/me`) was verified ALREADY SHIPPED by Phase 5 (route +`router.go`, service `v2_discovery.go GetMemberMe`, tests +`v2_discovery_test.go`) and was not rebuilt. + +**The file-layout opt-in (the live bug, APIV2_SURFACES.md §4).** The +evidence held: v2 search's base row scope and ListObjects both pin +`util.ObjectLayouts`, which contains no file layout, while v1 search has +`prepareBaseFilters(includeFileLayouts)` — so a pure-v2 agent could +upload a file (POST /files) and never find it again. As built: + +- **Trigger = the type channel, both request forms.** A top-level `type` + naming a file type key (`file`, `image`, `video`, `audio` — + `util.IsFileTypeKey`, v1's `fileTypeUniqueKeySet` vocabulary; there is + no `pdf` type key — pdf is a *layout* of type `file`, and the widened + scope includes it) or a **positive** `type` filter leaf sets the plan's + `includeFileLayouts`, which switches the base row scope to + `util.ObjectAndFileLayouts` for that query. The leaf detection sits + in `resolveTypeLeaves` — AFTER the two filter forms converge on one + tree — so the string and structured forms behave identically. A mixed + `type IN ("task","image")` widens; a **negated** leaf (`!=`, `NOT IN`, + `notAllIn`, `notExactIn`, `notContains` — `negatedFilterConditions`) + does NOT (excluding a type is not asking for files — pinned by a test + with a second file object the negation must not leak in). *Positive* is + decided by exclusion from that negated family, NOT by an `=`/`IN` + allowlist: the review reproduced `allIn` (the compact string's + `HAS ALL`) silently returning zero file rows under the original + allowlist — the exact upload-then-never-find-it bug this opt-in exists + to kill, for an advertised condition. Two scope consequences, decided + and pinned: the widening is **query-global** (plan-level), so a + positive file-type leaf under `OR` widens the other arms' row scope + too — the caller named a file type, and per-branch scoping would make + the row scope depend on tree shape; and the opt-in trigger stays the + type channel ONLY — a filter on file metadata alone (`size > 5`) + does not widen, so it matches nothing until composed with a file type + (`type = "image" AND size > 5`). +- **Scope: the search surface only** (space-scoped and global — one plan + builder). ListObjects has NO type channel and deliberately gains none + (§4's "the v1 opt-in reproduced *without a new parameter*"); file + discovery is search's job. The sets/collections reads never had the + layout scope at all (their filters are the setOf/membership + translation — verified `listObjects` in `v2_list_read.go`), so a set + over a file type already returned its rows; nothing to widen there. +- **The file vocabulary: `mimeType` and `size`, live in EVERY channel.** + Aliases mapped to the backing store relations + `fileMimeType`/`sizeInBytes` (`v2FieldAliases`, `v2_object.go`). The + names are the format's OWN file vocabulary — the SPEC §5 file-block + fields and the POST /files result. As hardened by the Phase-7 review + (two findings, both reproduced): + - **Resolution is per SPACE, never per row** (`activeFieldAliases`): + an alias is active only when no real property of the space claims + its key. A user relation literally keyed `size` deactivates the + alias for the whole result set — the original per-record fallback + reported a file's byte count under the user's short-text `size` + property on rows that lacked a value, one key meaning two things in + one result set. + - **An active alias works in `fields=`, filters AND sorts** — + translated to the backing relation in the plan (`rewriteAliasLeaves`, + the sort rewrite, `aliasedFormatName`). The original display-only + scoping made the one advertised spelling a dead end outside + `fields=` while the store spellings worked, splitting the vocabulary + by channel. Filtering composes with the type-channel opt-in above: + `size > 5` alone stays in the file-less base scope. + - **Honesty note on C2**: the store names `fileMimeType`/`sizeInBytes` + remain live property keys wherever they are real keys of the space + (bundled relations, type recommendations) — rejecting a genuine, + discovery-advertised key would be worse than the duality. The alias + is the canonical, schema-advertised spelling; the store names are + ordinary properties, not part of the advertised file vocabulary. + (The earlier §8.8 claim that the aliases *prevent* two names on one + concept was wrong — the review reproduced both spellings rendering + in one row when both are requested.) + +**The space surface (APIV2_SURFACES.md §2 shapes).** + +``` +GET /v2/spaces/{space_id} → {"id","name","description"} +POST /v2/spaces {"name","description"?} → the same shape (201) +PATCH /v2/spaces/{space_id} {"name"?,"description"?} → the same shape +``` + +(`v2/handler/space.go`, `v2/service/space.go`, DTOs in `v2/model/model.go`; +routes beside the spaces list in `router.go`.) `gatewayUrl`/`networkId` +stay v1-only (client infrastructure, not agent fields). No space delete +(v1 has none; deletion is an account-level operation). The spaces LIST +row gained `description` in the review hardening — it sits in the same +tech-space record for free, and withholding it forced a GET-one per +space on the canonical "list my spaces, pick one" trace (a 1+N pushed +onto the agent). + +- **The read is one tech-space store query, zero RPCs.** The §2 claim + verified: v1's `getSpaceInfo` opens `WorkspaceOpen` + `ObjectShow` per + call — and v1's *list* does that per row (`service/space.go:88-94`, + `212-250`), the N+1 by construction. The space view mirrors the + workspace object's `name` AND `description` (`workspaceKeysToCopy`, + `core/block/editor/spaceview.go`), so `GetSpaceViewDetails` serves the + whole v2 shape. Consequence, recorded: the row is as fresh as the + async workspace→spaceview sync, the same freshness the shipped v2 + spaces LIST already has. +- **Create is ONE WorkspaceCreate call.** `CreateWorkspace` applies + every detail to the workspace object (`core/block/create.go`), so the + description rides the create request — v1's second `WorkspaceSetInfo` + RPC for it is dropped. Everything else is v1 parity: `CHAT_SPACE` use + case, random icon option, regular space type, widgets homepage, + trimmed strings. A success carrying no space id is answered 500, not + an id-less 201 (C8 promises created ids; a cached id-less 201 would + replay forever, while a 500 is not cached and a keyed retry + re-executes). +- **`name`/`description` are capped at 4096 characters** (code points), + the bound the `space` discovery kind advertises — enforced on POST and + PATCH with a path-addressed 400 (`validateSpaceField`; a drift test + pins the schema's maxLength to the enforced constant). Before the + review the schema out-promised the endpoint: a 200,000-character name + was accepted and propagated to every member's device. The other kinds' + advertised maxLengths remain unenforced where the store itself bounds + them (chats) or nothing does — hardening them is follow-up work, not + silently claimed here. +- **A recorded C8 window, not closed**: `CreateWorkspace` creates the + space first and can still fail later (set-details, use-case import); + the RPC then discards the space id (`core/workspace.go`) and v2 + answers 500 — which is deliberately NOT cached, so a keyed retry + re-executes and can create a SECOND space. Caching 5xx would make + transient failures permanent (worse), and surfacing the orphan id on + error needs a middleware change that could misreport a + partially-initialized space as created. Accepted as the lesser evil; + the failure mode is rare (the create path is local). +- **C8 on both mutations** via the route idempotency middleware — the §2 + finding was the motivation: an auto-retried space create with no key + duplicates an *entire space*, the worst possible duplicate. The + router test pins both registrations. **C9 scoped honestly**: the + create dry run validates the body only and says so (a space create + cannot be simulated); the PATCH dry run reports the would-be row. +- **PATCH contract.** At least one of `name`/`description` (the + set_properties empty-op precedent — an accepted `{}` would let an agent + believe it renamed something); `name` present-but-empty is rejected + (the POST /chats precedent: the C5 row is `{id, name}`), while + `description: ""` clears. Unknown space 404s before body validation. + The response overlays the patch onto the current view row instead of + re-reading (the async view sync would race an immediate read-back). +- **Status predicate, re-decided in the review hardening.** GET-one and + the spaces LIST serve **live spaces only** — `isLiveSpaceView`, v1's + two-axis predicate (local status Unknown/Ok AND account status + Unknown/SpaceActive), now shared with the global-search fan-out + (`spaceRefs`) so the three surfaces agree on what a space *is*. The + original as-built served any space with a view; the review showed a + deleted space's row is indistinguishable from a live one (the shape + has no status slot), so an agent picking it would PATCH or write into + a space that can never load. Two recorded asymmetries: (1) the + earlier "same contract as ensureSpace" equivalence claim was wrong + even as written — `ensureSpace` short-circuits the TECH space, so + space-scoped routes resolve it while GET-one 404s it (and always + did); (2) `ensureSpace` itself stays laxer (any view + the tech + space) — sub-routes of a just-dead space keep answering during status + transitions rather than flapping 404, and the tech space remains an + internal address no space row ever advertises. +- **C7 exemption, recorded**: the space surface carries no etag and + PATCH ignores If-Match — a space view has no agent-visible tree head + to hash (the object/chat etags hash CRDT heads), and the name/ + description pair is last-write-wins by design. Concurrent renames + race silently; acceptable for a two-field resource. +- **Discovery: one `space` kind** (strict, `required:["name"]`). PATCH + takes the same two fields (both optional, at least one) and is + documented on the kind's endpoint string rather than minting a + `spaceUpdate` kind — the chat precedent (`chatMessageEdit`) split + kinds because the shapes diverged; here they share every field name, + and a strict-schema agent that includes `name` on PATCH is simply + valid. +- **RPC error mapping**: BAD_INPUT → 400 `validation_failed` carrying + the description; then the review hardening added the description + classification the chat surface has (`v2SpaceRpcError`) — the + workspace RPCs answer UNKNOWN_ERROR for everything reachable (core + `mapErrorCode` has no workspace mappings), so without it a PATCH + racing a space deletion, or a reader's PATCH in a shared space, was a + retry-looping 500. Pinned strings: `space not exists` / + `space is deleted` / `space storage missing` + (space/service.go sentinels) → 404 `not_found`; `restricted` + (restriction.ErrRestricted via SetDetails) → 403 `forbidden`; + everything else stays 500 with the description carried. + +**Handler plumbing note.** The chat handlers' strict body decoder was +generalized (`decodeStrictJSONBody`, `v2/handler/error.go`) and is shared +by the space handlers; chat error texts are unchanged +(`decodeChatBody` delegates). + +**Tests that pin the phase**: the RPC-free space read (any RPC fails the +mock), the single-call create carrying the description (a +`WorkspaceSetInfo` expectation would fail), the at-least-one-field and +empty-name PATCH 400s, C9 dry runs sending nothing (service AND handler +layers — a regressed `dry_run` would create a real space), C8 wiring on +POST/PATCH spaces (`server/v2_router_test.go`), the space kind's +strictness, the opt-in matrix (top-level type / string leaf / structured +leaf / mixed IN / negated leaf / bare search) where the widening test +can only pass through `ObjectAndFileLayouts`, and the fields aliases +rendering from the backing relations +(`v2/service/search_test.go TestV2SearchFileLayoutOptIn`, +`v2/service/space_test.go`, `v2/handler/space_test.go`). + +**Review hardening (2026-08-06, three opus lenses)** added the pins for +everything the hardening changed: per-space alias shadowing +(`TestV2FieldAliasShadowing` — a user property keyed `size` must +deactivate the alias for the whole result set), the alias filter/sort +channels, `allIn`/`HAS ALL` widening, the query-global OR widening as a +decision, the live-space predicate on GET-one and the list (deleted +space → 404 / filtered out) plus `description` on the list row, the +workspace-RPC description classification (404/403/500 matrix), the +no-space-id 500, the 4096 caps with the schema-drift pin, the +end-to-end keyed `POST /v2/spaces` replay (`server/v2_router_test.go` — +`WorkspaceCreate` mocked `.Once()`, second response +`Idempotency-Replayed`), and the set-over-a-file-type read rendering +`?fields=mimeType,size` (`v2_list_read_test.go`). Two C2/C8 footnotes +recorded rather than changed: `dry_run` keeps its snake_case spelling +in response bodies — then a deliberate carve-out from a camelCase C2 +across all eight v2 mutation DTOs (the echo mirrors the `?dry_run=` +query parameter it answers), *since §8.46 not a carve-out at all but +the plain rule, the rest of the vocabulary having moved to meet it*; +and an Idempotency-Key reused across +`POST /v2/spaces` and `POST /v2/validate` (the two space-less routes +sharing the empty-space key namespace) answers 409 +`idempotency_conflict`, which is correct — a key names one logical +operation. + +### 8.9 The /v2 key-scope gate (2026-08-06 — decisions as built) + +The one amendment to §8's "no new auth surface": authentication stays +shared with v1 (same bearer keys, same pairing endpoints, same +`ensureAuthenticated`), but `/v2` carries a group-level authorization +gate v1 does not — `ensureJsonApiScope`, installed on the v2 group +directly after Auth. Only keys whose scope is `JsonAPI` or `Full` may use +`/v2`; every other scope — the web clipper's `Limited`, and any future +enum member until explicitly admitted — is refused with 403, distinct +from the invalid-key 401. Legacy keys minted without a scope carry +`Limited` (the enum zero value; anytype-cli's `CreateApp` historically +sent none) and are grandfathered on `/v1`: they keep working there +exactly as they ship today and hit this 403 on every `/v2` route. +Migration stance: `docs/superpowers/specs/2026-08-06-api-key-scoping-design.md`. + +- **The 403 body names the remedy**: `api key scope does not allow json + api access: key "" has scope, create a new api key + with JsonAPI scope` — the failure reads as "re-issue the key", not as + a transient permissions bug. Error text is API surface; tested + verbatim. +- **Envelope, recorded**: the gate answers in the shared v1 envelope + (`{object, status, code, message}`, code `forbidden`), not the C6 + shape — the same seam as the group-level auth 401, which aborts before + any v2 route middleware runs. Group-level refusals speak the shared + server's dialect; the C6 shape starts where v2's own middleware and + handlers do. +- **Coverage is a test, not a convention** + (`server/v2_router_test.go`, "every /v2 route carries the scope + gate"): every route under `/v2` in the real engine's table must answer + the gate's exact 403 to a cached `Limited` key, except the two public + documents (`GET /v2/docs/openapi.{yaml,json}`) on an explicit exempt + list — so a `/v2` route registered outside the gated group fails the + walk instead of shipping ungated. + +### 8.10 The /v2 space-grant gate (2026-08-06 — decisions as built) + +Where §8.9's gate decides the key's KIND, this layer decides which spaces +and which verbs a key's *grant* covers. A key may carry a grant record +(`{spaces, perms: read|readwrite}`, sealed into the app-link file); the +grant — never the key-string format — is what enforcement reads. +`Grant == nil` is the legacy unscoped key and behaves exactly as before. +Design: `docs/superpowers/specs/2026-08-06-api-key-scoping-design.md`. + +- **The gate** (`v2/authz.go ensureSpaceGrant`, installed directly after + the key-scope gate): a `:space_id` must be in the grant's space list, + else 403 `space_not_granted`; the tech space is denied unless + explicitly granted (this gate runs BEFORE the service's `ensureSpace`, + which admits the tech space as an ordinary id). A `read` grant on a + write-classified route → 403 `write_not_granted`. Both messages NAME + the actual grant — error-guided self-correction over enumeration + resistance, which is a non-goal for a localhost single-user API. +- **The registry, not inference**: every route is classified in an + explicit table (`v2RouteAuthz`) — verb (`POST /v2/search` and + `/v2/validate` are READS; chat `POST …/read` is a WRITE, it mutates + the synced read watermark) and, for no-`:space_id` routes, a global + class: `auth-exempt` (public docs), `data-free-allow` (`/v2/validate`, + `/v2/schemas*`), `service-filtered` (`GET /v2/spaces`, + `POST /v2/search` — allowed through, constrained in the service), or + `scoped-denied` (`POST /v2/spaces`: a key that can mint spaces it then + owns is not meaningfully scoped). An UNREGISTERED no-space route is + refused, fail closed; the conformance walk + (`server/grant_gate_test.go TestV2RouteAuthzConformance`) makes a + missing or stale classification a CI failure in both directions, pins + the `auth-exempt` precondition behaviorally (an exempt route must + answer without credentials, every other /v2 route must 401), and + refuses unknown route-param names — the gate reads the addressed space + from exactly `:space_id` (`apiv2.SpaceParam`), so a space param under + any other name must fail CI rather than slide into a global class. +- **Fan-out + backstop**: the two service-filtered surfaces intersect + their space set with the ctx grant at the INPUT (`spaceRefs`, + `ListSpaces`) — not the output rows, so a per-space warning cannot + disclose a non-granted space's existence. The service layer carries + BOTH backstop halves, in the gate's precedence (space first, then + verb): `ensureSpace` consults the grant before its tech-space + admission, and the write entry points go through + `ensureSpaceWrite`/`ensureChatWrite`, which also refuse a read-only + grant (`ensureWriteGranted`) — so a future route that forgets the + middleware can neither reach a non-granted space nor mutate a granted + one with a `read` key. `GetSpace`/`CreateSpace`/`UpdateSpace`, which + bypass `ensureSpace`, carry their own checks. +- **Envelope**: this gate is v2's own middleware, so it answers in C6 + (codes `space_not_granted` / `write_not_granted`) — unlike §8.9's + shared-server gate. Granted keys are refused on `/v1` with C6 + `v1_not_available_for_scoped_keys` pointing at `/v2` (grant presence + decides, never format; legacy keys stay served on `/v1`). +- **WWW-Authenticate** rides every auth failure (MCP clients are + required to parse it): 401 → `Bearer realm="anytype"` (bare when no + credential was sent, `error="invalid_token"` otherwise); 403 → + `Bearer error="insufficient_scope"`, with + `scope="space::"` when the request addressed + one space — the scope-string shape is implementation-defined + (RFC 6750 §3.1) and this is the documented one. +- **Grant edits bite immediately**: `LinkLocalUpdateApp` evicts the + key's cached HTTP session entries (`RevokeToken`), so an in-place + NARROWING is enforced on the very next request + (`TestGrantEditTakesEffectOnNextRequest`) — a stale cached grant would + be a silent authorization bypass. The sweep can only evict entries + that exist, so a mint IN FLIGHT during the sweep must not cache + afterwards: the server keeps an eviction generation, snapshotted with + the cache read and re-checked at the cache write — on a mismatch the + minted entry serves that one request and is dropped, and the next + request re-mints against what the wallet holds then + (`TestGrantEditDuringMintIsNotLost`). + +### 8.11 whoami + legacy-key signals (2026-08-06 — decisions as built) + +The P1c introspection layer over §8.10's enforcement: an agent that cannot +read its own grant either over-requests and fails or under-requests and +does nothing useful. Design: +`docs/superpowers/specs/2026-08-06-api-key-scoping-design.md` (P1 §6). + +- **`GET /v2/auth/whoami`** (authenticated) describes the CREDENTIAL, + never the person. Body (snake_case per C2, RFC 3339 UTC dates): + `{key: {id, name, created_at, expires_at}, scope, grant: {scoped, + permission, spaces: [{id, name, permission}]}, api: {version}, + key_status, notice?}`. + - `grant.scoped` is the REQUIRED explicit boolean and the load-bearing + field. A legacy unscoped key is `{scoped: false, spaces: [], + permission: null}` — NEVER `spaces: null`: consumers get the + null-vs-empty test backwards, and that failure direction is + fail-open (the agent concludes it may touch every space). + - `spaces[]` entries are OBJECTS with a per-entry `permission` + (uniform today) so P2's per-space permissions land without a wire + break; the grant-level `permission` stays as the compact form agents + string-match on. + - `spaces[].name` is resolved through the SAME grant-intersected + `ListSpaces` path `GET /v2/spaces` serves, so a non-granted space's + name cannot appear even by accident. The grant record stays + authoritative for WHICH spaces are listed: a granted space missing + from the live list keeps its entry with an empty name. + - **The mirror is the gate's own record**: whoami is discovery, not + enforcement, and derives from the request-context carriers + `ensureAuthenticated` populated — `util.ApiGrantFromCtx`, the same + accessor `ensureSpaceGrant` and the service backstop read — never a + second derivation path (that is how a mirror starts lying). + `TestWhoamiAgreesWithTheGate` derives gate expectations ONLY from + the whoami body and fails on any disagreement. + - The token is read ONLY from the `Authorization` header by the shared + auth middleware; a query/body token is never accepted, an unknown or + revoked key gets the middleware's plain 401 — deliberately NOT + RFC 7662's introspection shape (no `active`, no POST), which would + make the route an enumeration oracle. + - **Registry class, reasoned**: `service-filtered` — authenticated + (auth-exempt is impossible inside the gated group and the + conformance walk enforces that behaviorally), addresses no single + space, and its body's space names come from the service's own + grant-intersected path, the exact pattern the class names. + `data-free-allow` would be wrong: names are space data. + - The `key.id`/`created_at` plumbing is two additive + `WalletCreateSession` response fields (`appHash`, `appCreatedAt`), + cached on the session entry like scope and grant. + - **`key.id` is credential-adjacent**: it is the app link's hash — + sha256 over the raw key bytes, the same id ListApps shows — so a + whoami response (and the api-server log line below) carries a full + offline VERIFIER for the credential: not invertible (256 random + bits) and computable by the holder anyway, but treat pasted whoami + bodies and shared api-server logs as credential-adjacent artifacts. + - **One gate decision the mirror cannot express**: `POST /v2/spaces` + is `scoped-denied` (§8.10) — refused for EVERY granted key, + readwrite included — and the whoami vocabulary (spaces × + permission) has no field for it. Kept un-modeled deliberately while + the class covers exactly one route, and creating a NEW space is + outside "the spaces I was granted" by plain reading; if the class + ever takes a second route, add an additive + `grant.restrictions: [...]` array instead of letting the mirror + under-tell further. +- **Legacy-key deprecation signals** — emitted by `ensureAuthenticated` + on BOTH route groups (legacy keys live on `/v1`). Deliberately NOT + RFC 9745 `Deprecation`/`Sunset`: that header requires a Date (the + boolean form died in draft) and §2.2 scopes it to the RESOURCE in the + response — on `/v1` it would declare `/v1` deprecated, the opposite of + the grandfathering promise (a test pins that neither header ever + appears). Instead: `Anytype-Key-Status` (`legacy`|`scoped`, ALWAYS + present so absence never means anything; grant PRESENCE decides), and + `Anytype-Notice` (one printable single-line ASCII sentence, never + interpolating user data) plus + `Link: <…/docs/guides/get-started/authentication>; rel="deprecation"` + (legal without a `Deprecation` header — RFC 9745 §3.1's own worked + example for "policy, no date committed"; the target is the live + authentication guide until the dedicated key-scoping page ships, + because a policy link that 404s inverts the signal). The remedial + pair — notice and Link — is emitted ONLY for nil-grant keys of + JsonAPI scope: a grant is only ever valid on JsonAPI scope + (`wallet.ValidateAppLinkGrant`), so a Limited (clipper) or Full + credential cannot follow the "re-issue as a scoped key" advice; those + keys still read `Anytype-Key-Status: legacy` (they ARE unscoped) but + get no impossible instruction. The whoami body repeats the signal + (`key_status`, `notice`) under the same rule — agents read bodies, not + headers. A rate-limited INFO log line (once per key per process + start, re-armed hourly; nothing is wrong, so never warn) names the + key id and app name for nil-grant JsonAPI keys only — it exists so WE + can tell whether anyone still presents legacy JSON-API keys before a + sunset is ever contemplated, and counting clipper keys would inflate + exactly that number. +- **Secret-scanner rules** (the §1b deliverable): detection-only + gitleaks + TruffleHog rules in `docs/secret-scanning/` (README there + explains why the GitHub partner program is unavailable to a local-first + app and why TruffleHog cannot live-verify a localhost credential — the + offline CRC32 is the stand-in). Both rules carry the published RANGE + pattern verbatim; `core/wallet/applink_scanner_rules_test.go` pins them + against a freshly minted key and the repo's own `anytype_…` + identifiers, pins the two rules against each other, and walks the + tracked tree asserting every full-shape match (the swagger example key + and the OpenAPI documents generated from it) falls under the shipped + gitleaks allowlist — the rule must come back clean on the repo that + ships it. Coverage boundary, recorded per spec §1b: Limited/gRPC keys + keep minting unprefixed and stay invisible to the rules by design. +- **OpenAPI**: `make openapi` is green again and the `docs/v2` documents + are regenerated (whoami included, 401/403 documented as the shared + middleware envelopes). The v2 swag step used to die on + `json.RawMessage` (swag cannot resolve the stdlib alias, and its v3 + parser panics outright on `swaggertype:"array,…"` — Items is never + set); the fixes are `swaggertype:"object"` on `SchemaEntry`'s + object-valued raw fields, plus two doc-only stand-ins for the + array-valued cases: `v2model.ViewObject` for the view-listing + responses and `v2model.SearchRequestDoc` for the search body (a + reflection test pins the twin's JSON field set to `SearchRequest`'s so + the published document cannot drift from the decoded type). + +### 8.12 Surface-review fixes M2 + M6 (2026-08-07 — decisions as built) + +**M2 — permanent refusals no longer dress as retryable 500s.** Four +producers used to fall through `RespondV2Error`'s 500 fallback, sending +retrying agents into loops on refusals that can never succeed: + +- **Restriction refusals on PATCH → 403 `forbidden`.** The per-op + gate (`editNeedsForOps`/`restrictionRefusal`, ops.go) now produces the + C6 403 at the verdict site — message carries the adapter's refusal text + plus the offending op and `/ops/i` path, and the issue hint states the + refusal is permanent. The mutator path (the adapter's in-lock + `checkObjectEditable` re-check and `Apply`'s per-block restrictions) is + classified by `mapWriteError` (object.go) on + `restriction.ErrRestricted` via `errors.Is` — sentinel-backed, no + string matching. Dry runs reach the same 403 (the verdict rides the + read). The earlier refusal tests fed a ready-made `*v2model.Error` + into `BlocksRefused` — green against a shape production never + produces; they now feed the adapter's real wrapped-sentinel chain. +- **Foreign chat edits/deletes → 403** through the exported + `chatobject` sentinels (§8.7's classification bullet, updated there). +- **File-upload failures are classified** (`v2FileRpcError`, file.go): + in URL mode, a source answering non-2xx (matched through the + `fileuploader.ErrFailedToDownload` sentinel, which the uploader now + wraps — pinned by an uploader test that fails if the wrap is dropped) + and a fetch that never got a response (`Get "…"` — `url.Error`'s + fixed rendering, pinned against the stdlib type in the v2 test; + `CleanupError` masks the URL inside but keeps the shape) are 400s + naming `/url`. Local-path staging failures and storage faults stay + 500 — those are genuinely retryable or server-side. The upload + pipeline has no size-cap error to classify: nothing bounds a URL + download's size today (recorded, not fixed here). +- **The space classifier's strings are compile-pinned** (M2d): the + workspace-RPC arms now match `space.ErrSpaceNotExists` / + `ErrSpaceDeleted` / `ErrSpaceStorageMissig` and + `restriction.ErrRestricted` through the sentinels' own `.Error()` + text instead of duplicated literals, so a producer rewording updates + the matcher at compile time. Behavior unchanged. + + +**M6 — the five typed Phase-2 bodies bind strict and bounded.** +CreateProperty, UpdateProperty, CreateSet, CreateCollection and the JSON +UploadFile now decode through `decodeStrictJSONBody` like chat/space/ +search: unknown fields 400 with the field named in a C6 issue (the +reproduced trap — `"option"` for `"options"` silently creating an +option-less property — now rejects), empty bodies 400 with the shape +hint, and the bodies are capped at 1 MiB (`maxV2StructuredBodySize`) +regardless of Idempotency-Key — previously the cap only engaged when the +idempotency middleware buffered a keyed body, so keyless requests to +these five routes were read unbounded. The bounds the discovery schemas +advertise are enforced at the service layer from named constants +(schema_write.go: name 4096, key 256 + `^[a-zA-Z0-9_]+$`, options 100, +option color 64, filter 4096, sorts 10, views 10, collection items 1000 +— checked before the per-item store walk — url 4096), and a drift test +in schemas_test.go pins the served schema JSON to those constants so +neither side can move alone. The one-table derivation the review +suggested (generating the schema strings from the constants) was +REJECTED as not worth it: the schemas are hand-written JSON with prose +descriptions, and the existing chat/space precedent — constants + drift +test — already makes divergence a test failure. `UploadFileRequest.name` +remains accepted-but-unused by the service (pre-existing; the schema +advertises it — recorded, not fixed here). + +### 8.13 Surface-review fixes M1 + M5 (2026-08-07 — decisions as built) + +**M1 — the edit gate is per-op.** `checkObjectEditable` demanded both +`Restrictions_Blocks` and `Restrictions_Details` of every edit. Sets and +collections carry Blocks but NOT Details (`objRestrictEdit`), so a PATCH to +either was refused whatever it contained: renames, which restrictions never +forbade, and `add_items`/`remove_items`, the only v2 route into an existing +collection. A collection was write-once — seedable at POST, immutable after — +even though §6 retires v1's `AddObjectsToList` in favour of these ops. + +`apicore.ObjectRead` now carries `BlocksRefused` and `DetailsRefused` +separately, and `ObjectMutator.MutateObject` takes an `apicore.EditNeeds` +derived from the batch (`v2OpEditNeeds`, `editNeedsForOps`). Item ops need +NEITHER axis: they mutate the collection store +(`template.CollectionStoreKey`), which no object restriction governs — the +same position v1 takes, its `ObjectCollectionAdd` being ungated. (PUT +demanded both, a document replace rewriting blocks and details alike; that +caller is gone — §8.27 — and `EditNeeds` is now derived from ops only.) A +refusal +now addresses the offending op (`/ops/1`), not the request, so a batch mixing +a legal rename with an illegal block edit says which op is the problem. + +The set/collection restriction facts are pinned against the LIVE restriction +table in `objectmutateadapter_test.go`, so a change to `objRestrictEdit` +fails there rather than silently restoring the bug. + +**M5 — create-missing is bounded, and creates go last.** One FAILING PATCH +permanently created every option it named: 5,000 objects from a ~60 KB body, +~10^6 at the body cap, with no v2 option-delete surface to undo them. + +There is no transaction available — options are objects, each its own CRDT +tree, so "create N options and mutate a document" cannot be one commit. The +irreversible part therefore goes last and small, in two halves that catch +different requests (`guardCreateMissing`): + +1. **The bound** (`v2MaxCreatedOptionsPerPatch` = 64) is the only thing that + stops a *well-formed* batch — one that would apply cleanly — from creating + a million options. Enforced on a probe pass whose resolvers record instead + of create, so the rejection costs one JSON walk and no RPC. The error names + the count, the limit and the properties involved. +2. **The ordering** is the only thing that stops a *failing* batch from + leaving debris: the batch is applied against a private state first, so an + op that cannot apply is found before any create RPC fires. This subsumes + case-by-case skip lists (a key claimed by both `set` and `unset`, a scalar + where a list is required, …) — enumerating the ways a batch can fail is + open-ended; validating it is not. It runs only when the batch actually + names new options, so an ordinary PATCH pays nothing. + +Both halves apply to dry runs, so C9's preview reaches the same verdict. + +What remains is a crash or cancellation between the creates and the apply, +which cannot be eliminated without a cross-object transaction. It is now +bounded by the cap, convergent on retry (`OptionId` resolves an existing +option by name before creating, so a retry adopts the first attempt's options +rather than duplicating them), and detectable — created options carry +`ObjectOrigin_api`. A compensating delete was deliberately NOT added: the +delete is not atomic either, and another client may have started using an +option in the window, so it would add a second failure mode to paper over the +first. Cleanup belongs in a provenance-backed sweep, not the request path. + +### 8.14 Surface-review fix M3 (2026-08-07 — decisions as built) + +Three malformed structured-filter shapes reached the store as MATCH +EVERYTHING — silently, with no warning — inverting the surface's own promise +("unresolved → did-you-mean, never a silent no-match") in the most damaging +direction available: + +1. a node carrying BOTH arms, `{"operator":"and","property":"severity", + "condition":"equal","value":"High"}`. The codec (`filterFromJSON`) and the + semantic gate both branch on `Operator != ""`, take it as a group, ignore + every leaf field and emit an AND with no children. An empty AND is true. +2. a group with an empty `filters` array — the same empty AND, reached + directly. +3. a leaf with no `condition`, which a typo'd key (`"conditon"`) also + produces: it reaches the store as `Condition_None`, and + `database.FiltersFromProto` drops it. + +`validateFilterStructure` (`search.go`) enforces the SHAPE the served +`filters` schema already described but nothing checked, and +`decodeFilterNodes` is the single entry both v2 callers use. **The gate runs +on POST /sets as well as the query path**, because a set PERSISTS its filter: +there the same shape is not a bad query but a set that quietly contains the +whole space, for good. + +The served schema was tightened to match the enforcement rather than left +advertising the broken shapes: the leaf arm now requires `condition` (it +required only `property`, which is what made shape 3 schema-legal), and the +group arm's `filters` gained `minItems: 1`. The two examples already carried +a condition on every leaf, so nothing published had to change. + +Deliberately NOT touched: the shared document codec, which must keep +accepting `Condition_None` — stored dataviews legitimately carry it, and this +is a v2 request gate, not a format change. This is also the one input channel +with no GBNF grammar (a documented C13 exception, the tree being recursive), +so it is precisely where a small model's malformed output lands. + +### 8.15 Surface-review fix M4 (2026-08-07 — decisions as built) + +Sending the `Idempotency-Key` that C8 mandates on every mutation capped file +uploads at 10 MiB. The middleware buffered the whole body to hash it, so a +multipart upload — whose body IS the file — hit `MaxRequestBody` and got a 413 +naming the body, never the header. The disciplined agent was therefore the +only caller that could not upload a large file, and the error steered it to +shrink the file rather than to drop the header it had been told to send. +Every keyed upload under the cap was also buffered whole in RAM and then +re-parsed by `multipart`. + +**As built.** `isStreamedUpload` (multipart content types only) switches the +identity from the exact body to a BOUNDED PREFIX (`idempotencyPrefixBytes` = +64 KiB) plus the declared `Content-Length`; the remainder streams to the +handler through an `io.MultiReader`, so nothing buffers whole files and no +size ceiling appears. JSON bodies are unchanged — exact-body hash, 10 MiB cap +— because their bytes are small and ARE the request. + +**The trade, stated plainly.** For multipart the conflict guarantee narrows: +two uploads under one key that agree in both declared length AND first 64 KiB +now replay instead of answering 409. In practice the prefix covers the +boundary, the part headers, the filename and the file's opening bytes, so +distinct uploads still differ there — the test drives exactly that case. This +is a narrower guarantee than the exact-body hash, bounded to this one content +type, and it is the price of not having a size ceiling. + +**Rejected alternatives.** Exempting the route (the review's third option) +would have created the first C8 exception, which §1 currently says do not +exist — a documentation cost paid forever to avoid a bounded technical one. +Keying on the staged file's digest inside the handler is more correct still, +but moves idempotency out of the middleware for one route and cannot answer +the replay before the upload has already happened. + +### 8.16 Surface-review fix M7 (2026-08-07 — decisions as built) + +One PATCH could hold the object lock for tens of minutes. `v2MaxOpsPerPatch` +bounds the op count, but every mutating op invalidates the applier's view, +so the next op re-marshals the WHOLE document — under the smartblock lock, +where the cost is not latency for one caller but starvation for every +reader and writer of the object: ObjectOpen, sync, the app's own UI. +Reproduced before changing anything: 400 trivial replace_text ops measured +12.2 s on a 4,200-block document and 71.4 s on a 24,000-block one (~7 µs +per block-render), linear in the document exactly as the O(ops × document) +product predicts; the 10 MiB body cap × 512 ops extrapolates to 15–20 +minutes from a single request. + +**As built — two bounds, then one exemption that earns its way out.** + +1. **The per-op blocks cap** (`v2MaxBlocksPerOp` = 256, enforced in + `decodePayloadRun`): the served op schemas ALWAYS advertised + `maxItems: 256` on the blocks channel of insert_blocks and + replace_subtree; nothing enforced it, so one op could inflate the + document by 24,000 blocks for every later op to re-render. The markdown + channel already enforced the same number; the two channels now share + `v2MaxBlocksPerOp` by definition. No schema text changed — the schema + was right, the server was lenient. +2. **The render-work bound** (`v2MaxPatchRenderWork` = 2^20 block-renders, + `checkPatchRenderWork`): the product itself — view-rebuilding ops × + (document blocks + payload blocks, markdown counted at its per-op cap) — + refused whole after the `begin()` marshal, before any op applies, with + the numbers in the error. Rejection therefore costs one marshal, the + same floor a GET pays. 2^20 ≈ 7 s worst case on a desktop machine. + The hint says split the batch, and splitting is the fix rather than a + workaround: the object is RELEASED between batches, which is the whole + point. The check sits in `applyPatchOps`, so the guardCreateMissing + probe pass, the dry run and the locked run all reach the same verdict + (C9 dry≡real). Like the 512-op cap it is server-side behavior, not a + schema-advertised bound. +3. **replace_text maintains the view in place** (`textEdited`): it changes + exactly one exported field of one block — no ids, no structure, no + indents — and it is the one op that inherently arrives many-per-batch + (one find/replace each). It writes the CANONICAL rendering a re-marshal + would emit: `RenderInlineText(parse(splice))` for markup text (the + exporter's own `renderInline`; mark compaction is off on the edit path, + verified in `compactMarks`), the literal splice for code/embed (§8.4), + field dropped when empty (`setNonEmpty`). Canonical matters: a splice + can leave adjacent marks (`**re****port**`) whose re-marshal reads + `**report**`, and the next op's find must match what the agent would + read back. With the exemption in `v2OpRebuildsView`, a full 512-op + replace_text batch on a large document is legal again AND cheap: + 400 ops on 24,000 blocks went 71.4 s → 0.8 s, on 4,200 blocks + 12.2 s → 0.15 s — the two remaining renders are begin + the final + after-document, which diff_stats and the R5 net need regardless. + +**Tests that fail if the fix is reverted** (verified by reverting): the +per-op cap and work-bound rejections in `TestPatchObject`, the work-bound +exemption for replace_text, and `TestApplierRenderCounts`, which pins the +bounded-work property in the unit that cannot flake in CI — whole-document +renders (`marshalCount`): exactly 2 for a 50-op text batch, per-op for +structural ops. Two semantics tests (sequential-canonical, an +update_block merging after a replace_text) pass on BOTH code paths — +they pin that the in-place update is byte-equivalent to the re-marshal +it replaces. + +**Deliberately NOT done.** Incremental view maintenance for the +structural ops (insert_blocks, move_block, delete_block, update_block, +replace_subtree, set_cell): their exported form is produced by the +exporter's tree walk — normalization, indent clamping with the C11/B′2 +warning contract, table wrapper pinning, the document-wide id domain — +and replicating that per op in the applier is the applier rewrite this +review round warned against. Nor for set_properties/add_items/remove_items: +they batch naturally into ONE op (maps and arrays), so the product term +barely exists for them, and the properties view has real staleness corner +cases (a key unset and re-set in one batch must re-check space existence). +The bound covers what the exemption does not. + +**The residual limit, stated plainly.** A structural batch still pays +O(ops × document) up to the 2^20 budget — single-digit seconds of held +lock on a desktop, proportionally more on slower devices. Below the +budget, a hostile-but-legal batch can still buy ~7 s of lock; above it, +the work simply cannot be purchased in one request. And every PATCH keeps +its two-marshal floor, so a very large document costs one render even to +reject — the same floor its GET costs. The exact worst case a caller can +reach is therefore max(two renders of the document, the render budget), +never minutes. + +### 8.17 The view write path: update_view (2026-08-07 — decisions as built) + +**The gap, as reported by an agent using the API.** Dataview views were +readable three ways (the object document, `GET …/sets/{id}/views`, +`…/collections/{id}/views`) and writable zero ways after creation: the +then-existing PUT refused type documents by kind, the types PATCH accepts +only properties/typeProperties, and no view route accepts a write. The reporter's +concrete case — a custom type's default "All" view rendering every custom +column `hidden: true` — was TWO bugs stacked: no write path (this section), +and the generator regression that hid the columns in the first place +(GO-5969 inverted `MakeDataviewContent`'s precedence so a type's explicitly +passed relation links stopped being marked visible; fixed at the generator, +pinned in `collection_test.go` — a freshly created type now gets a usable +view and the write path is a repair tool, not a required rite of passage). + +**Surface: an eleventh PATCH op, `update_view` — not a route.** Views are +part of the object's document (SPEC §6.2); C2 says one concept, one slot, +and the object-edit slot is `PATCH …/objects/{id}`. A dedicated +`PATCH …/views/{viewId}` was rejected: it would be a second way to edit one +object with its own idempotency/dry-run/etag wiring, it cannot compose +atomically with other ops, and it would need THREE registrations (sets, +collections, types) plus a fourth story for inline dataviews — the op works +on all four today, including `PATCH …/objects/{typeObjectId}` with the id +from `GET …/types/{key}` (type objects pass `checkEditPreconditions`; it +was only PUT's kind-gate that refused them, and PUT is gone — §8.27). Whole-array rewrite via `update_block` was +rejected twice over: it is the documented small-model trap (resend every +view to flip one bit), and update_block's `{Blocks: true}` classification +refuses it on exactly the three object classes that carry dataviews. + +**Shape.** `{op, block?, view?, set?, columns?}` — at least one of +set/columns. `block` defaults to the object's only dataview (types, sets, +collections have exactly one, at the fixed id "dataview"); `view` defaults +to the only view; both resolve by full id or unique suffix (the C4 rule +`resolveViewRef` already applies on the read surface — resolution by NAME +was considered and dropped: names collide and localize, and every +ambiguity/not-found error lists `id ("name")` pairs so the repair needs no +second read). `set` merges §6.2 view-level fields with update_block +semantics (named fields change, explicit null clears one); `sorts` and +`filters` replace whole when named — small ordered lists; `filter` is the +compact-string alternative to `filters` (parsed exactly as POST /sets +parses it, ambiguous together). `columns` merges PER COLUMN, keyed by +property key: a patch object merges `{hidden, width, align, aggregation}` +into that property's column, appends a column for a key that has none, and +null removes one (removal is deliberately not key-validated — a stale +column for a deleted property must stay removable). `id` immutable, +`set.columns` steered to the columns channel, `groups`/`objectOrders` +rejected as §4a output-only — but they SURVIVE the edit: the merge happens +on the block's exported JSON and re-imports through the format codec +(`UnmarshalBlock`, the set_cell pattern), and the importer round-trips +kanban editor state, so untouched views, columns, group orders and manual +object orders land back bit-identical. All validation runs against a +private deep copy first — a failing op leaves state and view untouched. + +**One vocabulary, exported from the format.** The op validates view types, +card/list sizes, column align and aggregation against lists the +`anyblockjson` package now exports (`viewvocab.go`), pinned to the codec's +own enum tables by a drift test — necessary because the codec itself maps +unknown enum names silently to defaults on import, which is exactly the +silent-degradation an op surface must not inherit. Sorts and filters +validate through the exported fragment codec (`UnmarshalSorts`, +`UnmarshalFilters` — read-only resolvers, issues rebased onto +`ops[i].set.…` paths), the M3 structural gate runs on `set.filters` for the +same reason it runs on POST /sets (a persisted match-everything filter is a +view that quietly shows the whole space, for good), and the §6.2 +unguarded-date-comparison finding rides the C11 warnings channel — PATCH +responses now carry `warnings` for the first time. + +**THE RESTRICTION CLASSIFICATION — the decision that could have recreated +M1.** `v2OpEditNeeds["update_view"] = {}` — neither axis. Sets and +collections carry `Restrictions_Blocks` (`objRestrictEdit`) and object +types carry it too (`objRestrictEditAndTemplate`) — the three +dataview-bearing classes, so a Blocks-classified view op would be refused +on precisely the objects it exists to edit, the M1 bug reborn. The +classification is not a convenience but the editor's own position: the +Blocks axis gates document content (`basic.CreateBlock`, tables, clipboard, +uploads all check it) while the native view surface — `sdataview. +UpdateView`/`CreateView`/`DeleteView`, i.e. v1's ungated +`BlockDataviewView*` RPCs — checks NO object-level restriction, which is +how the app edits views on a set at all. Proved three ways: +`objectmutateadapter_test.go` pins sets/collections AND a custom type +object against the LIVE restriction table (Blocks refused, Details not); +`viewops_test.go` drives a PatchObject with production-shaped +`BlocksRefused`+`DetailsRefused` on the read and asserts the op succeeds +with `EditNeeds{}` recorded at the mutator; and the same on the dry-run +path (C9 parity). Verified fail-on-revert by flipping the classification +to `{Blocks: true}`: both tests fail, nothing else notices. + +**Create-missing wiring (the M5/B6 interplay).** A view filter's select +values and a custom sort order carry option NAMES, which the dataview +import resolves with create-missing — so `prewarmCreateMissing` learned the +op: it walks `set.filters`, the parsed `set.filter` string and +`set.sorts[].customOrder`, resolving select/tag values BEFORE the object +lock and thereby inside the M5 bound (a channel prewarm cannot see is a +channel the too-many-options cap cannot count — the bound test fails if the +prewarm branch is disabled, verified by disabling it). One §11 alignment +closes the residual gap: `empty`/`notEmpty`/`exists` leaves get their +`value` stripped on store (the canonical form), so the in-lock import never +resolves — never mints — an option the view cannot use, and prewarm and +import see identical work. + +**Reference-key rule, and a recorded divergence.** Keys a patch introduces +(columns, sorts, filter leaves, groupBy/coverProperty/endProperty) must be +known to the dataview (pre-merge membership: properties list ∪ any view's +columns) or to the space — rejected with the did-you-mean otherwise; +resolvable keys are appended to the dataview's `properties` list so formats +rehydrate (§6.2 sorts/filters carry no cached format). This is deliberately +LOOSER than POST /sets' R9 rule (type-recommended keys only): generated +views already carry columns outside that set (`backlinks`, +`lastModifiedBy`, `lastOpenedDate`), and an edit surface must not reject +what the surface already shows. The divergence means a two-step +set-build can reach a filter key the one-step create would refuse — +accepted: the native app allows the same, and the cost of the strict rule +here is false rejections on every generated view. + +**Bounds (M6 discipline: advertised = enforced).** columns ≤ 64 +(`maxV2ViewColumns`), sorts ≤ 10 (shared `maxV2SetSorts`), filter string ≤ +4096 (shared `maxV2FilterLength`), pageSize ≤ 1000, width ≤ 10000 px (SPEC +§6.2: the editor's own range is 54…1000; omitted/null lets the client pick +per format), name ≤ 4096, keys/ids ≤ 256. The op rebuilds the document view +(`v2OpRebuildsView`), so the M7 render-work bound counts it with no new +plumbing. Served schema: `GET /v2/schemas/ops/update_view`, C13-strict +except the documented `filters` recursion (small models steered to +`filter`); the example is the one-line repair of the reported gap: +`{"ops":[{"op":"update_view","columns":{"status":{"hidden":false}}}]}`. + +**Tests that fail if reverted** (each verified by actually reverting): +the generator-regression case in `collection_test.go` (stash the +`collection.go` fix → fails); the two restriction-classification tests +(flip `v2OpEditNeeds` → fail); the M5 bound test (disable the prewarm +branch → fails); the vocabulary drift test pins the exported lists to the +enum tables; and removing the op registration trivially fails the whole +`TestUpdateViewOp` suite. + +**Deliberately NOT built** *(superseded by §8.18 — the view family shipped +the same day)*. View create/delete/reorder (`addView`/ +`delete_view` — POST /sets seeds multiple views at creation; editing was the +reported gap; creation-after-the-fact is a separate, smaller decision and +the native RPC precedent has its own last-view invariant). Name-based view +addressing (see above). A dataview-properties op (the `properties` list +self-maintains through key usage). Type-scoped R9 tightening for edits +(recorded divergence above). `activeView` anything — local UI state the +proto excludes from changes. The swagger annotation names the new op; +`make openapi` regeneration is pending per the working agreement. + +### 8.18 The view family: insert_view, move_view, delete_view (2026-08-07 — decisions as built) + +Supersedes §8.17's deferral: create, reorder and delete now exist, so the +view surface is symmetric — everything `GET …/views` can show, PATCH can +make, change, order and remove. The op set grows to 14, and stays learnable +because the three additions introduce NO new grammar: the block family's +verbs (insert/move/delete), view-scoped, sharing `update_view`'s channels +(`set`, `columns`), `update_view`'s block/view addressing, and +insert_blocks/move_block's targeting words. One noun, zero new verbs. + +**Naming: `insert_view`, singular — a deliberate break from `insert_blocks`' +plural.** The blocks payload is a structured RUN (ordered, indent-nested), +which is what the plural names; views have no internal structure, one view +per intent is the overwhelming case, and several views are several ops in +the already-atomic batch. The family symmetry that matters is with +update_view/move_view/delete_view, all singular. A mode-flagged mega-op +(`update_view` with create/delete/move modes) was rejected for the same +reason replaceBlock died in v0.3.5: mode flags are disambiguation load. + +**insert_view = "update_view aimed at a fresh view."** The base is either +sensible defaults or a `copy_from` duplicate; `set`/`columns` then merge on +top through the SAME code paths (`applyViewSet`/`applyViewColumns`), so +everything §8.17 established — vocabulary, filter gates, key validation, +warnings, option create-missing (prewarm covers insert_view too; the M5 +bound test fails if it does not) — holds for create without a second +implementation. `name` is required (a view is a named tab; ≤4096). The +minted id returns in a new `created_views` response map ("ops[i]" → id); +view ids are always server-minted — a payload has no id slot, `set.id` +stays rejected. + +**The bare default is a view someone can look at.** `{"op":"insert_view", +"name":"Recent"}` produces: one column per property the dataview lists, +ALL visible, sorted lastModifiedDate-descending. This deliberately breaks +with the native `CreateView` default (`dataview.go:333` — every column +hidden except name), which is the same disease the GO-5969 fix cured for +generated type views; matching native here would have shipped the reported +bug as the create default. The sort matches native (`DefaultLastModified- +DateSort`). `copy_from` duplicates an existing view of the same dataview — +columns, sorts, filters, type, groupBy, card options, even the per-view +editor state, everything but id and name — because "like that one, but…" +is the common intent; §6.2 nests `groups`/`objectOrders` per view, so the +copied editor state re-keys to the new view id on import for free. + +**Reorder is targeted, never a rewrite.** `move_view {view, after|before| +position}` — the move_block vocabulary minus `inside` (views are a flat +list; there is no container), with `position: "first"|"last"` standing +alone instead of riding `inside`. `position: "first"` is documented as the +"make this the default tab" verb: `activeView` is local UI state (§6.2, +excluded from changes), so the FIRST view is what a fresh client shows. +*(Revised in §8.19: move_view now REQUIRES a destination — the silent +append default this section originally shipped was judged a +forgotten-field trap that quietly changes the default tab. insert_view +keeps its append default, where appending is the natural create +position.)* The splice adjusts the target index across the removal +(move-after-a-later-view is the test case); moving relative to itself +degenerates to a no-op rather than an error. insert_view shares the same +targeting for its insertion point. + +**Delete has one guard and one deliberate non-behavior.** Deleting the +last view is a clean C6 refusal (`cannot delete the last view` — the +native `DeleteView` invariant surfaced as a 400 with a repair hint, not a +corrupt object; the editor would regenerate a default on open, but relying +on that is sync-dependent). The guard counts the BATCH's state, not the +original document: insert-then-delete in one PATCH is legal and is exactly +how an agent replaces a type's default view atomically (tested). Deleting +a view some client had active is deliberately unhandled server-side: +activeView is per-device local state; that client falls back to the first +view. Per-view editor state (groups, objectOrders) vanishes with its view — +the §6.2 nesting makes orphaned group orders structurally impossible. + +**Classification: the whole family is `{}` — neither axis** — same +derivation as §8.17 (all three dataview-bearing object classes refuse the +Blocks axis; the native view RPCs are ungated). Pinned as a FAMILY: one +test drives an insert+move+delete batch through PatchObject against a read +refusing BOTH axes and asserts `EditNeeds{}` at the mutator — flipping any +of the three in `v2OpEditNeeds` fails it (verified by flipping). + +**Tests verified fail-on-revert** (by actually reverting each): the family +classification (flip → fails), the last-view guard (remove → fails), the +insert_view prewarm coverage (drop from the condition → the M5 bound test +fails). The create-defaults decision is pinned by construction (the bare- +insert test asserts every column visible and the sort). + +**Deliberately NOT built.** A per-dataview view-count cap (native has +none; unbounded growth across requests is the insert_blocks precedent, and +the M7 render-work bound covers per-request work — an advertised cap that +existing user data already exceeds would strand update_view). Client- +supplied view ids (nothing needs them pre-creation; minting keeps the id +space server-shaped). copy_from across dataview blocks (the source must be +a view of the addressed dataview — cross-block copying is a read+insert +composition the agent can already do). View duplication INTO another +object (out of PATCH's one-object scope by definition). `make openapi` +regeneration is pending (the PATCH annotation now names all 14 ops). + +### 8.19 View-family review fixes (2026-08-07 — decisions as built) + +Three review lenses (correctness/data-safety, agent contract, tests) went +over §8.17/§8.18 as shipped. The headline finding invalidated a §8.17 +claim; the rest tightened contracts and closed fixture gaps. Dispositions, +in the reviews' order: + +**A — the commit path could mint options under the lock (fixed, the big +one).** The view-op commit re-imported the WHOLE dataview block through the +create-missing resolver. Export writes option values as NAMES (falling back +to the raw id for a dangling reference), so every view's filters and custom +sort orders re-resolved name→id on every view op — including views the op +never touched. Consequences, all reproduced: a dangling reference (deleted +tag, filter keeps the id) round-tripped into a BRAND-NEW option named after +the raw id with the filter rebound to it; those creates fired under the +object lock (the B6 invariant §8.17 claimed could not be reached); they +bypassed both halves of M5 (one move_view over an untouched view with 200 +dangling values = 200 options past the cap of 64, and a batch REFUSED as +atomic still left its options created); and with two options legally +sharing a name, a pure move_view repointed a filter to the other twin by +store listing order. + +As built, two mechanisms: + +1. **The commit imports with a NO-CREATE resolver** + (`commitImportOptions` / `readOnlyOptionResolver`): names resolve + through the prewarm's create cache and the store; a miss passes through + verbatim instead of minting. Op-authored names are created by the + PRE-LOCK prewarm, so the cache covers them; anything the prewarm cannot + see is by construction content the op has no business minting for. This + also closes the narrow prewarm/import format-disagreement case the + review flagged (dv-list says select, space says longtext): the value now + passes through verbatim instead of creating under the lock. +2. **Unauthored content is restored from the live proto after the import** + (`viewCommitPlan` / `restoreUnauthoredViews`): move_view and delete_view + author nothing — every surviving view is byte-restored, only + order/membership comes from the splice; update_view and insert_view author + one view, and within it sorts/filters restore from the live (or + copy_from-source) proto unless the op's set actually named them. The + codec round-trip is thereby a no-op for everything the op did not write: + no rebinding, no twin repointing, no drift. + +The fixture hole the reviews named — no test ever put content in a view +the op does not address — is closed by four tests: dangling-value +verbatim survival through move_view, twin-option id stability through +update_view-on-the-other-view, format-disagreement pass-through, and +copy_from preserving the source's exact ids. Each verified fail-on-revert +by reverting the resolver and the restore separately. + +**B — the M7 bound was blind to dataview weight (fixed).** A dataview is +ONE block whose marshal cost is O(views × columns); a fully legal +512×insert_view batch on a wide set held the lock ~25 s while scoring 0.05% +of the budget — and §8.18's no-view-cap justification leaned on the bound +it was beating. `checkPatchRenderWork` now takes the parsed blocks: the +document factor counts per-view weight (1 + columns + sorts + filters) and +every insert_view adds the document's heaviest per-view weight to the +payload factor (the copy_from worst case). The §8.18 justification holds +again. Pinned by a test whose 512×insert_view batch on a 10-view×50-column +set must be REFUSED — it passes the old cost model, so it fails if the +model reverts (verified; the reverted run also demonstrated the 24 s hold). + +**C — the family's M7 registration was asserted, never tested (fixed).** +The marshal-count pin (the TestApplierRenderCounts pattern: two view ops = +begin + one rebuild + final) is now COUPLED in one test to the +`v2OpRebuildsView` entries, so measured per-op re-marshaling and the map +that accounts for it cannot drift apart. + +**D — the served sorts schema rejected what reads emit (fixed).** Stored +sorts carry an `id`; the exporter emits it; the strict item schema lacked +it — so read→edit→write of a sort was schema-refused while the server +accepted it. The item schema gains `id` (documented output-only-on-reads, +accepted back). A drift test now walks the served schema and pins the enum +lists to the `viewvocab.go` exports too — the hand-duplicated schema enums +were the one link the vocabulary drift test did not reach. + +**E — insert_view had two name slots (fixed).** `set.name` silently +overrode the op's required `name`, and `set.name: null` produced a +nameless view from an op whose schema declares name required. insert_view +now rejects `set.name` with the steer (update_view keeps it — there it IS +the rename channel), and its served set schema drops the name property +(`v2ViewSetPropDefNoName`), keeping schema and server in agreement. + +**F — the indent strip was load-bearing and unexercised (fixed).** All +fixtures kept the dataview at indent 0, so deleting the view-doc `indent` +before re-import was dead weight in every test while being essential for +the shipped inline-dataview shape. An indented-inline fixture now pins it. + +**Minors.** Removing a column from a column-less view no longer writes +`columns: null` into the block (a reachable 400). The advertised +`customOrder` maxItems (128) is enforced, and `set.filters` both advertises +and enforces a top-level-nodes cap (32; nesting stays the documented C13 +recursion exception). The compact filter string now validates keys against +the same membership as the structured form (the whole dataview: properties +list + every view's columns — it saw only the addressed view's columns, so +the recommended input form rejected keys the structured form accepted, +with a did-you-mean that omitted the right answer). move_view requires a +destination (§8.18 revision note above). The bare-insert default is built +from pre-op membership — its default sort no longer grows the properties +list, so two bare inserts in one batch produce identical views. copy_from's +kanban editor-state fidelity is now pinned by a fixture that has some. + +**Rejected, with evidence.** "copy_from duplicates per-node sort/filter ids +— a state the native editor never produces": the generator itself ships +fixed node ids on every generated view (`DefaultLastModifiedDateSort`'s +`byLastModifiedDate`, `defaultChatSort`'s `byLastMessageDate` — identical +across every set in every space), so shared node ids are generator-normal; +and after fix A the copy's sorts/filters are proto-restored from the +source, making a JSON-side strip literally unreachable code (it was +written, then deleted on that evidence). + +**Deferred.** `?fields=` projection on GET …/views (a read-cost +optimization, no correctness stake). The Date-object `state.ErrRestricted` +500 is pre-existing, shared with every block op, and ticket-worthy — not a +view-family fix. + +### 8.20 The MCP delivery: two model tiers over one table (2026-08-07 — decisions as built) + +The §3 "MCP server binary" item is built: `anytype mcp --tier small|large` +serves the §7 tool table over MCP stdio. This section records the research +verdict that scoped it, the tier split, the selection and transport +decisions, and the repair-loop contract. + +**Who MCP is for — the research verdict (confirm-with-caveats).** The +hypothesis under test: MCP is not worth it for large models (Sonnet-class +should get `core/api/v2/SKILL.md` + raw HTTP, or the CLI + its skill); +MCP exists to serve small local models. The evidence *confirms the +narrow form and rejects the sharp one*: + +- *Context cost and tool-count degradation are real and hit large models + too* — Anthropic's own engineering posts measure a 150k→2k token drop + from keeping MCP schemas out of context (code-execution-with-MCP, + 2025-11) and a 49%→74% (Opus 4) / 79.5%→88.1% (Opus 4.5) accuracy gain + from deferred tool loading (advanced-tool-use); practitioner + measurements put single servers at ~55k tokens of schemas; BFCL and + successor benchmarks show selection accuracy falling with tool count. +- *But the ecosystem's fix was to repair MCP's discovery mechanics + (deferred/searchable tool loading), not to abandon MCP* — so "MCP is + wrong for large models" is not the lesson; "don't preload schemas you + don't need" is. +- *For CLI-capable large-model harnesses the skill+CLI/HTTP path + measurably wins on cost, not correctness*: Arize's 500-trial eval + (2026) found correctness statistically tied between MCP and CLI/skills + while MCP ran ~6× the cost and ~5× the latency on hard tasks. Simon + Willison's skills-vs-MCP token argument is the same conclusion from + the token side. +- *The standing counterargument*: non-terminal hosts (desktop apps, + chat clients, mobile) cannot run a CLI at all — for them MCP is the + only delivery at ANY model size. And small-model tool calling is where + strict schemas + grammar constraints genuinely compensate for a + capability gap (sub-7B models emit malformed calls unaided; GBNF + fixes syntax, not semantics). + +Decision as recorded: the MCP server here **targets local small models** +(the task's premise, upheld); large CLI-capable agents keep being pointed +at the CLI skill (`cmd/anytype/SKILL.md`) or the raw-HTTP skill +(`core/api/v2/SKILL.md`). The caveat is recorded rather than acted on: +a capable model in a non-terminal host may legitimately use `--tier +large`, and nothing in the design penalizes that — at ≤12 tools this +server never enters the schema-bloat regime the research warns about +(the whole large-tier `tools/list` is ~1.5k tokens). + +**The tier split — a field on the one table, never a second list.** The +§8.6 one-definition invariant extends: `Tool.Tier` marks the smallest +tier a tool is served to; `ToolsForTier`/`BuildManifestForTier` filter; +golden-list tests pin both sets and a mandatory-tier test makes an +undeclared tool a failure, not a silent omission (`tier.go`, +`tier_test.go` — verified fail-on-revert). + +- **small (~8B, Gemma-class), 8 tools**: `spaces, find, read, describe, + create, set_properties, add_blocks, edit_text` — the tasks a local + assistant actually performs (find/read notes, capture, set a status, + append content, fix wording). Omissions are decisions, each on the + misuse-worse-than-missing principle: `check_item` (the E4 recipe steers + task completion to `set_properties` anyway; a block-addressed toggle is + niche and adds a whole reference-resolution failure surface), + `set_cell` (five required args on a rare shape), `move_block` + (restructuring is rare; the after/under anchor vocabulary is the most + confused one in the set), `delete_block` (destructive with a + `recursive` escalation — the worst cost for a wrong guess). +- **large (~20B, Qwen-class), 12 tools**: the whole table. No NEW tools + were minted for it: §7.2's exclusions (whole-document replace — since + §8.27 not a REST surface either — batches, structured filters, block-field updates, archive-without-a-route) were re-checked + and stand — they were excluded for corruption/ambiguity reasons, not + for being beyond a 3–4B. The tier field makes a future 13th tool a + one-line tier decision; chats are the named candidate when a use case + shows up. +- Arguments are NOT tiered: the table's args are already flat and + minimal, and per-arg tiering would fork the schema/GBNF/CLI renderings + of one definition — rejected as complexity without a demonstrated win. +- The CLI verb set is NOT tiered (coding agents are large models); + `anytype tools --tier small` narrows the manifest for non-MCP + function-calling hosts. + +**Selection + packaging.** One binary, an `mcp` verb on `cmd/anytype`, +tier by `--tier` flag (default `large`). Rejected: a separate `cmd/` +(duplicates env/flag plumbing and splits the skill story; the §8.6 +"verb set == tool set by construction" argument applies to deliveries +too); tier-per-server-name (two registrations of one binary with no +added expressiveness — hosts pass args natively); an env var (flags are +visible in host config where the choice is made). The server constructs +the §8.6 long-lived Runner over the in-memory session store — the CLI's +session file stays the CLI's (concurrent MCP servers must not fight +over it, and a host restart starting clean is predictable behavior). + +**Transport.** MCP stdio (newline-delimited JSON-RPC 2.0), hand-rolled +in `wrapper/mcp.go`: `initialize` (version negotiation across +2024-11-05/2025-03-26/2025-06-18 — the tools-only surface is identical +across them; unknown versions answer ours), `tools/list` (the C13 +schemas as `inputSchema`, `readOnlyHint` on the four non-mutating +tools), `tools/call`, `ping`; notifications acknowledged by silence, +batching refused with steering. A third-party MCP SDK was weighed and +rejected: the needed subset is ~300 lines, the repo carries no MCP +dependency today, and hand-rolling keeps the wire shapes pinned by our +own tests instead of a vendor's release cadence — the SDK becomes worth +it the day this server needs resources/prompts/elicitation, and that +day should be a §3 item, not a drive-by. `initialize` also serves +tier-aware `instructions` (the SKILL.md loop compressed: spaces → find +→ describe-before-create → read-before-edit, dates/@me, "follow the +error, retry once"). + +**The repair loop (the guessability contract).** Tool failures return +IN-BAND (`isError: true` + text) so the model reads the tip; only +malformed JSON-RPC and a name outside the tier are protocol errors — +and the unknown-tool message still lists the tier's tools. The tip +chain, outermost first: wrapper argument validation (already +steering-shaped), the §8.6 ops→tool vocabulary translation (server C6 +hints arrive saying `under`/`block`/`read mode=outline`, never +`ops[0].inside`), and two MCP-layer additions for the conditions whose +fix is outside the model's reach — API unreachable ("ask the user to +start the Anytype app") and key rejected ("ask the user to check +ANYTYPE_API_KEY"), both ending "no change to the call will help" so a +small model stops burning retries. `TestMCPRepairLoop` pins the loop +end-to-end per case: the wrong call, the EXACT tip, then the corrected +call succeeding in the same session (handle-before-find, missing +required arg, after+under together, bad enum, server C6 in tool +vocabulary, 401, unreachable). The vocabulary translation and the +in-band error path were both verified fail-on-revert. + +**Not built, stated.** No resources/prompts/sampling/elicitation (no +use case on a localhost notes API today); no HTTP/SSE transport (local +hosts spawn stdio; the API server itself is the HTTP surface); no +per-tier GBNF re-derivation beyond what the manifest already serves +(the grammars are per-tool and tier filtering subsets them); no +Claude-facing MCP recommendation (per the verdict, capable CLI-running +agents keep the skill+CLI path). + +### 8.21 Small-model benchmark fixes (2026-08-08 — decisions as built) + +The first LIVE benchmark of the shipped MCP surface: `anytype mcp +--tier small` (8 tools) driven by gemma4:e4b and gemma4:e2b over +Ollama against a running Anytype API, 8 realistic tasks. The numbers +that motivated this section: + +- **e4b**: right tool 7/8, executed 5/8 first try — the existing tips + repaired 2 of the 3 failures. +- **e2b**: right tool 6/8 but "executed" 8/8, because two successes + were SILENT WRONG ACTIONS — it called `spaces` for "what properties + does the page type have", and `read` for "change the word draft to + final". A wrong action that returns 200 is worse than any refusal: + nothing in the transcript invites a repair. +- **Every argument error was a naming/capitalisation guess** — + `"Page"` for type `page`, `set.Name` for property `name`, `"page + type"` lifted from the prompt's phrasing — never a structural or + schema violation. The GBNF/schema layer is doing its job; the + remaining error surface is semantics, and semantics is repaired by + candidates in error texts, not by grammar. +- The one tip WITH candidates (unknown property key: "known … keys: + …") repaired on the first retry; the one WITHOUT (type not found) + produced **no retry at all** — same run, same model. Candidate lists + are not decoration; they are the difference between one repaired + call and a dead end. + +Three fixes, each shaped by those observations: + +**1. `edit_text.block` is optional — the snippet locates the block.** +Both models routed around `edit_text` (the `read` silent-wrong-action +above IS this defect): a required block id is unknowable on turn one, +and a tool that requires a prior call is a tool a small model will not +use. When `block` is omitted the wrapper reads the document and +applies the mandatory ambiguity rule — the snippet must identify +exactly ONE block, and (the existing rule) occur exactly once within +it. Zero matches → refusal steering to `read mode=outline`; several +matching blocks → refusal LISTING the candidate block labels with +~30 chars of surrounding context, so the retry passes `block` +explicitly; several occurrences in the one block → the existing +more-context refusal, issued during the locate (no wasted PATCH). A +silent wrong edit is far worse than any of these refusals. The locate +read retains labels (the next call starts resolved); a located id is +full, so the §7.4 ambiguity retry never double-fires. The manifest +example is now the block-less form — the call a small model can +actually make first. Rider: the server's `replace_all` escape hint is +stripped by the ops→tool vocabulary translation (edit_text +deliberately has no `replace_all`, §8.6 — the hint steered models +into an argument the tool rejects). One-table invariant held: schema, +GBNF, example and CLI flag re-derive from the same Arg row. + +**2. The not-found family lists candidates (server-side).** The +routes addressing a type or property BY KEY (GET/PATCH/DELETE +`types/{key}`, options listing, PATCH/DELETE `properties/{key}`) +answered a bare "not found — list keys with GET …" while the R9 +create path always listed keys + did-you-mean — an inconsistency in +our own error surface, and the measured dead end above. One composer +(`notFoundWithKeys` in refs.go) now serves the family: known keys +capped at 15, nearest-match did-you-mean, the list route only when +the list was truncated with no suggestion. Family survey, recorded: +view refs and the filter option path already listed candidates; block +refs steer to outline (the candidate list IS the outline); the space +404 gains the `GET /v2/spaces` steer but never a candidate list (ids +are opaque — no did-you-mean can help — and a scoped grant must not +imply the full space list); the object 404 is left alone (unbounded +candidate set, and wrapper models reach objects through find handles +— the wrapper's own no-session/stale-handle errors steer the re-find). + +**3. Type and property keys fold case — in the WRAPPER, not the API.** +The judgment, argued: C2 (a key is a key) is the REST surface's +contract — programmatic clients depend on exact-match strictness, and +two keys differing only by case must stay distinguishable over REST. +The wrapper is the layer built to be forgiving for small models +(§7.3 already places @me, relative dates and the A2 guard there), so +the fold sits beside them. The hard rule either way: if two keys +differ only by case, refuse naming both — never pick one. Property +keys fold in `prepareValues` against the format index the call +already fetched (zero extra requests), BEFORE the format lookup — so +`"DueDate": "friday"` also gets its date resolution and the A2 guard +runs on the folded key; a key given together with its case variant +refuses instead of last-write-wins. Type keys fold on the ERROR path +only (find, describe, create retry once with the unique case +variant): the correct-key common case never pays a type listing, and +a folded create re-derives its Idempotency-Key — a different resolved +request must not reuse the failed body's key (C8). A fold miss +surfaces the server's now-candidate-bearing error untouched. NOT +folded, stated: select option NAMES (user data — `done` and `Done` +can both legitimately exist; the A2 guard's case-insensitive +did-you-mean already covers the guess) and property keys inside +filter STRINGS (parsed server-side; the parse error carries +did-you-mean — a wrapper-side fold would mean parsing the filter +twice; revisit if a benchmark shows filters failing on case). + +Tests: `tools_smallmodel_test.go` pins all four locate outcomes, the +fold-and-refuse matrix and the replace_all strip; +`discovery_test.go`/`schema_write_test.go` pin the server candidate +lists. Every behavioral assertion was verified fail-on-revert (the +fold-miss and unknown-key pass-through cases assert unchanged +behavior and pass either way, by design). Deferred, named: case-fold +inside filter strings; a candidates steer on the object 404; B4 +re-tuning of tool descriptions once the benchmark re-runs. + +### 8.22 The (a) identity layer — mint, corpse policy, key resolution (2026-08-08 — decisions as built) + +Implements the safety core of `core/api/APIV2_ADDRESSING.md` (§7.5, +§7.5a, §7.6 build step 3 plus the step-5 corpse policy), superseding the +queued "point v2 at apiObjectKey" fix — which, alone, would have imported +the slug layer's collision problem (§2.3-1). Nine commits (seven code, +two docs), each verified +fail-on-revert where the behavior is invisible until data is wrong. + +**The derived slug table** (`pkg/lib/bundle/apislug.go`): the authority for +a bundled key's snake api slug is a fixed table in code, both directions — +verified collision-free (194 relation slugs distinct, 29 type slugs +distinct, fold layer clean) by tests that fail on the first bundle change +that mints a collision. `ApiSlug` is deliberately the same transform +objectcreator's `injectApiObjectKey` applies at mint. One dossier example +respells: `dueDate2`/`due_date2` converge on `due_date_2` (strcase +separates trailing digits), not `due_date2` — same collision, different +joint spelling. + +**The corpse policy** (§7.5-req-2, the §8-OQ2 vacate lean — both live +defects fixed): UI delete sets only `isUninstalled`, which the query +layer's injected defaults never filtered — so a UI-deleted property still +listed in `GET /properties`, still resolved on PATCH/DELETE, and blocked a +same-key create with "already exists" pointing at a corpse, while an +archived one was invisible to the guard and the create died downstream on +a raw `ErrTreeExists`. Now: `keys.go`'s live queries exclude corpses +everywhere the API addresses schema (listings, routes, did-you-mean +candidate lists, existence guards), corpses vacate the slug namespace at +mint, and delete-then-recreate is a clean create with fresh identity. +DELETE of an already-deleted property/type is a 404, not a re-archive. + +**The mint** (§7.5 strategy (a), the strategy-(b) remnants retired): +`POST /properties` no longer writes the caller's key as the stored +relation key, `POST /types` no longer derives the uniqueKey from the +document key, and `PropertyId` no longer pins typeProperties keys — +every create mints a BSON internal key and the caller's key becomes the +`apiObjectKey` slug, snake-normalized. The union collision check ships +WITH the mint (bundled keys + bundled-derived slugs + live stored keys + +live stored slugs; §8.23 unified it onto the resolution chain — all mint +paths, fold classes included): `due_date`, `Due Date` and `dueDate` can no longer +shadow the bundled property, sequential normalized twins refuse loudly, +and a name-derived slug (key omitted) is guarded identically — the +refusal steers to the existing holder or an explicit different key +(auto-suffixing was considered and rejected for POST: explicit beats +silent). Bundled keys keep the derived install path — convergence IS the +install mechanism (§2.4-1). + +**Input resolution** (§7.5a-5 + §7.5a-3): every place v2 takes a type or +property key walks the chain — exact stored key, live slug namespace, +bundled vocabulary (exact or derived slug), fold layer (`DueDate`, +`due-date` → `dueDate`) — with ambiguity a loud 400 listing every holder, +never store order. Wired into route params, search/set type scopes, +document creates (`canonicalizeDocumentKeys` — detail keys and +`ot-` URLs are the store's vocabulary, not the wire's; it guarded PUT the +same way until §8.27), `set_properties` +keys, and the option-create prewarm (a slug-keyed select would otherwise +mint options bound to the slug string). The §8.21 benchmark's Title-Case +miss now resolves with zero retries. + +**Output spelling** (the dossier's blessed interim, NOT yet the full +§7.5a): `GET /properties`/`GET /types` rows and the type column on +object/search rows spell a BSON-keyed entity by its slug iff the slug +round-trips to that row (twins and stored-key shadows keep the honest +BSON; corpses always do). Readable stored keys keep today's spelling. + +**The §2a format check** (§7.5-req-4): a typeProperties entry whose +declared format contradicts the resolved relation (live, slug-resolved or +bundled) is a path-addressed 400 on both POST and PATCH types, checked +before the create-missing resolver can mint. It lives in the v2 wiring +because `PropertyDefinition` cannot distinguish an absent format from +longtext (enum zero). + +**Slug hygiene**: the option path's un-snaked second injection branch +(§2.3-3) removed — it was dead code (ToSnake never empties a non-empty +transliteration). + +**§8 leans built as assumed**: OQ1 — `apiObjectKey` stays mutable, +address-only (nothing freezes it); OQ2 — vacate + loud floor. Deferred, +named: the ACTIVE re-slug-on-revive half of OQ2 (no v2 revive endpoint +exists; the bundled reinstall path cannot collide because minting over +bundled slugs is refused; a UI bin-restore of a custom twin is caught by +the ambiguity-loud lookups and the conservative served spelling, not yet +auto-repaired); the full §7.5a respelling sweep (bundled keys → snake on +the wire, SPEC §3 key-vocabulary flip, schemas/goldens/SKILL/eval +respell); ADDRESSING §7.6 steps 1–2 (pins + the D1 kill — SPEC-level); +§7.4 strict-on-PATCH write defaults; the §7.5-req-5 backfill (its own GO +issue — until it runs, pre-slug custom BSON keys have no stable bare-op +address, exactly as the dossier states); key slots inside view-op set +channels accept stored keys only — set filters and the whole query +surface canonicalize since §8.23 (slug inputs in the one remaining channel fail +loud via R9, never silently). The heart-side mint (`injectApiObjectKey`) +still checks nothing — UI creates can still mint twin slugs; v2 defends +via the ambiguity 400 and round-trip serving. OpenAPI regeneration is +pending (`make openapi` not run here); no annotation shapes changed. + +### 8.23 Identity-layer review pass: five causes, fixed as causes (2026-08-08 — decisions as built) + +A five-lens review of §8.22 found incomplete wiring in five structural +clusters; each is fixed at its cause. Seven commits, every behavioral fix +verified fail-on-revert (by targeted behavior reverts where a whole-file +revert would only fail compilation). + +**Cause 1 — the document body was an unguarded input channel +(REGRESSION, reproduced).** A type document's properties map copied +verbatim into the create RPC; `uniqueKey` is itself a bundled relation, +so a forged `{"uniqueKey":"ot-page"}` rode into `getUniqueKeyOrGenerate` +and `DeriveTreeObject` — occupying the id a later bundled install +converges to (strategy (b)'s silent merge, reachable under (a) through a +channel the union check never inspected). Fixed with a reject list +(uniqueKey, relationKey, isReadonly, restrictions — export strips all +four, so no legitimate round trip carries them; path-addressed 400) and +a drop list for system-managed details a round trip DOES carry +(apiObjectKey — a supplied value bypassed the union check when the name +slugged empty — origin, space_id, isArchived, isDeleted, isUninstalled). +The same forgery's second channel — an envelope `key` on an OBJECT +document becoming `snapshot.Key` → `uniqueKeyInternal` → +`DeriveTreeObject` (found while fixing; the review named the details +channel) — is rejected in `validateDocumentRefs`, covering every document +create (it covered PUT too until §8.27). + +**Cause 2 — one canonicalization chain everywhere.** The prewarm's +`canonicalPropertyKey` and `PropertyId`'s resolution now ARE +`resolvePropertyInput` — the §7.5a-5 chain every other channel walks — +closing at one stroke: the M5 bypass (the prewarm lacked the fold the +in-lock pass had; a folded spelling made 70 option creates run INSIDE +the object lock past the 64 cap — the repro is now a test), the +corpse-resolving typeProperties path (custom corpse keys mint fresh; +bundled keep the storeresolver fallback — bundled identity is derived +and invariant), and the stored-key shadow (`myKey` beside a legacy +`my_key` resolves to the legacy relation via the fold; a spelling whose +fold misses but whose minted slug collides — `"My Key"`, the space +survives folding — is refused by the slug-side union re-check). The +canonicalization-equivalence table test pins prewarm ≡ in-lock over +stored/slug/folded/ambiguous/miss spellings, so the next divergence +fails there first. + +**Cause 4 — guards robust.** `liveProperties`/`liveTypes` return their +store error (a hiccup no longer empties the namespace and waves +collisions through — fail closed; hint-only lists degrade); entries are +primed once per request and mandatory in the chain (the N+1 loops in +document validation and set_properties/view checkKey share one snapshot); +the mint remembers its own request (`mintedSlugs` — two spellings of one +key in one document refuse instead of both minting `warranty_until`); +the union check covers fold classes (`moodlevel` beside `mood_level` +refuses — an occupied folded spelling would be permanently ambiguous); +the type union check runs BEFORE Unmarshal (a refused type create leaves +no orphan typeProperties relations — M5's lesson in a new path); +`canonicalizeDocumentKeys` reports duplicates deterministically; and +ambiguity candidates print stored key + id — the addresses that always +resolve (twins printed one identical slug; nothing was actionable). +Hidden holders vacate the slug namespace (resolution, fold, collision, +serving) while keeping exact-stored-key addressability: a hidden twin is +invisible and undeletable to the caller and must not 400 the visible +holder's slug. + +**Cause 3 — the query channels speak what the listings advertise.** +`keycanon.go` canonicalizes every concrete property input of search +(fields — read from the stored key, emitted under the requested +spelling — structured filters, the compact string, sorts, format and +option lookups), list `?fields=`, and set creation (the request's +filters/sorts/views rewritten in generic JSON before validation and the +persisted document — a served slug would have become a permanently dead +dataview filter). Membership accepts stored AND served spellings (plus +bundled derived slugs — acceptance is wider than advertising); candidate +lists speak served spellings only. The type filter LEAF resolves through +the chain, corpse-aware — one spelling now works at every level, and a +UI-deleted type stopped being a usable query scope (`typeKeyExists` +likewise: objects/templates of a corpse type refuse). PUT tolerated +corpse-HELD property keys (GET emits them for objects still carrying +values, and a GET→PUT of the same bytes had to round-trip) while POST kept +live-only; §8.27 retired the tolerance with PUT on the grounds that +live-only was now the whole rule — **wrong, and reverted in §8.29**: PATCH +has its own in-document escape (`checkKey` passes any key already on the +document), and create is the channel a read body is pasted into, so the +tolerance moved to **create** as `propertyKeyHeldByAnyRelation`. The +file aliases' deactivation is chain- and corpse-aware (an uninstalled +`mimeType` relation no longer silently drops the field space-wide). + +**Cause 5 — derived-slug hygiene.** Name- and document-key-derived slugs +are sanitized to the advertised `^[a-zA-Z0-9_]+$`/maxLength grammar +("50% done", "C++", "☕" → unidecode "?" — all previously became +identity-bearing, unaddressable apiObjectKey values); empty means no +derivable slug and the minted BSON stays the only address. + +**Rejected, with evidence:** corpse-awareness for `isCollectionType` +(the input is the object's OWN stored type key — a data predicate; +refusing add_items on an existing collection whose type was uninstalled +would diverge from the app) and for set-source resolution +(`setSourceFilters` reads setOf — stored identifiers, never wire +spellings; a set over a deleted type still lists its objects in the +app, and v2 stays at parity). + +**Still deferred, restated:** view-op set channels (update_view/ +insert_view filters and sorts) accept stored keys only — slug inputs +there fail loud via the view-key validation, never silently; folded +spellings are accepted on routes, documents and ops but NOT inside +search/set filter validation sets (only stored, served and bundled-slug +spellings enumerate); the §7.5a respelling sweep, pins/D1, §7.4 +defaults, the backfill and active re-slug-on-revive as in §8.22. +Truthfulness fixes to §8.22 ride this pass (commit count, the union +check's scope, search's former stored-keys-only state). + +### 8.24 Wave 0.1 — the served body is compact all the way down (2026-08-09 — decisions as built) + +**The defect.** C3 promised "compact JSON always" and the envelope +delivered it only at the top level. `anyblockjson.Marshal` emits the +format's canonical byte form — two-space indented with a trailing +newline (`marshalCanonical`, SPEC §4) — and the read path splits that +document into `map[string]json.RawMessage` and re-emits a compact +envelope whose `properties`/`blocks`/`refs` values keep their indented +bytes **verbatim**. Every default object read shipped an indented +document inside a compact wrapper. + +**Measured on the live account** (o200k, the six-document TOKENS corpus, +served bytes → the same documents re-rendered compact): −15.5 % (XS, +0 blocks) · −20.2 % (S, 12) · −21.3 % (M, 24) · −26.4 % (L, 66) · +−23.9 % (R, 22) · −21.4 % (K, 31); corpus total **−23.2 %**. This +reproduces TOKENS §1.1 (16–26 %) within a point on every document. + +**Where the fix lives, and why not in the format package.** In +`encodeEnvelope` (`v2/service/service.go`), which compacts each embedded +value with `json.Compact` before concatenating it. Three reasons the +serving layer is the right one: + +1. `marshalCanonical` is the format's **canonical byte encoding**. SPEC + §4's serialization canon ("UTF-8, LF, two-space indent") is what + §11.2's `Export ∘ Import` byte-stability is defined over, and what the + four `testdata/rich*.json` goldens pin byte-for-byte. Making it + compact would move all four goldens and silently flip ~30 in-package + `assert.Contains` probes that match on `": "` — six of them + `NotContains`, which would go **false-green**, not red. None of that + is a price a token saving should pay. +2. Nothing outside the tests depends on the *whitespace*: the etag hashes + tree heads (not the document), C8 idempotency hashes the client's + request body, `snapshotdiff` and the eval corruption scorer are + state-level, and every round-trip/byte-stability assertion compares + Marshal output to Marshal output under the same options. The one + cosmetic loser would be `cmd/anyblockroundtrip`'s `firstDiff` line + reporter and the human-diffable `.json` artifacts `anyblockrecover` + writes — both of which want the indent. +3. The envelope fix is **total — for the right reason** (corrected by the + Wave-0 review: the first write-up named "PATCH/PUT response documents" + and a "create echo" that do not exist — the edit routes return + `EditResult` and creates return `CreateResult`, plain structs via `c.JSON`, never + through `encodeEnvelope`). Only three handlers write bytes verbatim + (`c.Data`): `GetObject`, `markdownEnvelope` and `GetType` — and all + three serve `encodeEnvelope` output. Everything else exits via + `c.JSON`, which compacts embedded `json.RawMessage` on its own. So the + invariant is guarded by the **handler's write-path choice**: a future + read handler reaching for `c.Data` with bytes that did not pass through + `encodeEnvelope` reopens the hole. + +`json.Compact` is whitespace-only — the exported form runs with HTML +escaping OFF, so it neither re-escapes `<`/`>`/`&` nor rewrites +U+2028/U+2029, and the format writer's deliberately non-HTML-escaped +strings survive byte for byte (pinned by a test whose fixture carries the +raw U+2028/U+2029 characters, not just the six-character escape — +`json.Compact` could never touch the escape, so only the raw characters +pin that half of the claim). A value the JSON scanner rejects is appended +verbatim rather than erroring: `encodeEnvelope` is a formatter, not a +validator, so malformed input keeps exactly the body it produced before +compaction existed. + +**No golden moved and no fixture was regenerated** — which is the signal +that the change landed at the right layer. + +### 8.25 Wave 0.2 — `?ids=` splits into two document shapes (2026-08-09 — decisions as built) + +**The defect.** `CompactIds` is shorthand for two mechanisms with +**opposite** economics (`export.go:35-37`), and one query parameter moved +both: + +- `CompactBlockLabels` relabels doc-local block/row/column/view ids to + short suffixes. **Legend-less** (the server resolves a label by exact + id, else unique suffix — `matchBlockRef`, shared by `?block=`, `?view=`, + every block-addressed op and `resolveTablePart`) and **lossy** (the + originals are not recoverable from the document alone). A pure win on + reads: cheaper *and* easier for a small model to echo back. +- `CompactObjectRefs` shortens object refs through the `refs` legend. + Lossless, and a measured net **loss**: 85–90 % of refs in real documents + are used exactly once, so the legend lines cost more than the inline ids + they replace, and the indirection traps write-back of object-valued + properties (a model that saw `"ai52e"` inline must dig the legend to + name the object again). + +So you could not take the winner without the loser. The outline shape +already moved them independently (T7), which is the precedent this +generalizes. + +**As built.** `objectReadPlan` carries the two axes separately; +`V2ObjectQuery.validate` composes them into one of two shapes, and +`GetObject` just applies them: + +| `?ids=` | block ids | object refs | for | +|---|---|---|---| +| absent / `compact` | short doc-local labels | full inline, no legend | the default **edit** read | +| `full` | full | the `refs` legend | the **export** read: backups, and the shape to clone from | +| (outline, any `?ids=`) | short doc-local labels | full inline, no legend | T7, unchanged | + +*(Superseded by §8.26 in two cells: `full` no longer carries the legend — +object refs are full inline on every shape — and "short doc-local labels" +narrowed to machine-minted ids only.)* + +The old `?ids=full` (no compaction on either axis) is gone; nothing +depended on it and Wave 2's `?mode=` enum has no such profile. **Today's +default shape did not disappear — it became `?ids=full`**, so no shape was +invented and the export loop keeps exactly the bytes it had. *(§8.26 then +dropped the legend from `full`, so the export bytes changed once more — +deliberately. §8.27 removed the write-back leg of that loop entirely: the +export shape is a BACKUP shape, not a round-trip-through-PUT shape.)* + +**Legend resolution on input is untouched.** SPEC §9a's resolution rule is +total, and the create/PATCH paths still accept any document carrying a +legend, whoever produced it. + +**`GET …/types/{key}` rides along** — it delegates to `GetObject`, so a +type document's minted view ids come back as labels too. That is safe +because every consumer resolves by suffix +(`update_view`/`insert_view`/`move_view`/`delete_view` via `matchBlockRef`, +`?view=` via `resolveViewRef`), and because the internal documents those +ops work on are rendered **without** compaction — `list_read.go`'s +fixed-`"dataview"` block lookup and the applier's per-op re-render both +see full ids. Type creates reject a `blocks` array outright, so there was +no GET-type → PUT-type block loop to break even before PUT went away. +*(As first shipped this hardcoded the default query — so "the export shape is one query parameter +away" was false for types, and the well-known `dataview` block id was +served as `aview`. §8.26 threads `?ids=` through and the minted-shape +rule keeps `dataview` full by construction.)* + +**Measured on the live account** (o200k, the six-document TOKENS corpus, +each axis isolated against the same compact-encoded baseline): + +| doc | blocks | block-label axis | legend axis (live served bytes) | +|---|---|---|---| +| XS-props | 0 | — | −11.5 % | +| S-12blk | 12 | **0.0 %** | −4.6 % | +| M-24blk | 24 | **0.0 %** | −5.6 % | +| L-66blk | 66 | −19.1 % | −2.4 % | +| R-20refs | 22 | −1.8 % | −5.3 % | +| K-recipe | 31 | −21.9 % | −0.9 % | + +The legend column confirms TOKENS §1.2 on every document: dropping it is +a saving, never a cost. + +**The block-label column is bimodal, and the review's flat "~15 %" is +not what the corpus shows.** The rule behind the bimodality (as hardened +in §8.26): **opaque machine-minted ids compact, meaningful ids do not** — +relabeling fires on 24-hex bson ids and view UUIDs, where it is worth +**19–22 %**, and never on anything else. (As first shipped the mechanism +was accidental — a dash-free label *charset*, which keyed on the id's +last five characters rather than the id's nature: the UUID +`32726bf3-…-688e9525ed67` relabeled to `5ed67` despite its dashes, while +`pages-roadmap-home-1` did not, purely because of its tail `ome-1`. §8.26 +replaces the charset accident with the explicit minted-shape predicate, +which an agent can reason about in one sentence.) The S/M/R rows are 0 % +because their seeded ids are readable — a property of this demo account's +seeding, not of production documents; but it is also a real ceiling: a +document of meaningful ids gets nothing from the axis, by design. The +useful corollary: **on such documents the default read IS the export +read** — every id serves in full, so the two shapes are byte-identical +there, and the id-adoption trap (while PUT still existed) was confined to +minted-id documents. + +**Combined with §8.24, against the actual served bytes:** XS −24.9 % · +S −23.5 % · M −25.1 % · L −42.1 % · R −28.9 % · K −39.3 %; corpus total +**−33.1 %** — a third off every object read. + +**The one behaviour that got worse, named plainly — and then removed.** +PUT took the document's block ids literally. As first shipped, a +GET(default) → edit → PUT loop on a minted-id document did not "re-mint +with `diff_stats` visibility" as this section originally claimed — it +**adopted the 5-char labels as the stored block ids, permanently** +(reproduced: after the PUT the stored ids *were* `1bcb9`, `1c5c4`, … and +a PATCH with the original 24-hex id 404ed), breaking every id another +client held; and on tables `diff_stats` under-reported the rewrite (§8.26). +PATCH was never affected (it suffix-resolves). §8.26 closed the trap with +a refusal, and **§8.27 closed it by construction**: the literal-id channel +is gone, so no served vocabulary can reach the identity channel at all. +The follow-up this section left open — "teach PUT the same suffix +resolution the write ops already use", parked for Wave 2.1 — is retired +with its subject. + +### 8.26 Wave 0 hardening — served ids are a vocabulary, not an identity channel (2026-08-09 — decisions as built) + +A three-lens opus review of Wave 0 (encoding correctness, live +round-trips, tests/truthfulness) found the compaction half sound and the +id half leaking: the default read's block ids had become a different +vocabulary from the stored ids, and several write paths took served ids +literally. This section records the fixes — the relationship was fixed, +not the nine symptoms — plus the defects recorded deliberately unfixed. + +**The relabel rule inverted: only machine-minted ids relabel.** The +shipped predicate was "relabel unless the candidate label has a dirty +charset" — an accident that keyed on an id's last five characters rather +than its nature, relabeled meaning-bearing ids (`table1` → `able1` in the +golden; `featuredRelations` → `tions`; the documented `dataview` constant +→ `aview` on type reads), and carried a genuine aliasing hole: the label +census only counted ids longer than the label width, so a 5-char id and a +minted id sharing that suffix were served as the SAME id — silent +wrong-block PATCHes, a wrong `?block=` subtree, and a write-back of the +server's own read 400ing on `duplicate id` (live today via `?outline=true` +regardless of Wave 0.2). Now `isMintedLocalId` recognises the actual +minting sites — 24-char lowercase hex (`bson.NewObjectId().Hex()`: every +editor block/row/column id, and the format's own `defaultGenerateId`) and +RFC-4122 UUIDs (`uuid.New().String()`: view ids) — and everything else +keeps its full spelling *and is reserved*: the census counts every local +id and the `fullIds` avoid-set (already used by the refs labeler) rejects +any label equal to a reserved id. A false negative costs a few tokens; a +false positive destroys a meaningful identifier. The invariant "no two +blocks ever share a served id" is pinned by a test independently of the +rule that produces it. Notably the wrapper had this predicate first +(`fullBlockIdRe = ^[0-9a-f]{24}$`) — the server's charset rule was the +accidental one. + +**PUT refuses unowned ids instead of adopting them — SUPERSEDED by +§8.27, which removed PUT.** Reproduced live: GET(default) → PUT stored the +5-char labels AS the block ids, permanently — not the "re-mint visible in +diff_stats" §8.25 first claimed. The guard (`checkPutBlockIds`) collected +the body's local ids (blocks, table columns/rows, views — the relabel +domain) and refused any id the object's own export-shape marshal did not +carry, before the creating resolvers ran. It was explicitly *the honest +interim* until PUT learned the unique-suffix resolution C4 permits; the +interim ended by deleting the surface instead, which is the stronger +form of the same fix — a channel that cannot take an id literally cannot +adopt one. What survives is `docLocalIds` and the create-side +`warnLabelShapedIds` warning, whose subject (a clone adopting labels) is +real and harmless. + +**Partial reads are marked partial.** A `?block=` subtree read was a +schema-valid envelope with nothing marking it partial, and PUT of that +exact body deleted every block outside the subtree (reproduced: +`blocks_removed: 5` on a 6-block page) while the equally partial outline +was refused loudly. The subtree envelope carries `"subtree": true` — +schema-invalid by `additionalProperties: false`, the way outline is +partial by construction — and create names it precisely before the schema +does. (The PUT half of the refusal went with PUT; the marker and the +create refusal stand, and the delete-everything-outside failure mode is +now unreachable by construction.) + +**Create accepts a pasted read.** `POST /objects` 400ed on the `etag` of +every read shape while PUT stripped the same field. The create body goes +through the same envelope stripping (etag, warnings) and the subtree +refusal — and since §8.27 removed PUT, `normalizeCreateBody` is the only +place that stripping lives. Label-shaped ids (5 lowercase hex — every served label's exact +shape) ride a warning rather than a refusal: with no owned-id baseline a +label is indistinguishable from a rare authored 5-hex id, a clone of a +document that truly owns such ids must keep working, and adoption on a +fresh object breaks no other holder of the ids. + +**No shape serves the refs legend.** The export shape kept the legend that +this work's own measurement shows as a pure loss on the measured corpus +(the §8.25 legend axis: it costs 0.9–11.5 % per document, 5.3 % on the +ref-heaviest row, and saves on none) — which left no shape offering full +block ids without the indirection §8.25 itself calls a write-back trap. +*(Corrected by the release review: this section first cited "+0.6 % on a +41-block ref-heavy document" — a figure with no traceable provenance; the +corpus has no 41-block document and its ref-heaviest row is the 22-block +R-20refs at 5.3 %.)* `?ids=full` now means full ids AND full inline refs. +Legend *resolution on input* is untouched (SPEC §9a is total). The claim +stays scoped to the corpus: §1.2's own model has the legend winning at +≥2× ref reuse, which the corpus rarely shows. + +**`GET …/types/{key}` threads `?ids=`** so the export shape is one query +parameter away on types too; `dataview` needs no exemption because it is +not machine-minted. + +**The wrapper retired client-side relabeling.** `relabelDoc` was not the +harmless no-op §8.25's era assumed: its 24-hex predicate matches nothing +in a server-labeled document, so `session.Labels` was never written — +making the §7.4 ambiguity retry dead code — while the `if labels != nil` +guard preserved a STALE map that rewrote a just-read label into a full id +from a previous document version (accepted by `matchBlockRef` as an exact +match; reachable through the CLI's persistent session file across an +upgrade). The label map is gone; refs pass through verbatim; +`retryAmbiguous` rebuilds its pool from the re-read document's own served +ids; and because the ambiguity rewrite happens after the Idempotency-Key +is minted, `LastWrite` records it (`PriorHash` + `Rewrites`) so an +identical re-run reproduces the rewrite and REPLAYS against the C8 store +instead of 409ing or re-applying under a fresh key. + +**Recorded, deliberately unfixed:** + +- `diff_stats` under-reported a PUT identity rewrite on tables: reproduced + 4 added/4 removed while 3 row ids and 6 derived cell ids also changed + identity. Any §8.25-era argument that "diff_stats makes it visible" was + therefore half-true on tables. *(Moot since §8.27: no surface performs a + whole-document identity rewrite. The narrower fact — diff_stats does not + count derived table-cell identity — still holds and is still unfixed.)* +- Table columns **with at least one stored cell** never relabel — the + column's 5-char tail is shared with every derived cell id in that + column, so the census counts ≥ 2 — which means a served table mixes a + 5-char `row` with a 24-char `col`, `set_cell` sends that mix back + (resolveTablePart handles both), and such columns contribute 0 % of the + label saving. *(Scoped by the release review — "structurally never" was + overstated: a column with NO stored cells has no derived ids in the + census and DOES relabel, verified live, so served column ids are 5-char + or 24-char depending on sparsity.)* + +**Numbers.** The §8.25 measurements were not re-run live (the running +desktop app predates this branch; reviewers reconstructed served bytes +with a branch-built harness) and are expected to hold within noise: +the documents that saved were minted-id documents, which still relabel; +the 0 % rows were readable-id documents, which still do not — now by +rule rather than by charset accident. + +**Two trades of the minted-shape rule, unnoted when it landed.** The +outline got materially longer on readable-id documents — `ing-1` became +`notes-2024-meeting-1` — which matters because outline's whole value is +being the cheap map; readable ids are also the documents where the label +axis already saved 0 %, so the outline is where their cost now shows. +And the old charset rule *accidentally laundered* charset-dirty stored +block ids (an id the schema's `^[A-Za-z0-9_-]{1,64}$` pattern rejects +used to serve as its clean 5-char label); such ids now serve verbatim, +so a slightly wider class of documents fails its own `Validate` — +pre-existing data shape, no live producer found (ANOMALIES #12). + +**Release pass (the fourth-lens review of this section's own work).** A +follow-up review of the hardening found five code defects, all fixed on +this branch with fail-on-revert tests: + +- The `?block=` stored-id fallback mapped the resolved stored id back to + a served spelling with a first-match-wins suffix scan — any earlier + served id that happened to tail the matched stored id won (reproduced: + `?block=` returned the unrelated block `b1`). The + mapping is positional now (a second, uncompacted marshal of the same + read — same block set and order, only spellings differ); stored ids + never served (root, table wrappers, cells) 404, and a served-vocabulary + ambiguity stays a refusal. +- The bullet this section previously recorded as *deliberately unfixed* — + "`checkFreshIds` treats copied labels as fresh ids … cosmetic debris" — + was **false as written**: "never relabel or alias afterwards" covered + serving, not resolution. `matchBlockRef` returns on the first EXACT + match, so an adopted label *captured the reference* — reproduced: read → + `insert_blocks` with the read's own label → the next `replace_text` on + that label edited the copy while the original silently lost it. PATCH + now refuses any payload id that tails a kept block id, diagnosing the + pasted-label shape. +- `docLocalIds` skipped table-cell **descendants** (the §6.1 F10 array + form renders them as flat blocks with ids, in the relabel pool), so a + body whose only minted id lived inside a cell PUT its label back 200, + adopted permanently. It recurses now. The PUT consumer is gone (§8.27); + the fix lives on in create's label warning, which is where the helper + now sits. +- The wrapper's C8 record had no lower bound on its reuse window (a + backwards clock step revived an arbitrarily old key and its rewrite — + `LastWrite` persists in the CLI session file), judged the window from + two `now()` readings that could disagree across the boundary (a + rewritten body under a fresh identity), and kept only a single-level + rewrite chain (`PriorHash` captured the already-rewritten hash on a + second rewrite — reproduced double-apply). Floored, single-reading, + and PriorHash-once + merged rewrites now. +- `POST …/types` 400ed on the etag of its own `GET …/types/{key}` read — + this pass taught GetType to serve etag but never gave CreateType + `normalizeCreateBody`. It normalizes like every other create now. + +**Record corrections** (the commits cannot be amended, so the corrections +live here): + +- `9e18ac568`'s message ("additive h1 entries for versions already listed + — go-md2man, blackfriday — no dependency change") under-describes the + diff: 10 lines across SIX module@version pairs. `urfave/cli/v2 v2.25.7` + and `xrash/smetrics` were absent from go.sum entirely; + `go-md2man v2.0.7`, `urfave/cli v1.22.17` and `sigs.k8s.io/yaml v1.3.0` + are new versions (only blackfriday matches the message). No functional + harm — go.mod is untouched and `go mod verify` passes — but the record + was wrong. +- `e966b5bd2`'s message under-reports its yaml diff: 10 of the 17 hunks + predate the claimed Q5 backlog (strict-bind prose and `413` responses + originating in `b794b0c36`'s annotations, flushed here because the + artifacts had not been regenerated since). And "the sorts id" is not in + the artifacts at all — it lives in the `v2ViewSetPropDef` string + literal served by `GET /v2/schemas/ops/{op}`, structurally invisible + to swag. + +### 8.27 PUT removed — snapshots are for creates, edits are ops (2026-08-10 — decisions as built) + +`PUT /v2/spaces/{space_id}/objects/{object_id}` replaced an object's whole +document: the body became a `SmartBlockSnapshotBase`, `NewDocFromSnapshot` +materialized it, and `history.ResetToVersion` diff-applied it against the +live object. **It is gone, whole.** This section records why, so the shape +is not re-proposed. + +**The principle it leaves.** *Snapshots are for creates; edits are ops.* A +surface that requires materialising a WHOLE document to change part of it +is the wrong shape for this API — it pays whole-document cost in both +directions, it re-derives identity on every write, and it needs a repair +layer (`preserveEditorOwnedState`) to undo the damage its own round trip +causes. Approaches that lead back to snapshot generation on the edit path +are to be treated as a design smell, not a shortcut. + +**Four reasons, in the order they weigh.** + +1. **Token cost, both ways.** Changing one word cost ~2 400 tokens to read + plus ~2 400 to write, against **33** for a PATCH `replace_text` op — + two orders of magnitude, on the commonest edit there is (TOKENS §3's + flow table, M-24blk: "GET default → PATCH `replace_text` by id = + 2 417 + 33"; PUT pays the 2 417 again on the way out). +2. **Its one distinguishing property was conditional and unexercised.** + The id-matched minimal CRDT diff only helped a client that had + preserved the stored block ids. A client that had not got a full + rewrite — which `set_properties` + `replace_subtree` already produce, on + an id-addressed path that cannot silently mutate identity. +3. **No consumer.** Nothing in the tree called it: not the §7 wrapper + (which excluded it by design), not the CLI, not the evals, not the + integration tests. Only its own unit tests. +4. **It cost three review rounds.** Every id-identity defect of the Wave-0 + hardening is downstream of one asymmetry: **PUT took block ids + literally while PATCH resolves them.** Labels adopted as stored ids, + the cell-descendant hole, the `?ids=full`-before-PUT ceremony, the + owned-vocabulary refusal (§8.26) — all of it exists to protect one + surface from a vocabulary the rest of the API handles by construction. + Removing the consumer removes the asymmetry. + **Corrected by §8.29:** PATCH resolved its *reference* slots and took its + *payload* slots literally, so removing PUT removed one literal channel + and left three. The asymmetry was inside PATCH as well; §8.29 closes it + there. The reason still stands — it was just not the whole account. + +**Removed:** the route (`registerEditRoutes`) and its authz registry entry; +`PutObjectV2Handler` and its OpenAPI operation (`v2_put_object`); +`V2Service.PutObject`, `putPipeline`, `runEdit`, `normalizePutBody`, +`checkPutBlockIds`/`maxPutIdIssues`, `finishEdit`, `marshalForEdit`; +`apicore.ObjectMutator.ResetObject` (the port is single-method now) and the +adapter's reset implementation with `preserveEditorOwnedState`, +`preserveStructuralBlocks`, `copySubtree` and `isStructuralBlock`; and +`docCreateOptions.tolerateCorpseKeys` with its `anyRelationByKeyExists` +probe, whose only purpose was letting a GET→PUT of a corpse-held property +key round-trip (§8.23 cause 3). **That last removal was wrong and is +reverted in §8.29** — the tolerance belonged to the pasted-read-body case, +not to PUT, and create is where that case lives now. + +**Kept, and re-framed.** `?ids=full` survives as the **backup/export +shape** and as the read to clone from — not as "the PUT read"; the +id-spelling ceremony that existed only to feed PUT is deleted from the +guides. `docLocalIds` and the `"subtree": true` marker are shared with +create and keep their create halves (`warnLabelShapedIds`; the subtree +refusal). The C11 write-safety guard loses its PUT exemption: its 422 now +says "edit it in the app" rather than "replace it wholesale with PUT". +`ensureIdempotency` keeps `PUT` in its METHOD set deliberately — it +classifies methods, not routes, so a future mutation method is covered by +construction. + +**What this retires from the plan.** Wave 2.1's "teach PUT the +unique-suffix resolution C4 already permits" disappears with its subject, +as does the review-debt item "PUT and `POST /sets` still run whole-document +creating-resolver imports" for the PUT half (`POST /sets` still does). + +**The one capability it nominally served — "clear the document and write +new content" — is owed a replacement.** Today that costs a `delete_block` +per top-level block. The named follow-up is a **range block-remove op** on +PATCH (APIV2_PLAN.md), so the clear-and-rewrite case is one bounded op plus +one `insert_blocks`, at op cost rather than document cost. Deliberately not +built here: removing the wrong shape first is what makes the right one +easy to specify. + +**If a whole-document-replace client ever materialises**, it gets rebuilt +with id **resolution** from the start (`matchBlockRef`, as every other +write channel does) rather than inheriting the literal semantics that +caused these bugs. + +### 8.28 The id-compaction mechanism is not agent-facing (2026-08-10 — decisions as built) + +The compaction rule (§8.25) is now **absent from both agent guides**, and +that is deliberate rather than an omission. + +`core/api/v2/SKILL.md` and `cmd/anytype/SKILL.md` used to explain it: that +machine-minted ids are served as short labels, that labels are derived per +read, and that a structural edit can change one. Every word was true and +every word was a liability — it hands a model a concept it must reason +about, in exchange for a case the model cannot act on differently. Small +models were the ones this cost most. + +**What the guides say instead:** *use block ids exactly as a read served +them; if one is rejected as unknown, re-read.* One instruction, one +recovery, no mechanism. + +**Why that is safe, not merely shorter** — three properties of the rule as +built, none of which the agent has to know. *(This list was written before +the §8.29 audit and two of its three claims were overstated; they are +restated here as what the code actually guarantees. The section stands, but +it stands on the corrected version.)* + +1. **A label is never ambiguous — and a subset read cannot make one.** + When two minted ids share a last-5 suffix, *neither* shortens + (`mintedSuffixLabels`, the `counts[suffix] == 1` guard), and the census + runs in `buildCompactIds` over the WHOLE snapshot before any slicing — + so a `?block=` subtree read cannot hand out a label that the blocks it + omitted also claim. This is the load-bearing half and it is sound. + Pinned by `TestServedLabelsAvoidTailCollisions`, including the subset + case. + **Not** "the read never serves a block it cannot then resolve": that + conclusion is false for cell descendants, which a default read serves + with ids no channel resolves (F4 / §8.29). The guarantee is about + *labels being unambiguous*, not about *every served id being + addressable*. +2. **Every id slot resolves the same way — since §8.29.** `matchBlockRef` + tries the exact id, then a unique suffix, in *reference* slots and + *payload* slots alike. A full id, a served label and any unique tail are + all valid everywhere. + As originally written — "no write path depends on which shape a read + chose" — this was **false**: the payload slots took the served spelling + literally, so a document echoed back from a default read was renamed to + its labels. It is true now, and it is true because it was fixed, not + because it was restated. +3. **Staleness fails loud.** A label from an older read either still + resolves or is refused as unknown — which is exactly what the recovery + instruction answers. Unchanged, and now total: an unresolvable id in a + payload is refused too, rather than silently becoming a new block. + +**The residual, stated for the record and not for the guides:** a cached +label can retarget. Two paths, not one: + +- **Collision plus deletion.** The block is deleted *and* a new block + appears sharing that 20-bit tail. Inherent to any suffix scheme. +- **Retarget with neither** (missed when this section was written): a + suffix match is resolved against the CURRENT document, so a label cached + from an older read resolves to whatever now owns that tail. No deletion + is needed and no collision is needed — only that the tail's owner + changed. Post-§8.29 this is bounded by the ambiguity refusal (a tail with + two claimants is a 400, never a silent pick) and by minting: new ids are + 24-hex random, so an accidental tail hand-off is ~2⁻²⁰ per minted block. + +Documenting either to agents would cost more comprehension than the risk it +removes; the recovery instruction ("if an id is rejected, re-read") is the +agent-facing answer to both. + +**`?ids=full` keeps exactly one framing:** the backup/export shape — the +read to archive or clone from. With PUT gone (§8.27) there is no write-back +read, so it is not an editing knob and the guides do not present it as one. + +**If a future review finds the code and wants to "fix" the docs to match:** +this section is the answer — *the corrected version of it*. An audit +(§8.29) read the original three properties against the code and falsified +two of them; that is what this section is for, and being the standing answer +does not make it exempt from being checked. Check it again if you have +reason to. The mechanism belongs in §8.25 and in the API reference; it does +not belong in a guide whose readers are language models. + +### 8.29 Audit round after the PUT removal: payload ids, corpse keys, honest hints (2026-08-10 — decisions as built) + +§8.27 removed PUT on the stated grounds that no channel took block ids +literally any more, and §8.28 recorded three safety properties of the +compaction rule. An audit of both reproduced data corruption. Four +findings, fixed at their causes; every fix verified fail-on-revert. + +**F1 — the PATCH payload slots took ids literally (data corruption, +reproduced).** Reference slots resolved (`matchBlockRef`: exact, then unique +suffix). Payload slots did not — they went to the format importer verbatim, +which reads an id as an identity. The documented loop + +``` +GET ?block=aaaa1 # default = compact shape +PATCH {"op":"replace_subtree","id":"aaaa1","blocks":} +``` + +answered **200** with `blocks_added: 1, blocks_removed: 1` and permanently +renamed the stored `0000000000000000000aaaa1` to `aaaa1`. Consequences: +other clients' cached ids 404; the adopted id is not minted-shaped so it +never relabels again *and* permanently reserves that label in the +exporter's avoid-set; the CRDT records a delete plus a create where an edit +belonged. `update_block set:{rows:[…]}` did the same and reported it as the +innocuous `blocks_changed: 1`. `set_cell value` did it to cell descendants. + +The old guard (`checkFreshIds`) missed exactly these because +`keptBlockIds(leaving)` **subtracts the subtree being replaced** — so it +covered `insert_blocks` (where nothing is leaving) and skipped every op +whose payload replaces the label's own owner. + +*Fix:* payload ids resolve like reference ids, against the pre-op +vocabulary **including the leaving subtree** (`payloadids.go`; +`v2EditDoc.localIds` is the vocabulary — block, row, column, +cell-descendant and view ids, the exact domain relabeling covers, shared +with create's `docLocalIds` so the two cannot drift). The loop above is now +*correct*, not merely refused: identity is preserved and a no-op echo is a +genuine no-op (`diff_stats` all zero). + +*Unmatched id — refused, not minted.* A payload id that resolves to nothing +is a 400 with the C6 hint naming both legitimate moves. Minting over it +would silently turn a stale or mistyped id into a new block — the same +silent-wrong-thing class F1 is about — and it would keep a literal channel +open for non-minted-shaped ids. Refusing costs a caller who meant new +content one edit (`id` is omitted, the server mints, `created_blocks` +reports it), and it makes the rule total, which is what lets C4 finally say +"no channel takes ids literally" truthfully. This retires the one +affordance it removes: a client can no longer choose the id of a block it +creates through PATCH. Nothing in the tree used it (the wrapper authors via +`markdown`), and `created_blocks` returns what was minted. + +*Ambiguous suffix* is a 400 `ambiguous_input` listing the candidates. +*`checkFreshIds` is gone*, rewritten as `claimPayloadIds` — resolution +subsumed its tail-scan half, and two overlapping guards with different +coverage were the bug. *`created_blocks` now reports only minted ids*: a +resolved payload position names something that already existed. + +**F2 — the GET → POST clone broke on corpse-held property keys.** §8.27 +removed `tolerateCorpseKeys` because "PATCH names only the properties it +edits, so live-only is now the whole rule". Both halves were wrong: PATCH +has its own in-document escape (`checkKey` passes any key already in +`doc.properties`), and **create** — the channel advertised as "a pasted read +body creates a copy" — became the only write path with no tolerance at all. +A `GET` of an object holding a value of a since-archived relation, `POST`ed +back, was a 400; with a live key spelled one character away the hint read +*"did you mean corpse_kez?"*, steering the caller to move the value onto an +unrelated property. *Fix:* `propertyKeyHeldByAnyRelation` on the create +path. It is a round-trip tolerance, never an address — nothing resolves a +corpse key to a property object and no listing advertises it — and a key no +relation holds at all is still refused. (Both ways of dying are covered: a +UI-deleted relation carries `isUninstalled`, an archived one `isArchived`, +and the explicit no-op `isArchived Condition:None` filter is what suppresses +the store's injected `isArchived: false` default so the second is visible at +all. Both arms are pinned.) + +> **CORRECTION (§8.40).** The parenthesis above was half right and the fix +> was therefore half dead. A UI-deleted relation carries `isUninstalled` +> **and** `isDeleted` — the same Apply stamps the second — so suppressing +> only the `isArchived` default made this tolerance work for archived +> corpses and do nothing for uninstalled ones, which are the common case. +> Every fixture modelled `{isUninstalled}` alone and could not see it. +> §8.40 adds the `isDeleted Condition None` clause and runs the corpse +> fixtures over both store shapes. + +*The asymmetry left standing, on purpose.* PATCH `set_properties` still +refuses a corpse-held key the target object does not already carry, with the +same near-miss did-you-mean — so the F2 argument above ("the hint steers the +caller onto an unrelated property") applies verbatim to the PATCH +cross-object case, and it is not being fixed. The difference is what the +channel is FOR. Create's whole advertised loop is "a pasted read body creates +a copy" (§3(b)): the document the caller pastes is one this API served, and +the key is in it because the source object holds a value of that relation — +refusing is refusing our own output. `set_properties` is not a paste channel; +it names the properties an edit changes, one at a time, and a key the object +does not already hold arriving there is a caller writing a NEW value onto a +dead relation, which the near-miss hint is at least approximately right +about. The in-document escape (`checkKey` passes any key already on the +document) covers the round-trip half of PATCH — echoing a read body back — +which is the only part that shares create's justification. A later reader +should take this as decided, not overlooked: the day `set_properties` grows a +paste-shaped channel, the tolerance moves with it. + +**F3 — the system-managed exclusion lost its only coverage.** The +`STRelation | STRelationOption | FileObject | Participant` switch in +`checkEditPreconditions` was pinned solely by a PUT test. +`TestPatchExcludesSystemManagedObjects` covers all four on the PATCH path. + +**F4 — the 404 hints lied.** Both said *"GET the object with +`?outline=true` to list block ids"*. Cell descendants are served by a +default read (inside `rows[].cells[]`), resolve on **no** channel — not +`?block=` on either spelling, not `replace_text` — and the outline does not +list them (`blockIds()` and `exportShapeBlockIds` read only top-level +`blocks[]`). The guides' one recovery instruction therefore looped forever. +*Fix:* the hints now scope the outline's promise to the document's blocks +array and say outright which served ids are not block references (a table's +rows and columns are addressed by `set_cell`'s `row`/`col`, a dataview's +views by the view ops, and a block inside a cell is not individually +addressable — rewrite its cell). Pinned by a property test: every outline +entry must resolve as a `?block=` reference, and the cell descendant a +default read serves must not be in the outline. + +**Making cell descendants addressable is NOT taken here — ticketed.** What +it would cost, so the decision is a decision and not an oversight: + +1. The block-ref vocabulary would move from `doc.blockIds()` to + `doc.localIds()` — cheap, already built. +2. But `resolveRef` returns an **index into `doc.blocks`**, and every + ref-taking op works on `doc.blocks[idx]`. Cell descendants have no entry + there, so each of `update_block`, `replace_subtree`, `move_block`, + `delete_block`, `replace_text` and the `insert_blocks` targeting needs a + second addressing mode routed through the owning table's re-import (the + `set_cell` path) — or the flat view has to promote cell descendants to + first-class entries, which changes the served document shape. +3. `?block=` on a cell descendant would have to serve a *partial cell run*, + a shape the AnyBlock envelope cannot express (a cell descendant is not a + top-level block). +4. `?outline=true` would have to list them with a container marker, or the + caller reads them as siblings of the top-level run. + +The state side is already there — they are real blocks — so the entire cost +is in the addressing model and the served shapes. That is a format-level +decision, not a bug fix. + +### 8.30 A field that cannot succeed does not appear in that op's schema (2026-08-10 — decisions as built) + +§8.29-F1 made every PATCH payload id slot resolve through `matchBlockRef` +instead of being taken literally, and refused an id that resolves to +nothing. Correct, and it stays. But it finished a change of meaning that had +been half-made: after it, a payload `id` means exactly one thing — *name an +existing element and keep its identity* — and `insert_blocks`, whose payload +has no existing content to name, was left advertising a field in which +**every value is an error**. An id that resolves is refused as a duplicate +(`claimPayloadIds` with an empty allow-set); one that does not resolve is +refused as unresolvable. Both refusals are correct and neither is +reachable-by-repair: there is no third value. + +**Why the runtime guard is the wrong instrument.** A refusal is a fine +answer to a caller who guessed wrong. It is not an answer to the consumer +this API is built for. A small model emits the fields it is shown, and under +constrained decoding it does so *by construction* — the grammar compiled +from the published schema had `id` in it, so `id` was always emittable and +the guard could only ever fire after the fact. The model has no channel to +learn from the 400 within the request, and the next request is generated +from the same grammar. The instrument that reaches a constrained +decoder is the schema itself: with `additionalProperties: false` (C13), a +field absent from the schema cannot be emitted at all. *(Overstated as "the +only instrument" here, and only half-built: as shipped this covered the +block's OWN id slot — the nested `columns`/`rows` were untyped arrays and +`views` was not a block property at all, so below the top level the runtime +guard was doing all the work. Corrected and the nested entries typed in +§8.31.)* + +**The split.** The payload block def became two: + +- `v2OpNewBlockDef` — **new content** (`insert_blocks`): no `id`, on the + block or nested in `rows`/`columns` (typed as such since §8.31; `views` is + not a block property on either shape, so nothing can name one here). +- `v2OpBlockDef` — **existing content** (`replace_subtree`): keeps `id`, + because naming the block being replaced is what makes echoing a read back + a no-op instead of a rename (§8.29). + +`update_block`'s `set.{rows,columns,views}` and `set_cell`'s `value` are +existing-content payloads too — both allow ids drawn from the addressed +block's own subtree — and their schemas publish those channels untyped, so +they advertise no `id` to remove. + +The runtime is unchanged in what it accepts: `insert_blocks` still refuses an +id. What changed is the verdict's meaning — it no longer resolves the id at +all (`rejectPayloadIds`), so it says *"id is not part of this op"* instead +of reporting a duplicate or an unresolvable reference, neither of which +names the actual repair. + +**The rule going forward: a field that cannot succeed in an op does not +appear in that op's schema.** A runtime refusal is a backstop for the caller +who ignored the schema, never the mechanism. This is C2 — one concept, one +slot — applied one level down, to a *field*: `id` carried two meanings +("name this existing element" and "choose the id of this new one"), and the +two sharing one slot is precisely what produced F1. Splitting the slot is +the same move C2 makes on the surface's nouns. + +**Boundary check.** Every id-bearing payload slot was re-derived from the +code rather than assumed. Table rows, columns and cell descendants are real +state blocks, so `claimPayloadIds` sees them and the always-error verdict +holds for them in `insert_blocks`. Dataview **view** ids are the one slot no +guard covered: they are not blocks, so an `insert_blocks` payload naming an +existing view used to import a *second* dataview holding that view id — no +refusal, a duplicate view id in the document. Rejecting the field closes +that for `insert_blocks` without a new guard — but only for `insert_blocks`: +the EXISTING-content payloads (`update_block set:{views}`, `replace_subtree`) +still had no view-id guard at all, which is the seam §8.31 closes. +`update_view`/`insert_view` `set.sorts[].id` is a +different id domain (a sort's own id, output-only on reads, accepted back so +a read round-trips) and is untouched. + +**Not taken.** The unreferenced `$defs.block` that every op schema carries — +`set_properties` publishes a payload-block def it never `$ref`s — is noise, +not a trap: a decoder reaches only what the root schema references. Left +alone. The format's own document schema (`GET /v2/schemas/object`, +`pkg/lib/anyblockjson/schema`) is a different contract — a create body is a +snapshot, where an id is legitimate — and was not touched. + +> **Superseded in part by §8.33 (2026-08-11).** The verdict below — +> *"the mechanism is unconfirmed"* — was correct for this probe and is no +> longer the last word. A three-arm A/B on `edit_text`'s optional `block`, +> varying **only** the published definition, moved emission from 11/11 to +> **0/13** by removing the field from the schema and left it at 8/8 when the +> field stayed and prose told the model not to use it. Schema shape changes +> behaviour; prose does not — on a field these models actually emit, which is +> the separation the control below could not produce. What §8.33 does **not** +> support is the inference that removal is therefore good: the arm without +> the field relocated the value into `object`, the only id-shaped slot left, +> and looped. Read the mechanism as confirmed and the *remedy* as +> field-by-field. + +**Measured 2026-08-10: the removal is unregressed, the mechanism is +unconfirmed.** `cmd/apiv2eval -probe` put 210 real calls through the +published op schemas (`gemma4:e2b` and `gemma4:e4b`, three runs — 120 with +the example as published, 60 with it at op level, 30 with the discriminator +diagnostic; artifacts in the gitignored `eval-out/`). **Zero** payload `id` +emissions, at any path, in all 210: + +| arm | payloads | schema publishes a payload `id`? | `id` emitted | +|---|---|---|---| +| `insert_blocks` | 140 | no (this section's change) | 0 | +| `replace_subtree` | 70 | **yes** — and half of them come from the `echo_block_existing` case, which quotes a read block WITH its id and asks for the replacement *"keeping the block's identity"* | 0 | + +The second row is the control, and it is what limits the claim. These models +did not write a payload `id` **whether they were shown one or not**, so the +two arms did not separate and the probe cannot say the schema is what stopped +the first arm. What it does say is that the removal cost nothing: no +generation needed the slot it lost. The argument for §8.30 therefore rests +where it needs no behavioural claim at all — a field in which every value is +an error is an incoherent thing to publish, whatever any particular decoder +would have done with it. The paragraph above still reads *"the instrument +that reaches a constrained decoder is the schema itself"*; treat that as the +design's reasoning, not as a measured result. *(§8.33 measured it: on a +field the models do emit, the schema is what decides — so that sentence is +now a result and not only reasoning. The removal being the right response +to it remains a per-field judgment, and §8.33 is the case where it was +not.)* + +Where the rule *is* argued by measurement is its second instance: `position` +on `insert_blocks` was published with a description that made it read inert, +and in the no-target shape **every** value of it was a 400 — a shape +`gemma4:e2b` produced on 20 of its payloads, 10 of 10 in each of the two +cases that reach for it. That is the same rule catching a second field, and +it was fixed the other way round — §8.32 gave the field a meaning instead of +removing it, because both of its values named a real intent. + +### 8.31 The two halves of the id rule disagreed about what "exists" means (2026-08-10 — decisions as built) + +§8.29 made every PATCH payload id slot RESOLVE, and §8.30 removed the slot +from the ops where no value of it can succeed. Both are about what an id may +*name*. The other half of the rule — what an id may *claim* — was never +brought along, and the two halves were reading different documents: + +| | resolution (`payloadids.go`) | collision guard (`claimPayloadIds`) | +|---|---|---| +| domain | `doc.localIds()` — blocks, table columns, rows, cell descendants **and dataview views** | `a.st.Exists` — **blocks only**, because it walks the imported `[]*model.Block` and a view is not an element of it | + +A dataview **view id** therefore resolved but could never collide. Two +reproduced consequences, both 200 before: + +**(a) Duplicate view ids sailed through.** +`update_block {"id":"dataview","set":{"views":[{"id":"viewAll1",…},{"id":"viewAll1",…}]}}` +stored a document holding two views under one id. `matchViewRef` returns the +first exact match, so a later `update_view {"view":"viewAll1"}` renamed only +view #1 — and every subsequent view op addressed view #1 **forever**; the +second was unreachable for the life of the object. A column or a row in the +identical position was refused correctly, which is what makes this a seam +rather than a policy: the guard covered every id slot that happened to be a +block. + +**(b) A block could adopt a view's id.** +`replace_subtree {"id":"blockPara1","blocks":[{"id":"viewA1","type":"paragraph"}]}` +stored a paragraph whose id equals a live view's — reachable by compact +suffix too, since view ids are in the relabel pool (§9a). Damage is bounded +today (block refs and view refs resolve against different lists) but the +document holds an identity collision no read can distinguish, and "bounded +today" is not a property to ship. + +**Fix at the API layer.** `payloadIdExists` is now the one answer to "does +this id already name something in this object", and it is the **union** of +the two views above: `a.st.Exists` (which sees the state blocks the served +document does not carry at all — root, title, header) **∪** the pre-op +`doc.localIds()` (which sees the doc-local ids that are not blocks — views). +`claimPayloadIds` claims view ids alongside block ids, so a duplicate within +one payload is caught, and `collectSubtreeIds` — the "ids this op may reuse" +set — now carries a dataview block's view ids, because an op that replaces a +dataview block replaces the identities it holds and echoing its views back +must keep them. Both halves ask one question of one domain; there is no +remaining slot where the resolver and the guard disagree. + +**And at the format layer.** `anyblockjson.Validate`'s uniqueness domain +(`claimId`) covered blocks, columns, rows and derived cells but not +`views[].id` — so a duplicate view id was invalid-but-unvalidated on **every** +channel, create and import included, not only PATCH. It is now checked in +`checkDataviewViews`, which means (a) is refused by the fragment import +before the API guard is even reached. That ordering is the point of fixing +it there. + +**The uniqueness scope chosen for view ids: within the dataview BLOCK, not +the document.** This is the only id domain in the format that is not +document-wide (SPEC §4), and both directions were checked against the code +rather than assumed: + +- *Why not narrower.* Every consumer resolves a view reference within ONE + dataview's `views` list (`matchViewRef` over `viewIdList(views)`; the + client's tabs), and the per-view editor state — `groupOrders`, + `objectOrders` — is keyed by view id **inside the same + `BlockContentDataview`**. A repeat inside one block makes the second view + permanently unaddressable, which is exactly bug (a). +- *Why not document-wide.* It would reject data the app itself produces. + `template.MakeDataviewContent` mints the first view of every set, + collection and type with the literal id `"default"`, and + `dataviewservice.CopyDataviewToBlock` copies a target object's views + **verbatim** into an inline dataview block — so a page with two inline + collections legitimately holds two views called `"default"`. A + document-wide error would fail on real exports. + +**Why the API layer is nevertheless stricter than the format**, and why that +is not an inconsistency: the format lets a view id collide with a *block* id +(different domains), while the API refuses it. The API's own id vocabulary +**is** document-wide — one compact-relabel pool (§9a) and one payload +resolver whose `localIds()` deduplicates — so it declines to *create* a +collision it could not later describe to a reader. It does not retro-refuse +one it is shown: a document that already holds one still reads and still +resolves. + +**The receipt the refusals promise is now delivered.** Both +`unresolvedPayloadIdError` and `newContentIdError` tell the caller to omit +the id because *"the server mints one and returns it in `created_blocks`"* — +but `created_blocks` was written only in `decodePayloadRun`, for **top-level +run blocks**. A minted view id, a minted cell descendant, and the row/column +ids of a table created through `insert_blocks` were all unreported — and +those are precisely the slots the refusals fire on (the §8.30 test asserts +path `ops[0].blocks[0].rows[0].id`). Reporting was chosen over rewording: +the alternative costs a model that must re-read to learn an id it just +created a whole round trip, and the fix is one walk. Minting moved from the +format importer into the API's slot walk, so every empty id slot is filled +and reported under **its own payload path** — `ops[0].blocks[0].rows[1]`, +`ops[0].value[1]`, `ops[0].set.views[2]`. Views go to `created_views` (a view +is not a block; that map already existed for `insert_view`), everything else +to `created_blocks`. + +**One walk, three passes.** Resolution and rejection used to be two hand-kept +walkers over the same slot set (`resolvePayloadBlock` / `rejectPayloadIds`), +with a comment promising they could not drift — and the drift that mattered +was a third participant, the guard, walking something else entirely. +`walkPayloadIdSlots` is now the only walk; its visitors are the three things +a slot can need (resolve, reject, mint). + +**§8.30's overclaim, corrected.** It argued the schema is *"the only +instrument that works against a decoder that emits what it sees"*, and +`v2OpNewBlockDef` said *"no id slot — neither here nor inside +rows/columns/views"*. Neither was true of the nested slots: the served +`columns`/`rows` were `{"type":"array","maxItems":N}` with **no `items`**, +and `views` is not a property of the block def at all. The schema constrained +the top-level slot and nothing below it; the runtime guard did all the work +there, and the new test's `schemaPropertyOwners` found no nested `id` only +because there was no nested schema — the same assertion would have passed for +`replace_subtree`. *Fix:* the nested entries are typed. `columns` and `rows` +publish `items` defs that are themselves `additionalProperties: false`, and +the id slot inside them follows the same §8.30 split as the block's own — +present on `v2OpBlockDef`, absent on `v2OpNewBlockDef`. What is **not** typed +is the interior of a cell run (string | null | object | array of blocks — +recursive, and a strict recursive def is a real cost to a constrained +decoder); there the runtime guard is the instrument, and the cell +description says so rather than implying otherwise. `views` is published on +**neither** shape, so no payload block can name a view through this channel +at all — the corrected claim, and what the block descriptions now say. The +test asserts the nested defs exist, are strict, and carry an `id` exactly on +the existing-content shape, so it fails both if a nested slot regains an id +and if the nested defs are removed again. + +**The new-content op set is one set.** The runtime literal `"insert_blocks"` +and `opSchemaNewContent(…)` were independent statements of the same fact. +Runtime-without-schema is merely strict; **schema-without-runtime re-creates +§8.30's exact bug** — an op publishing an id no value of which can succeed. +`v2NewContentOps` (ops.go) is now read by both: `decodePayloadRun` picks the +rejecting visitor from it, and `opSchema` picks the payload-block def from +it. `opSchema` takes the op NAME as its first argument for the same reason — +the `op` const, the required `op` field and the block def all derive from it +instead of being spelled three times. The schema test is table-driven over +`v2NewContentOps` × `v2OpNames`, so an op added to one half and missed in +the other fails. + +**What the seam says about the design.** The id rule was stated once and +implemented twice, and the second implementation was a *by-product* — a +guard written when payload ids were taken literally, kept when they started +resolving, never re-derived against the vocabulary the resolver had grown. +The rule "ids resolve against `doc.localIds()`" was true of one half and +false of the other for two releases, and nothing in the type system, the +tests or the prose could see the disagreement because each half was +individually correct about its own domain. The structural answer taken here +is to give the domain a NAME (`payloadIdExists`) that both halves call, +rather than two correct-looking expressions; the same move as `localIds()` +being shared by create's warning and the PATCH resolver. Where a rule has two +enforcement points, the shared thing should be the predicate, not the +sentence in a comment saying they agree. + +### 8.32 Three defects a small model found that five review rounds did not (2026-08-10 — decisions as built) + +§8.24–§8.31 were six passes of design review over the same surface, by +readers who know what every field means. `cmd/apiv2eval -probe` put 210 calls +from `gemma4:e2b`/`e4b` through the schemas the product actually publishes, +with no live API and no repair loop, and the transcripts named three defects +in an afternoon. None of them is subtle in hindsight. All three are the same +kind of thing: **the surface was reviewed as a specification and consumed as +a prompt**, and a reader who already knows the answer cannot see a field that +reads wrong, an example that contradicts its schema, or a vocabulary that was +never published. A generator has no other information, so it sees exactly +that and nothing else. + +**F1. `position` with no targeting field was a guaranteed 400 — now it names +an end of the document.** `resolveTarget` refused it: *"position without a +targeting field is meaningless"*. The published description said *"with +`inside` only; default last"*, which reads to a model as *last is the +default, so naming it is harmless* — and `gemma4:e2b` wrote exactly + +```json +{"markdown":"## Risks\n- Vendor delay","op":"insert_blocks","position":"last"} +``` + +on 20 payloads: 10 of 10 in *add a section at the end*, 10 of 10 in *copy +this block as new content*. Every one a 400 at `ops[0].position`. + +Accepting-and-ignoring was refused. `first` and `last` are **different +intents**, and silently discarding one would do the wrong thing for that +one — the silent-wrong-action class five rounds have been spent removing. +The field was made meaningful instead: with no `after`/`before`/`inside`, +`position` picks which end of the DOCUMENT, `first` the start and `last` (or +absent) the end. That turns a guaranteed refusal into the obvious reading, +and it gives *"insert at the beginning"* an expression that previously +required reading the document first only to learn the first block's id. +`move_block` shares `resolveTarget` and gets the same meaning: one vocabulary, +one meaning, in both ops that use it. + +**Root-first is not the state root's `InnerFirst`.** `InsertTo("")` targets +the state root, whose FIRST child is the structural header — title, +description, featured properties — which SPEC §7 keeps out of the served +document. Prepending there lands the block *above the title*, and nothing +repairs it until the object is next initialised (`template.RequireHeader` / +`normalizeTree` run at open, not at apply). The start of the *document* is +"before the first document block" — the same slot `before: ` +names, needing no knowledge of the header at all — with the append as the +fallback when there is no document block to sit before. `rootTarget` +(stateops.go) is that one resolution, and `move_block` passes it the moved +subtree to skip, so `position:"first"` on the block that is already first +anchors against the block *after* it instead of failing InsertTo's +"blockIds contains target" or, worse, falling through to the append at the +other end. + +The refusal that remains is the one the anchor makes redundant: `position` +alongside `after`/`before` is still `validation_failed`, because there the +anchor already names the slot. An out-of-enum value is still refused too — +`checkPosition` is now one check for both placements. + +**F2. Every `GET /v2/schemas/ops/{op}` served an example its own schema +rejects.** All 14. The `schema` describes ONE op — `additionalProperties: +false`, `op` required with a `const` — and the `example` was a whole request +body, `{"ops":[{…}]}`. The example beside the schema was therefore not an +instance of it, and a consumer that reads the pair together (the small +consumer §5 built this route for) got two contradictory shapes. Measured: +with the wrapped example, `gemma4:e4b` omitted the required `op` field on +**9 of 60** calls (`e2b`: 10 of 60); with the example unwrapped to op level, +**0 of 60** — and 0 of 30 in the follow-up run. + +Examples are now the op object itself. The pin the wrapper manifest has had +all along (`TestExamplesAcceptedByOwnGBNF` — every served example must be in +the language of the served grammar) is now on this route too, table-driven +over all 14 ops and compiling the served schema with a real validator, so a +new op cannot land with an example its own schema refuses. The eval harness's +`TestServedOpExampleIsNotAnInstanceOfItsOwnSchema`, written to *document* the +defect, is inverted rather than deleted: it now asserts the instance +relation, and the harness's `-probe-example` knob no longer separates two +shapes because both are the same bytes. + +**F3. The payload block's `type` published no vocabulary.** It was +`{"type":"string","maxLength":64}`, beside a block description pointing at +`GET /v2/schemas/object` — a fetch a decoder cannot make. Asked to add a +checkbox item, `gemma4:e2b` answered + +```json +{"type":"bulletedListItem","text":"[ ] Follow up"} +``` + +**10 times out of 10**: a plausible type plus a literal markdown checkbox in +the text, which is what inventing a vocabulary looks like. `checkbox` is a +real type; it was simply never shown. + +The enum is now published on both payload-block defs, and it is **derived, +not copied**: `anyblockjson.BlockTypeNames()` reads the names out of the +format's own embedded JSON Schema (`$defs.blockCore.properties.type.enum`) at +startup, and `AuthorableBlockTypeNames()` subtracts the §7 structural types — +`title`, `description`, `featuredProperties` — which import absorbs into +properties or drops, so a payload naming one produces no block. That +subtraction is §8.30's rule one level down, applied to a *value*: an enum +must not offer a value no caller can succeed with. The structural set is the +same map `topLevelBlocks` (import.go) switches on, so "which types are +structural" is stated once. A hand-copied list is the drift class §8.31 was +about; there is no second list here to drift. + +**The token cost, on the record.** 39 names in the format's enum, 36 +published. Measured with the Gemma 3 tokenizer (ollama `prompt_eval_count`, +delta method so the chat template cancels): the `type` property goes from +**12 to 100 tokens** — **+88** — and a served op schema from ~867–945 to +~956–1034 tokens, **+89 each, +9.4% to +10.3%**. That is paid on all 14 op +schemas, because `$defs.block` is carried unconditionally (§8.30 "Not +taken"); 12 of the 14 never `$ref` it, so a consumer that fetches every op +pays ~1.2k tokens for an enum most of those schemas do not reference. Making +`$defs` conditional on reference would recover that and about 1.5 KB per +non-referencing op besides — a bigger win than this enum is a cost, and +deliberately left as its own change rather than folded in here. Against the +cost: the vocabulary is 10% of the schema and it is the 10% without which the +`type` field is a guess. + +**What the three have in common, and what it says about review.** Each was +a *published artifact* that a specification reader completes from knowledge +they already have — "position obviously needs a target", "the example is +obviously illustrative", "the type list is obviously at the other endpoint" — +and that a generator completes from the bytes in front of it. Five rounds of +careful review did not find them because review reads for *correctness of +meaning*, and all three were meaning-correct; they were wrong as *stimulus*. +The cheap instrument for that class is not another reading. It is 210 calls +through the published bytes with no repair loop, which is a couple of hours +of a small model's time. Where the earlier rounds did land is in what made +these fixes short: the derivation points already existed (`v2NewContentOps`, +the vocabulary exports of §8.17), so each fix is a wiring, not a new list. + +**Not taken.** Neither `-probe` case set nor the model host was re-run to +measure F1 and F3 after the fix (the full matrix is a 3-hour serialized job) +— F1's before/after is pinned by unit tests and by the live surface, F3's by +the enum being the format's own vocabulary. The `position` semantics are +unchanged for the `inside` placement, and no other op's targeting vocabulary +was touched. + +### 8.33 The A/B that settled §8.30's mechanism, and three defects a live run found (2026-08-11 — decisions as built) + +§8.32 read the surface as a prompt with no live API behind it. This run put +`gemma4:e2b` through the product's own MCP wrapper against the real local +server — 60 attempts, four tasks, temperature 0, artifacts in the gitignored +`eval-out/attempts.jsonl` (run `20260810-222855`). The A/B answers the +question §8.30 could not. + +**Every number in this section is `gemma4:e2b`.** The next run added +`gemma4:e4b` and it scores far worse on the wrapper — for one string-handling +reason that has nothing to do with the wrapper's shape, and that this section +predates. Read §8.34 before drawing a model-vs-surface conclusion from either. The three defects are what a *loop* exposes that a +schema read cannot: each needs a tool call to have happened before the next +one goes wrong. + +#### The A/B: schema shape changes behaviour, prose does not + +Three arms differed in **one thing** — the `edit_text` definition published +in `tools/list`. The runner behind them was the same object, so a `block` +sent to an arm that publishes none still works and is still counted. + +| arm | `edit_text` definition | calls | …with `block` | +|---|---|---|---| +| `ab/a-shipped` | as shipped: `block` optional | 11 | **11** | +| `ab/b2-prose` | `block` published, description says the snippet locates it and to prefer that | 8 | **8** | +| `ab/b1-noblock` | `block` removed from the schema | 13 | **0** | + +The field is supplied on **every single call** where the schema shows it, +including the arm whose prose argues against it, and on **none** where the +schema does not. This is the separation §8.30's 210-call probe could not +produce: there the control field (`replace_subtree`'s payload `id`) was not +emitted *whether it was shown or not*, so the arms never diverged and the +probe could only report that the removal cost nothing. Here the field is one +these models reach for by default, and it moves 11/11 → 0/13 on a schema +edit and 11/11 → 8/8 on a prose edit. §8.30's *"the instrument that reaches a +constrained decoder is the schema itself"* is now measured, not argued. + +**And `block` stays published.** The arm that removed it was worse on +everything the removal was supposed to help. Wasted reads rose 8 → 19. With +no `block` slot, the model put the block label in `object` — the only +id-shaped argument left — and then, on the useless refusal that produced +(defect 2 below), repeated the identical call three times and abandoned: +`identical_repeat=3`, a signal that appears in **no other arm**. Removing a +field does not remove the impulse to supply the value. It relocates it, and +`object` is a worse home for a block reference than `block` is. The rule +§8.30 states is about fields *in which every value is an error*; `block` is +not that field — every value of it can succeed — so the rule never applied +to it, and the A/B is evidence about the **mechanism**, not a licence to run +the removal anywhere the mechanism operates. + +**The success rates are noise and are not cited.** 60%/80%/85% across the +arms, n=5 per cell. The two tasks that never call `edit_text` at all +(`append-section`, `set-property`) varied as much between arms as the two +that do. Nothing in this section rests on them. + +#### Defect 1 — a `find` with no criteria was not a search, but said it was + +`find {"space": …}` with no `query`, `type` or `filter` matched nothing and +rendered *"78 matches"* over a numbered handle list. **12 of 67 find calls in +the run were this shape** — just under a fifth of all searches, and every one +of them on a task whose prompt names the note by title, so every one a +dropped argument rather than an intent to browse. What the model did with +the result: + +- 4 attempts addressed handle 1 — an arbitrary object. Three read it, + noticed, and re-found; +- **the fourth read it and then wrote to it**: three blocks appended to + `Kimubabe` while the task named `Takosize`, reported as done; +- 2 attempts stopped on the bare `find` and claimed success having written + nothing; +- 9 of the 12 re-ran `find` *with* a query unprompted, which is the repair + already being in the model's repertoire. + +The receipt worked and did not help: `ok — "Kimubabe": 3 added` named the +wrong object plainly and the model read past it. A receipt is a record, not +a control. + +**Decided: a bare `find` lists, and a listing assigns no handles.** The +alternatives were refusing the call outright, or leaving it and steering +harder. + +Refusing was rejected on the B1 finding above: the impulse relocates. +`find {space, type:"page"}` is a legitimate search that returns the same +arbitrary first row, so a refusal that only bans the bare shape moves the +mistake one argument sideways and makes it un-refusable. Steering harder was +rejected on the B2 finding: the MCP instructions already say *"find (space + +query/type/filter)"* and the model dropped them anyway. + +What is left is changing what the call *produces*. A bare `find` now returns +the space's objects **unnumbered**, under a first line stating that nothing +was searched for, and clears `Session.Handles`. The browse intent is still +served — you can see what a space holds, which is a real thing to want — but +nothing the listing returns can be passed as `object`, so the wrong-object +write is unreachable rather than discouraged. A handle used afterwards earns +its own refusal naming the repair, and *not* `errNoSession`'s "run find +first", which would be false (find has run) and reads as a repair already +attempted. Any one of the three criteria makes it a search again, numbered +as before. + +The name the act gets is in the output, not in a thirteenth tool. Adding one +costs every model on every call — schema tokens, one more row of selection +pressure, and the set sits deliberately under the >15-tool cliff — to serve +an intent that is one degenerate argument shape of a tool already present. +What failed was never that browsing was unavailable. It was that browsing +looked like matching, and that is fixed where it was broken: in the +rendering and in the handle table. + +#### Defect 2 — the `object` refusal named no repair + +`edit_text {"object":"767cb", "find":"Q3", "replace":"Q4"}` earned `object +"767cb" not found in space "bafyrei…"`. True, and inert: it names the +failure and no repair, and the model sent it three more times before giving +up. This is H3's worst case, and the run's own numbers say the rest of the +surface does better — refusals that name a field get `fixed_named_field`, +`switched_to_read`, `switched_tool`; only this one produced +`identical_repeat`. + +Three shapes arrive in `object` and each has a known repair: a block +reference the model just read (5 in the run), the **space** id (6 in the +run), and an object's name. The wrapper now says which: + +``` +object "767cb" not found in space "bafyrei…" — that is a block reference: +read serves those, and they go in `block`. `object` takes a handle number +from the last find (1, 2, …) +``` + +Three properties of how it is built, each deliberate: + +- **It appends, never replaces.** The server's 404 is the fact; the hint is + the repair. Dropping the fact would hide which reference failed. +- **It runs on `Run`'s error path, not inside an executor.** The mistake is + a property of the *argument*, so every tool taking `object` gets the steer + by construction — `read`, `set_properties`, `move_block`, all of them — + and a new tool taking `object` inherits it without a line of code. + Fixing it in `runEditText` would have fixed one call site of nine. +- **It fires only after the server has said not-found**, and only when the + server's message names the caller's own reference. A pre-flight shape + check would have to prove that no legitimate object id is 5–24 hex + characters; the post-404 hint needs no such proof, because the 404 has + already established the value is not an object. The cost is one HTTP round + trip on a mistake, which is nothing. + +#### Defect 3 — `describe` answered a different question than it was asked + +Told to set a description, the model ran `describe`, did not find one, and +answered *"The note type does not have a 'description' property, so I cannot +set it."* The API does it in one call. + +The cause is that `describe` read the type's **recommended** lists — what a +client renders in its properties panel — and served them under *"use these +exact property keys … in create and set_properties"*. Those are different +sets, and they disagree in both directions. Live, `page` recommends +`createdDate` and `creator` (which `set_properties` **refuses**, SPEC §4a) +and `backlinks`, `links`, `lastModifiedBy`, `lastOpenedDate` (which it +accepts and silently ignores — see below); it recommends neither `name` nor +`description`. + +**Both of the two properties every object has were invisible.** `description` +is in the space's property index and no type names it. `name` is worse: it +is `hidden` in the bundle, so `GET /v2/spaces/{s}/properties` — which +excludes hidden relations — does not list it either, and no type recommends +it because clients render it as the title. It was reachable from no read of +the surface at all, on the one tool whose job is to answer *what can I set*. + +`describe` now reports the settable set: the type's own properties first +(the curation is real signal and its order carries it), then the rest of the +space's property index, then a line naming the output-only keys as +read-only rather than dropping them — a caller who saw `createdDate` in a +read and cannot find it in `describe` would reasonably conclude `describe` +is incomplete, which is the defect this rendering exists to close. `name` is +added from a small always-settable list, since neither source can produce +it. + +The output-only set is now stated **once**, in `v2model`, and both layers +read it: the service refuses a `set_properties` naming one, and `describe` +must not advertise one as settable. A second copy in the wrapper is the +drift class §8.31 was about. + +Option lists stay bounded to the type's own selects — one HTTP call each, +and fetching them for a whole space's index would turn `describe` into +dozens of round trips for values the A2 guard already names on refusal. + +**The token cost, on the record.** Measured with the Gemma 3 tokenizer +(ollama `prompt_eval_count`, delta method), `describe page` in the eval +space goes from **76 to 281 tokens — +205**, for 38 space properties. It is +paid once per task, against a ~10k-token attempt, and it buys the difference +between a tool that answers the question and a tool that produced a +confident refusal of a possible task. The cost scales with the space's +property count; the off-type listing is bounded at 120 rows and degrades +into a count past that. + +#### Also found, filed not fixed: SPEC §4a's output-only set is too small + +Verified live against the running API: `set_properties` accepts +`backlinks`, `links`, `lastModifiedBy` and `lastOpenedDate`, answers **200 +with `properties_changed: 0`**, and writes nothing. They are derived, marked +`readonly` in the bundle, and absent from `v2OutputOnlyPropertyKeys`. That +is an accepted write that does nothing, reported as success — the +silent-wrong-action class these rounds have been spent removing, and the one +place it survives. It is four of the six rows `page`'s recommended list +leads with, so it is also what `describe`'s first section now shows. + +Not fixed here: widening the refusal set turns previously-200 requests into +400s on the REST surface, which is a contract change that deserves its own +decision rather than a fold-in to a wrapper fix. `PropertyRow` carries no +read-only flag either, so the wrapper cannot mark them without the server +saying so first — the fix is server-side in both halves. + +#### What the spot checks did and did not show + +One live attempt per fix (`gemma4:e2b`, one task, one attempt, run +sequentially). All three passed — and **none of the three reached the +failure point**: the model supplied its `query`, put its block ref in +`block`, and set `description` without calling `describe` at all. They are +evidence of no regression on the changed surface and nothing more; a +sample of one on a high-variance model is not a rate, and is not offered as +one. The before/after that actually pins each fix is deterministic — the +live CLI against the running server, and a unit test per fix that fails when +the fix is reverted (all three reverts run and confirmed). + +### 8.34 A refusal that is correct and unactionable is a defect — stated once (2026-08-11 — decisions as built) + +Run `20260810-235748`: 110 attempts, `gemma4:e2b` and `gemma4:e4b`, three +surfaces, temperature 0, artifacts in the gitignored +`eval-out/attempts.jsonl`. It looked like a capability result and was a +string-handling quirk. + +| model | arm | passed | of | +|---|---|---|---| +| `gemma4:e2b` | wrapper/small | 18 | 20 | +| `gemma4:e2b` | wrapper/large | 21 | 30 | +| `gemma4:e2b` | ops | 23 | 30 | +| `gemma4:e4b` | wrapper/small | 3 | 8 | +| `gemma4:e4b` | wrapper/large | **2** | **12** | +| `gemma4:e4b` | ops | 8 | 10 | + +The obvious reading — the bigger model is worse at the wrapper and fine on +the raw op surface — is wrong twice over. + +#### The finding: one string, mangled in one argument + +A space id has two dot-joined parts: +`bafyreihwvsaekzzyb54o7um4hdpvpn5b2invn75lmijhhtghblvphxwz2i.28y6mgnwgodt7`. +**`gemma4:e4b` truncates it at the dot**, passing only the prefix — plausibly +reading `.28y6mgnwgodt7` as a file extension. It earned: + +``` +space "bafyreihwvsaekzzyb54o7um4hdpvpn5b2invn75lmijhhtghblvphxwz2i" not found — list spaces with GET /v2/spaces +``` + +**74 of the 79 `find` calls in e4b's 15 wrapper failures are this** (83 of 93 +across all 20 of its wrapper attempts). Every single truncation is in +`find`'s `space` argument; **not one** appears in any other argument of any +other tool. `gemma4:e2b` never does it, in any arm. In one attempt e4b called +`spaces`, was served the full ids, and went straight back to the truncated +form — the identical call **seven times**, until the turn budget ended the +attempt. Ten of the fifteen failures stopped on `turn_budget`, against zero +for e2b anywhere in the run. + +**And the ops arm is not a control.** Its tools take no `space` and no object +id at all — the harness binds the target, and across all 39 ops-arm calls in +the run the argument names are `op`, `id`, `find`, `replace`, `blocks`, +`row`/`col`/`table_id`, `value`, `position`, `set`, `markdown`, `after`, +`outline`, `recursive`. e4b scores 8/10 there because it never has to handle +a space id, not because it handles one well. The two arms differ in the +argument, not in the difficulty of the work. + +#### Fix 1 — the refusal names the mistake, with the full id in it + +A rejected space id that is a **prefix of a known space id** is a +recognisable error with a known repair, and the model already has everything +else right. The wrapper now answers: + +``` +space "bafyrei…z2i" not found — that is the first part of the space id +"bafyrei…z2i.28y6mgnwgodt7": a space id has two parts joined by a dot and +BOTH are part of the id — pass it whole, exactly as `spaces` prints it +``` + +Built the same way §8.33's `object` steer was, for the same reasons: it +**appends to the server's 404** (the fact is which value failed, the hint is +the repair); it runs on `Run`'s **error path**, so all three tools taking +`space` — `find`, `describe`, `create` — are covered by construction and a +fourth inherits it; and it fires **only after the server has refused**, so +nothing has to be proven impossible up front. The cost is one `GET /v2/spaces` +on a mistake and zero on a working call (asserted). A value that is nobody's +prefix, or an unreadable space list, leaves the server's refusal exactly as +it was written. + +One thing is new: the specific repair **supersedes** the generic one. The +message no longer also says "list them with the `spaces` tool", because two +repairs in one refusal compete and this run shows which one loses — the model +had already run `spaces`. That is a declared field on the steer table +(`supersedes`), not a special case in the code. + +**Object ids do not share the hazard**, and the check is worth recording. +Object ids are CIDs, `_bundled` keys, `_date_…` keys or participant ids, and +the two derived ids that *do* embed a space id — `NewParticipantId` and +`NewPersonalWidgetsId` (`core/domain/id.go`) — **re-encode the dot as an +underscore**, with the comment "to avoid issues on Desktop client". The one +dotted identifier on this surface is the space id. What can still happen is +the truncated space id landing in `object` instead of `space`, so the +`object` steer recognises that too (a strict prefix of the working space, 16 +characters or more — two CIDs in the same multibase share about eight). + +#### Fix 2 — the wrapper stopped speaking REST to a tool caller + +The refusal above ended in *"list spaces with GET /v2/spaces"*. On the HTTP +surface that is right: routes are its vocabulary. On the wrapper surface the +tool is `spaces`, the model **had already called it**, and the hint named a +thing it cannot do while leaving the thing it can do unnamed. + +The audit found this was never one message. Every hint the server writes for +a route the wrapper calls reaches a tool caller verbatim, and the wrapper had +exactly two ad-hoc rewrites — one in `describe` for two type-key phrasings, +one in `opsVocab` for a block hint the server **no longer sends** (§8.29 +rewrote it, and the rewrite was not followed here, so the current phrasing +leaked). Reachable and unhandled: the space hint, the type-key hints on +`find`/`create`, the property-key hint, the option-names hint, the current +block hint on every editing tool, the members hint behind `@me`. + +So the vocabulary is one table now (`steer.go`), applied to every tool error +— text, issue messages and issue hints alike, since the rendered text is +built from all three. Six phrases get a real tool-shaped repair. Behind them +is a **generic rule**: anything still matching `METHOD /vN/…` becomes "the +HTTP API". That is deliberately a bare noun phrase — it stays grammatical +wherever a route can appear in a sentence, and it says the true thing, which +is that the repair is not on this surface. A hint added server-side tomorrow +therefore cannot reach a tool caller as a route; the worst it can do is lose +its specificity, and a test asserts no route survives the pass. + +Not changed: the raw HTTP surface, where naming the route is correct and +stays. + +#### The pattern, stated once as a rule + +This is the third instance of one shape, and it is now a rule rather than a +per-field discovery: + +> **A refusal that is correct and unactionable is a defect.** When a rejected +> value is wrong in a *recognisable* way, the refusal must name the repair — +> and it must name it in the caller's own vocabulary. A small model handed a +> true "not found" with no repair does not explore: it re-sends the identical +> call until its turn budget ends the attempt. + +The three instances: the mis-shaped `object` (§8.33 defect 2, three identical +repeats), the B1 arm's relocated block reference (§8.33 — removing the field +moved the mistake, it did not remove it), and the truncated space id here +(seven identical repeats). In every case the wrong value was one shape with +one repair, and in every case the model's *other* arguments were correct. + +The code answer to "one rule, not one hook per field" is `steer.go`: an +`argSteers` table keyed by argument name, evaluated on `Run`'s error path. A +new diagnosable argument is a row, not a hook in an executor — and because it +is keyed by the argument and not the tool, every tool taking that argument is +covered the moment the row exists. `describe`'s private rewrite and the +`object` steer both moved into it; nothing in any executor knows a steer +exists. + +What the rule does **not** license is guessing. Each repair fires only when +the server has already refused *and* the refusal quotes the caller's own +value *and* the shape is provable (a prefix of a real space id, a pure-hex +block reference, the working space id). Everything else keeps the server's +words. + +#### Considered and not done + +**Changing the `space` argument description** to warn about the dot. §8.33's +B2 arm is direct evidence that description prose does not change what a model +puts in an argument, and it would cost tokens on every call by every model to +repair a mistake one model makes. The repair costs nothing when the model is +right. + +**Changing the identifier**. Out of scope by instruction and correctly so — +see the Phase 9 note below for what the suffix actually is. + +#### Where the suffix comes from, and what it would cost to hide it + +Reported, not acted on. `NewSpaceId(cid, repKey)` is +`cid + "." + strconv.FormatUint(repKey, 36)` +(any-sync `commonspace/spacepayloads/payloads.go`). The prefix is the CIDv1 +of the marshalled, signed space header; the suffix is the space's +**replication key**, a `uint64` in base36. It is load-bearing twice over: +`ValidateSpaceHeader` rejects a header whose id suffix does not equal +`FormatUint(header.ReplicationKey, 36)`, and `nodeconf.ReplKey` feeds **only +the suffix** into the consistent hash that picks which any-sync nodes are +responsible for the space. The CID half plays no part in node selection. + +Two consequences for anyone tempted to shorten it. First, the suffix is +*not* per-space in practice: derived spaces take `fnv64(accountPubKey)` and +every space a client creates reuses the personal space's key +(`space/service.go`, `space/create.go`), which is why all 17 spaces on the +eval account end in `.28y6mgnwgodt7` — the CID half is the part that +distinguishes them, which is exactly why the truncation *looks* survivable +and is not. Second, there is **no prefix index anywhere**: every local store +keys by the full id, and nothing in either repo does a prefix lookup. The +wrapper's steer reads the space list and matches in memory, which is fine for +an error path and is not a resolution mechanism. + +So handing tool callers something they cannot truncate means either an alias +the API mints and resolves (a new identity to keep, invalidate and reconcile +— the corpse-key class of problem §8.22 already paid for once), or not asking +them for a space id at all. The second is Phase 9, and it is cheaper. + +#### Evidence for Phase 9 (`APIV2_SURFACES.md` §10.3) + +The plan predicted `space_id` would be the argument a small model most often +**omits**, because it never appears in the user's request. The measurement +says it is the argument a model most often **mangles**: 83 of 93 `find` calls +in e4b's wrapper attempts, zero mangles in any other argument, and an ops arm +that takes no space id scoring 8/10 for the same model on the same tasks. The +prediction was right about which argument and wrong about the failure mode, +and the fix it proposes covers both. Added to the plan item; not implemented +here. + +#### Verification + +Live, against the running API, before and after — the mistake reproduces +through the CLI alone, no model needed: + +``` +before: space "bafyrei…z2i" not found — list spaces with GET /v2/spaces +after: space "bafyrei…z2i" not found — that is the first part of the space id + "bafyrei…z2i.28y6mgnwgodt7": a space id has two parts joined by a dot + and BOTH are part of the id — pass it whole, exactly as `spaces` prints it +``` + +The same steer on `describe` (the by-construction claim), an unknown space id +keeping its plain refusal, and a working call issuing no extra request, all +checked live and pinned by tests. + +**One `gemma4:e4b` spot check**, one task, one attempt: it truncated the space +id on its first `find`, read the repair, passed the full id on the very next +turn, and completed the task — 4 turns, `model_done`. That is the point it +previously did not get past. It is a spot check and not a rate. + +Each fix has a test that fails when the fix is reverted; all three reverts +were run: dropping the `space` row from `argSteers` (`TestSpaceRefSteering`), +unwiring the vocabulary pass from `steerError` (`TestRestVocabulary`), and +dropping the truncated-space case from the `object` repair +(`TestObjectRefSteering`). + +### 8.35 A space reference a model cannot truncate (2026-08-11 — decisions as built) + +§8.34 measured the mistake and made it recoverable. This removes it: `/v2` +now **serves** spaces by a short reference and **accepts** either spelling +everywhere a space id is accepted. The full id keeps working, forever; this +is additive addressing over the existing identity, not a new identity. + +#### The measurement this answers + +A space id is `.` — +`bafyreihwvsaekzzyb54o7um4hdpvpn5b2invn75lmijhhtghblvphxwz2i.28y6mgnwgodt7`. +`gemma4:e4b` truncates it at the dot, plausibly reading the suffix as a file +extension: **83 of 93 `find` calls** across its wrapper attempts in run +`20260810-235748`, **zero** mangles in any other argument of any other tool. +It collapsed that model's wrapper arm to 2/12 until §8.34's repair made the +refusal recoverable. `space` was the last place a raw composite id was asked +for — every object-addressed tool already takes `object` alone — and it is +exactly where the failure was measured. + +#### The form: the TAIL of the CID half, and NOT the replication key + +**Off the tail, not the head.** Every space CID on one account is a CIDv1 in +the same multibase and codec, so they all start `bafyrei…` — about eight +characters that distinguish nothing. That is the same reasoning that makes +anyblockjson's block labels shortest-unique-**suffix** +(`mintedSuffixLabels`), and the same machinery shape is reused here: a +census over the whole visible set, a fixed tail length, and no short form at +all for anything that collides. + +**Without the replication key**, which is what removes the dot — and with it +the character the model cut at. Excluding it costs nothing, because it +*distinguishes nothing*: derived spaces hash the account key and every +client-created space reuses the personal space's, so **all 17 spaces on the +eval account end `.28y6mgnwgodt7`**. `nodeconf.ReplKey` hashes it to pick +responsible nodes; it is not a per-space discriminator. + +That second fact is also the trap, and it is why the fixture is the load- +bearing part of the test file. **The census must run over the CID half, after +splitting on the dot.** Feed the composite ids in and every tail is +identical, the collision rule fires on every pair, and the feature emits +*nothing* — no error, no failing test, silence on exactly the accounts it +exists for. `spaceref_test.go` therefore pins the production shape: CIDs that +differ, replication keys that are **the same**, and an explicit assertion +that a composite-tail census would collapse to one bucket while the real one +does not. (Same shape as the Phase-3 review's "fixture-lossless mask", where +every edit fixture was built through `Unmarshal` and so could not observe a +loss that only happened on real documents.) + +**Six characters.** The CID half is 59 base32 characters, and its last +character carries only **3 bits** — 288 payload bits do not divide by 5, so +the final symbol is padded (the 17 real ids use 7 of its 8 possible values). +A tail of n characters holds 3 + 5(n−1) bits: 28 bits at n = 6. Over the real +account all 17 tails are distinct at every length from 2 up; at 6 the +birthday probability of any pair colliding is ~7 × 10⁻⁵ even at 200 spaces, +and a collision degrades gracefully rather than failing. + +**Only real space ids enter the mechanism.** `isSpaceIdShaped` requires +`.`; anything else — a fixture's +`spaceLive`, a tech space id, a hand-written string — neither shortens nor +answers to a tail. That is `mintedSuffixLabels`' own rule (`isMintedLocalId`, +"anything unrecognised stays full"): a false negative costs a few tokens, a +false positive turns a meaningful identifier into a guess. It is also why +this change moved no existing test. + +#### Resolution: the rule the codebase already has, not a second one + +`matchSpaceRef` **is** `matchBlockRef` (object.go) applied to space ids over +their CID halves: exact full id first, then a unique suffix; more than one +claimant is a 400 listing the candidates. Nothing new was invented, and the +one rule now covers block refs, view refs, payload ids and space refs. + +A consequence worth stating: **the §8.34 truncation now resolves**, because +the whole CID half is a suffix of itself. The mistake that cost 15 attempts +is a working call. + +#### Where it runs: a middleware, in front of the grant gate + +Every space-addressing `/v2` route uses one param name (`SpaceParam`; the +conformance walk refuses any other), so one middleware covers the surface — +including routes added later, the same by-construction argument the grant +gate rests on. It **must** run before `ensureSpaceGrant`: grants are keyed by +full space id, so a short reference reaching the gate unresolved would be +refused as a non-granted space. Resolving inside the services would be too +late by one middleware. + +**The scoped-key path, checked.** Resolution's candidate set is +`liveSpaceRows` — live spaces intersected with the ctx grant, the same +enumeration `GET /v2/spaces` serves from. So: + +- a short reference can only ever resolve to a space the caller can already + see. It is not a probe: a tail belonging to a non-granted space does not + resolve, the param is left exactly as it arrived, and the request meets the + refusal any other unknown value meets — quoting the caller's own value, not + naming the space the tail belongs to. +- a full id is returned untouched without reading the space list, so the + common path costs nothing and a non-granted full id still gets the same + 403 it got before. +- the grant check is not weakened anywhere: it still compares full ids, still + runs on every request, and the service-level `ensureSpaceGranted` backstop + is unchanged. + +`liveSpaceRows` is also the point of a small refactor: `ListSpaces`, +`spaceRefs` (the global-search fan-out) and whoami's name resolution used to +enumerate separately. They are one enumeration now, because a short form is +only unambiguous *relative to a fixed set* — two censuses would be two +answers to "which spaces exist". + +#### Serve short, accept either, echo what you were served + +Served short: `GET /v2/spaces` rows, `GET`/`POST`/`PATCH /v2/spaces/{id}`, +global-search rows' `space_id`, whoami's `grant.spaces[].id` (a granted space +the caller cannot SEE has no census entry and keeps its full spelling — the +grant echo must stay complete), and therefore the wrapper's `spaces` tool, +which passes `SpaceRow` through verbatim. The census runs over the WHOLE +visible set before pagination, so a page cannot mint a tail a space on +another page also claims. + +The echo is one hook, not twenty. `v2handler.RespondV2Error` — the single +place a v2 error becomes bytes — re-spells the resolved full id back into +the caller's own reference across the message, every issue message and every +hint. The alternative was the same substitution at ~20 `Sprintf` sites, where +one could drift from nineteen and a message added tomorrow would inherit +nothing. Substituting into a repair URL keeps it valid: every route takes +either spelling. The not-found and not-granted refusals needed nothing — +resolution only ever succeeds, so those messages already quote the caller's +own value. + +Per §8.28 the mechanism is **not** explained to agents: neither +`core/api/v2/SKILL.md` nor `cmd/anytype/SKILL.md` mentions short versus long. +Both already say "list the spaces and pass the id", which is now true of a +value with no dot in it. The mechanism is in the API reference (the v2 +OpenAPI description) and here. + +**The compact `filter` string is unaffected.** A space id cannot appear in +it: filter keys are the space's property keys plus the four-key system +allowlist, and `space_id` is not among the 38 keys the eval space publishes — +checked, not assumed. So §C2's validate-before-fold exception raises no +question here, and the string keeps treating identifiers exactly as it did. + +#### The token cost, on the record + +Measured with the Gemma 3 tokenizer (ollama `prompt_eval_count` via the +served `gemma4:e4b`), against the real 17-space account: + +| surface | before | after | change | +|---|---|---|---| +| the `spaces` tool's whole output | 895 tok | 159 tok | **−82.2 %** | +| one space id, as the model must EMIT it | 44 tok | 2 tok | **−95.5 %** | + +The second row is the one that matters: 44 tokens of exact base32 copying, on +every `find`, `describe` and `create` — which is the copy the model got +wrong. + +#### Live verification + +Before (the running API, old binary): + +``` +GET /v2/spaces → "bafyreia4znhhjvxek2iux7enfzilekj5vwlgddgogpfgnx5qnim7bugaxa.28y6mgnwgodt7" …×17 +GET /v2/spaces/hxwz2i → 404 space "hxwz2i" not found — list spaces with GET /v2/spaces +GET /v2/spaces/bafyrei…hxwz2i/objects → 404 (the §8.34 truncation) +``` + +After: + +``` +GET /v2/spaces → bugaxa | Project Tracker … hxwz2i | APIv2 eval (17 rows, all distinct) +GET /v2/spaces/hxwz2i → {"id":"hxwz2i","name":"APIv2 eval"} +GET /v2/spaces/ → {"id":"hxwz2i","name":"APIv2 eval"} (full still works, served short) +GET /v2/spaces/bafyrei…hxwz2i/objects → 200 (the truncation resolves) +POST /v2/spaces/hxwz2i/search → 200 (nested routes too) +GET /v2/spaces/zzzzzz/objects → 404 space "zzzzzz" not found (the caller's own value) +GET /v2/spaces/hxwz2i/types/nope → … not found in space "hxwz2i" … GET /v2/spaces/hxwz2i/types +GET /v2/spaces//types/nope → … not found in space "bafyrei….28y6mgnwgodt7" … +POST /v2/search → rows carry "space_id":"hxwz2i" +``` + +Through the wrapper CLI, all three `space`-taking tools: `spaces` prints the +short ids, and `find --space hxwz2i`, `find --space ` and +`find --space ` all return the same object. + +**One `gemma4:e4b` spot check**, one task, one attempt (`edit-one-word`, +wrapper/large): it passed `space: "hxwz2i"` verbatim on its first `find`, +made **zero** failed calls, and finished in 3 turns on `model_done`. Its +reasoning names the short id as the space. It is a spot check and not a rate. + +#### What this does to §8.34 + +§8.34's `space` steer is **superseded for the measured mistake** and stays in +place, unchanged, as a backstop. Its live trigger is gone twice over: the +served form has no dot to cut, and a truncated FULL id pasted from elsewhere +now resolves. The one case it could still meet — a truncated id belonging to +a space the credential cannot see — is one no repair can help, since that +space is unusable to the caller either way. Worth recording honestly: +`spaceIdsWithPrefix` reads `GET /v2/spaces`, which now serves short ids, so +the prefix match will not fire against a served list; the value that would +have needed the repair resolves instead. + +#### Tests, and the revert each one catches + +`core/api/v2/service/spaceref_test.go`: + +- **`TestShortSpaceRefs`** — a real-shaped account (identical replication + keys) shortens every space; a composite-tail census would collapse to one; + colliding CID tails keep BOTH full spellings; an unshaped id never + shortens. *Reverts caught:* computing the tail over the composite id + (the silent-nothing regression), dropping the `counts[tail] != 1` guard, + dropping `isSpaceIdShaped`. +- **`TestMatchSpaceRef`** — exact wins; a unique tail resolves; the whole CID + half resolves; an ambiguous tail reports both claimants. *Revert caught:* + matching the suffix against the composite id, or dropping the exact-first + branch. +- **`TestResolveSpaceRef`** — a full id passes through with an EMPTY store + (proving the common path reads no space list); ambiguity is a 400 with + candidates; **a non-granted space's tail does not resolve and the grant + backstop still refuses it**; a non-granted FULL id is still refused, not + hidden. *Reverts caught:* widening the candidate set past the grant + intersection, resolving before the grant filter, returning an error instead + of the caller's value on no match. +- **`TestSpacesSurfaceServesShortRefs`** — the list serves short; colliding + spaces are listed in full; the census precedes pagination; GET-one serves + short. *Reverts caught:* minting per page, serving the full id. +- **`TestWhoamiServesShortSpaceRefs`** — the maps stay keyed by FULL id (a + grant is keyed by space id); an invisible granted space keeps its full + spelling. *Revert caught:* keying `resolveGrantedSpaceNames` off the served + id, which silently empties whoami's space names. + +`core/api/v2/spaceref_test.go` (the route half): + +- **`TestResolveSpaceRefMiddleware`** — the param rewrite; the full id + untouched; the §8.34 truncation resolving; ambiguity refused before the + handler; the refusal echoing the caller's spelling and NOT the full id. + *Reverts caught:* unwiring `resolveSpaceRef` from the router, dropping the + echo from `RespondV2Error`. +- **`TestResolveSpaceRefAndTheGrantGate`** — resolution BEFORE the gate (a + granted space's short reference passes); a non-granted tail is refused + quoting the caller and never naming the space; a non-granted full id is + refused exactly as before; an invisible space's tail resolves nowhere. + *Revert caught:* moving `resolveSpaceRef` after `ensureSpaceGrant` — which + turns every short reference into a 403 for a scoped key. + +`core/api/server/v2_spaceref_test.go` (the REAL engine): + +- **`TestV2ShortSpaceRefThroughTheRealEngine`** — the two tests above build + their own middleware chain, so neither can see whether + `apiv2.RegisterRoutes` installs the middleware at all. This walks the real + engine: the list serves short and does not leak the full id, a short + reference on a path param resolves, the full id still works, a granted key + reaches its space by the short reference, and a non-granted tail is still + refused. *Revert caught:* deleting the `v2.Use(resolveSpaceRef(...))` line + from the router — which the package-local tests do not notice. + +All seven reverts were run and each failed the named test. + +One free consequence, recorded: the C8 idempotency key is scoped by +`c.Param("space_id")` (`middleware.go`), which the middleware has already +rewritten — so a retry that spells the space differently still hits the same +cached entry, rather than replaying the mutation under a second key. + +#### Retiring Phase 9 + +Phase 9 (space-optional object routes) is **retired** — see `APIV2_PLAN.md` +3.2 and `APIV2_SURFACES.md` §10.3. It was proposed for this problem and this +supersedes it for the measured version of it. Recorded there: what Phase 9 +uniquely solved (a cold-pasted object id with no prior `find`), that no eval +has ever produced that case, and the two defects found while scoping it — +`ResolveSpaceIdWithRetry` is `retry.Attempts(0)` (infinite, bounded only by +the context, so an unresolvable id spins instead of 404ing), and +`set_properties` needs a space regardless because `propertyFormats`, the +option-name guard, `@me` and relative dates are all space-scoped. Decision D2 +is **moot**, not decided. + +### 8.36 The full space id has to stay reachable (2026-08-11 — decisions as built) + +§8.35 made the short reference the served spelling and left the full id +reachable "everywhere" — on input. On **output** it became unreachable: +`?ids=full` was wired only into the object read (`V2IdsCompact` / +`V2IdsFull`, `object.go`), and the space surfaces ignored it. There was no +call in `/v2` that answered with a full space id. + +#### Why that matters: a short reference is not an identifier + +The short form is **unique only against the caller's current visible space +set**. Join a space whose CID tail collides and the collision rule fires: +both spaces drop back to their full spelling and the reference that was +printed yesterday addresses nothing today. That is the correct behaviour — +§8.35 chose graceful degradation over a wrong resolution — and it is exactly +why the short form is an *addressing convenience*, not an identity. + +So every caller that **persists** a space reference needs the full id: a +config file, a shell script, a log line, a support ticket, a gRPC call into +the heart, another API. `/v2` is not the only thing that speaks about spaces, +and the ones that are not `/v2` only know the composite id. + +Recorded honestly: the full id was *derivable* even before this change, by +string surgery on an unrelated object's id — `domain.NewParticipantId` embeds +the space id in every participant id with its first dot replaced by an +underscore, so `GET …/members/me` leaks it. That is a coincidence of an +internal id format, not a contract, and "parse our member id backwards" is +not an answer to "how do I store this space id". + +#### One parameter, not a second one + +`?ids=` already means "which spelling of ids do you want" and already has +`compact` (default) and `full`, a documented meaning (the backup/export +shape, C4), and a 400 for anything else. A space id is an id. So `?ids=full` +was extended to the space surfaces rather than given a sibling: + +- **not** `?fullIds=` — two parameters that answer the same question, which + a caller then has to set consistently, and which will eventually disagree; +- **not** a second `fullId` field beside `id` — C2, one concept one slot. It + would also double the token cost of the very list §8.35 shrank by 82 %, on + every caller including the ones that never wanted it. + +The value list and its refusal live in **one function**, `ParseIdsShape` +(`service/idshape.go`). The object read's own plan validation calls it, so +the block-id axis and the space-id axis cannot come to disagree about what +`ids=export` means — a second copy of a value list is how a surface starts +accepting on one route what it refuses on another. + +#### Where it runs: one middleware, like `ensureDryRun` + +`ensureIdsShape` (`v2/idshape.go`) parses `?ids=` once for the whole group +and records a full request on the **request context** — the carrier the +§8.35 echo already uses. `servedSpaceRefs` (the census) and `servedSpaceRef` +(the single-id form) consult it; the five serving surfaces call those and +need no branch of their own. + +It is group-wide for `ensureDryRun`'s reason: the parameter is group-wide, its +values are a closed set, and an unknown value must be a 400 on every route +rather than on the routes someone remembered to wire. A space-serving surface +added later inherits the behaviour by calling `servedSpaceRefs` — the same +by-construction argument the grant gate and the reference resolver rest on. + +It runs **in front of `resolveSpaceRef`**, so the candidate list an ambiguous +reference is refused with is spelled the way the request asked for. + +The ctx carries only the *full* case: an untouched context means compact, +which is what every internal caller and every existing test already carries, +and is why this change moved no existing test. + +#### Per-surface decisions + +| surface | serves a space id? | `?ids=full` | +|---|---|---| +| `GET /v2/spaces` | rows' `id` | **honoured** | +| `GET /v2/spaces/{id}` | `id` | **honoured** | +| `POST /v2/spaces` | the created `id` | **honoured** — a create is precisely when a caller has a new id to store | +| `PATCH /v2/spaces/{id}` | `id` (via GET-one) | **honoured** | +| `POST /v2/search` (global) | rows' `space_id` | **honoured** — the one place an agent learns a space id it did not name | +| `GET /v2/auth/whoami` | `grant.spaces[].id` | **honoured**. It has no space in its path, but it does have a space id in its body, and it is the surface a holder reads to learn which spaces it holds — the answer a scoped integration writes into its own config | +| ambiguity refusals (`ResolveSpaceRef` candidates) | the candidates | **honoured** — a refusal's repair value is part of the response | +| `POST /v2/spaces/{id}/search`, `GET …/objects`, members, types, properties, chats, sets/collections | **no space id in the body** | parsed and ignored: there is nothing to spell | +| `GET …/objects/{id}`, `GET …/types/{key}` | no space id in the document | see below | + +Two surfaces deliberately did **not** change: + +- **Accepting.** Unchanged and unchangeable: exact-then-unique-suffix, both + spellings always accepted, on every route. `?ids=` is about what is + **served**. `GET /v2/spaces/{short}?ids=full` is the round trip a caller + makes to turn a reference it was handed into one it can store. +- **Member ids.** `_participant__` keeps the full space id + inside it under both shapes. It is an id of a different object, addressable + as a unit; rewriting its interior would break it. + +#### The object-read overlap: same knob, and nothing to spell + +Should `?ids=full` on an object read imply full space spellings in that +response? **Yes — and it is the same flag, set by the same middleware, on +that request too.** There is no second knob to reconcile: a caller asking for +the export shape has asked for it, full stop. + +What that changes on the object read today is **nothing**, because an +AnyBlock document carries no space id — not in the envelope, not in a +property value, not in a ref. The same is true of the object list and the +space-scoped search (`includeSpaceId` is set only by the global fan-out). So +the answer is "yes, and it is currently a no-op there" — which is the useful +form of the answer, because the flag is already correct for the surface that +adds a `space_id` tomorrow. + +#### Not agent-facing (§8.28) + +Neither `core/api/v2/SKILL.md` nor `cmd/anytype/SKILL.md` gains a word about +short versus long — the §8.28 rule stands, and an agent that lists spaces and +passes the id it was given is still correct under both shapes. `?ids=full` +stays framed exactly as C4 frames it: the backup/export shape. The mechanism +lives in the v2 OpenAPI description (where the info block now names the +stability limit and points at `?ids=full` for anything stored outside the +API), in the six `@Param ids` annotations, and here. + +The wrapper and the CLI gain nothing either: they are the agent-facing tier, +they pass `SpaceRow` through verbatim, and nothing they do outlives the +session that made the call. + +One free consequence, recorded: the C8 hash already covers the query string, +so a retried create that *adds* `?ids=full` earns the 409 +`idempotency_conflict` rather than replaying — the same rule `?dry_run` has +lived under since §8.15. A retry must repeat the request it is retrying. + +#### Tests, and the revert each one catches + +`core/api/v2/service/idshape_test.go`: + +- **`TestParseIdsShape`** — the three legal values and which is the default; + `export`/`FULL`/`true`/`short` are 400s addressed at `ids` and naming both + allowed values; and `V2ObjectQuery.validate` produces those same answers. + *Reverts caught:* dropping the `default` branch (unknown values silently + becoming compact); re-inlining a private switch in `object.go` so the two + axes can drift. +- **`TestFullIdsCtx`** — an untouched context means compact. + +`core/api/v2/service/spaceref_test.go`: + +- **`TestSpacesSurfaceServesFullRefsOnRequest`** — under `?ids=full` the + list, GET-one, the global-search fan-out and whoami's grant echo all serve + the full id, while the default still serves short; and an ambiguity's + candidates follow the request's shape (the fixture is two spaces differing + at the SIXTH character from the end, the only ambiguity in which candidates + *have* short forms of their own). *Reverts caught:* `servedSpaceRefs` + ignoring the ctx — four subtests fail; `servedSpaceRef` losing its + short-circuit — the GET-one subtest fails. + +`core/api/v2/idshape_test.go` (the route half): + +- **`TestEnsureIdsShapeMiddleware`** — the middleware's own contract over the + real GET-one handler: `?ids=full` serves the full id, the default and + `?ids=compact` serve short, a short reference still *addresses* the space + it serves in full, an unknown value is a 400 before the handler, and the + shape is read before resolution. *Reverts caught:* dropping the error + branch from the middleware; the `servedSpaceRef` short-circuit again, this + time through a real chain. + +`core/api/server/v2_spaceref_test.go` (the REAL engine): + +- **`TestV2FullSpaceIdsThroughTheRealEngine`** — the package-local tests build + their own middleware chain, so none of them can see whether + `apiv2.RegisterRoutes` installs `ensureIdsShape` at all, or where. This + walks the registered engine: the list, GET-one-by-short-reference, whoami + (which nothing else exercises for this parameter), the unknown-value 400, + and the candidate spelling. *Reverts caught:* deleting + `v2.Use(ensureIdsShape())` from the router — five subtests fail while the + package-local ones stay green, which is precisely the blind spot this file + exists for; moving it AFTER `resolveSpaceRef` — exactly one subtest fails, + the candidate-spelling one. + +All six reverts were run and each failed the named tests, with the named +subtest granularity. + +### 8.37 Wave 1 — the identity layer finished: mint, backfill, one vocabulary (2026-08-13 — decisions as built) + +Wave 1 of `APIV2_PLAN.md` (items 1.1–1.4), specified in +`APIV2_ADDRESSING.md` §7.5 / §7.5a. Three commits, in dependency order — the +union check has to exist before the backfill can be safe, and both have to +exist before the re-spelling sweep can be anything but silent +mis-resolution. + +**1.1 — the heart-side mint checks a union** (`objectcreator/apikey.go`). +`injectApiObjectKey` derived the api slug from the create-time name and +validated **nothing** (§2.3-1); v2 defended only at resolution. The mint is +where the address is decided, so the check moved there, and it tests the +whole union §7.5a-6 names — not just stored slugs: (1) the space's live +stored `apiObjectKey` slugs, (2) the space's live stored **keys** (a legacy +readable key is an address at chain step 1, so a slug spelled the same is a +shadow), (3) the **bundled-derived** vocabulary via +`pkg/lib/bundle/apislug.go`. Arm 3 is the one that catches the headline +case — a UI property named "Due Date" slugs to `due_date`, which is exactly +bundled `dueDate`'s derived slug, and **no stored detail exists for it in +an old space**, so a store-only check would wave it through. Arms 1–2 come +from one bounded listing (§7.5a-2), arm 3 from a point lookup. + +Two deliberate asymmetries with v2's mint: a **bundled install skips the +check** (its slug is derived, not minted — the table in code is its +authority, and convergence is the install mechanism, §2.4-1), and a +collision **suffixes rather than refuses** (`due_date_2`) because a UI +create has no caller to steer. Corpses vacate the namespace; an entity +never collides with itself. A store error degrades the mint and is logged — +failing a user's property create because the store hiccuped is the worse +trade, and the ambiguity-loud lookups are the standing backstop. + +**1.2 — the backfill, and the floor under the collisions it cannot fix** +(`space/internal/components/migration/apiobjectkey`). `systemobjectreviser` +never set `apiObjectKey` — its revised-keys list has none, and it only +visits bundled/system objects anyway, which are exactly the ones that need +no stored slug. A migration, built like one: it fills only EMPTY slugs +(never re-points one — `apiObjectKey` is mutable and **v1-visible**), skips +bundled keys, processes candidates in ascending object-id order so two +devices converge, and costs one filtered query in the steady state. + +**The already-taken case is a deliberate no-op**, named in code as +`takenSlugPolicy` and pinned by tests. The mint suffixes because a UI +create must succeed and carries a name the user just chose; a backfill has +neither, and `due_date_2` invented unattended is a permanent, v1-visible +address minted out of an ordering accident. Skipping is the reversible +choice: the entity keeps exactly the addressability it has today, and a +later run picks it up if the obstacle clears. Which resolution the case +ultimately gets is ADDRESSING §8 open question 3 (lean: "floor first"). + +**The plan's stated defect for 1.2 was mis-attributed, and the real one is +now loud.** The wrong-entity write ("a UI property named Due Date claims +`due_date`, so `set_properties` lands in it instead of the bundled one") is +not what a backfill fixes: the squatter *has* a slug, so the backfill never +touches it. 1.1 prevents new ones; existing ones cannot be repaired without +re-pointing an address v1 serves. What needed no permission was the failure +mode — a stored slug that the bundled table resolves to a **different** key +is now **ambiguous**, and ambiguity is a loud 400 listing both holders. +`servedKey` refuses the same spelling, so the API never advertises an +address that 400s. Silent-and-wrong became refused-and-actionable; the +backfill's own value is the one §7.5a-6 states — pre-slug custom keys had +**no** bare-op address at all. + +**1.3 — one key vocabulary, bundled keys included.** 153 of 194 bundled +relation keys and 5 of 29 type keys (`objectType`, `relationOption`, +`spaceView`, `diaryEntry`, `chatDerived`) re-spell on the wire. + +*The reverse is a table, both directions, built from the bundle — never a +case transform.* `anyblockjson/keyvocab.go` states it and two tests pin it +with the counterexamples that prove it: `mediaArtistURL` → +`media_artist_url` → `ToLowerCamel` yields `mediaArtistUrl`, and `_score` +does not round-trip. A later "simplification" to a case function fails +there first. + +*Two authorities, one chain.* The package default is the bundled derived +table, which ships with every reader, so a document resolves its bundled +keys offline with no store. Inside a node, `storeresolver/keyvocab.go` +widens it with the space's stored slugs — **one bounded details query per +kind per resolver instance** (a resolver is per request/operation), never a +point query per reference, because `apiObjectKey` is an ordinary hidden +detail with no index. Precedence is §7.5a-5 and is load-bearing: an exact +live stored key wins over the slug layer, a twin slug resolves to neither, +and a store error degrades to the bundled table rather than to a partial +map — a half-built vocabulary would resolve a write against the wrong +property, the exact class §7.5a-2 forbids a cache from producing. + +*The format's key slots follow* (`properties` map, `typeProperties[].key`, +`typeProperties[].objectTypes`, envelope `type`/`templateFor`, dataview +`properties[].key`/`groupBy`/`coverProperty`/`endProperty`/sorts/filters/ +columns, the `property` block's `key`, a link block's `properties`): export +spells, import inverts. **`objectTypes` is the complete inventory's last +entry and was the one the wave missed** — it NAMES types, so it is a key slot +by the same test every other one passes, and leaving it untranslated would +have made one array in a type document speak a vocabulary the envelope two +lines above it does not (§8.38). Two +consequences worth writing down: canonical property order now sorts by the +**spelling**, because the reader sorts what it sees; and two stored keys +whose slugs would collapse onto one JSON key keep the honest stored +spelling for the second — a duplicate map key loses a value. + +*What did NOT re-spell, deliberately* (§7.5a-4): the envelope and DTO field +names (`space_id`, `iconSize`, `defaultTemplateId`, `has_more`'s existing +carve-out), block attribute names (a callout's `iconEmoji`), enum **values** +(`kind: "objectType"`, layout names) — `objectType` the layout value +coexists with `object_type` the type key, and that is intended — the +`index.json` envelope (§2c), and a type document's envelope `key` (it is +not an address: v2 stopped deriving identity from it in §8.22, and the +§8.23 reject list drops `uniqueKey` outright). + +*The cascade*, all of it mechanical: SPEC §3's key-vocabulary rule (rewritten +— the old text said "as stored, camelCase"), its worked examples and the §14 +full document; OVERVIEW and ANOMALIES; the four golden files; `schemas.go`'s +served examples and `schemas_ops.go`'s op schemas; the served EBNF examples +and the filter parser's hints; the wrapper's tool manifest; both SKILL +guides (the v2 one's teaching sentence changed meaning, not just spelling); +and a new eval task — every existing one named single-word keys only, which +cannot tell one vocabulary from the other. + +*Two functional channels needed the wire spelling taught to them*, found by +following the sweep rather than by test failure: `UpdateType`'s `properties` +patch never went through `canonicalizeDocumentKeys`, so the schema now +advertised `icon_emoji` against a check that only knew `iconEmoji`; and +`IsOutputOnlyProperty` is called by the wrapper with SERVED keys, so +`created_date` would have stopped reading as output-only. Both now accept +either spelling through the bundled table, and the output-only listing +advertises slugs. + +**1.4 — the deferrals, resolved rather than restated.** Both fell out of +1.3, and one turned from debt into a defect on the way: + +- **View-op `set`/`columns` key channels** were stored-key-only. That was + survivable while documents spelled stored keys; the moment they spell + slugs it is a defect, because the ops merge into the exported document — + a stored-key column address stops matching the column it names (it + appends a twin), and a folded spelling lands verbatim in the stored + `groupBy`, where nothing can ever match it. `canonicalViewKey` now + translates every key input once, on the way in, to the document's + spelling: `set.groupBy`/`coverProperty`/`endProperty`, `set.sorts`, + `set.filters`, the compact `set.filter` string's **output**, and the + `columns` map keys. +- **The compact filter string stays fold-strict on input** (§C2's stated + exception), unchanged: it validates against the served spellings before + folding. What changed is that its served spellings are now the slugs, so + the exception costs a caller nothing it can see — and its parsed output + is canonicalized like every other channel, which it was not before. + +**Verification.** Every behaviour has a test that fails on revert, and each +revert was run: the mint's union arms and its wiring in `createRelation` +(`objectcreator/apikey_test.go` — 10 subtests fail with the check stubbed +out); the backfill's fill, skip, idempotence and convergence +(`apiobjectkey_test.go`); the bundled-slug serving and the shadow's loud +400 (`keys_output_test.go` — reverting `servedKey` to the BSON-only rule +and dropping the `shadowedBundled*` branch fails 3 tests); the view-op key +spellings (`viewops_test.go` — stubbing `canonicalViewKey` fails both +subtests); and the vocabulary's two counterexamples +(`anyblockjson/keyvocab_test.go`). + +**v1 is untouched** — no file under `core/api/handler`, `core/api/service`, +`core/api/model`, `core/api/filter` or `core/api/docs/v1` changed, and its +suites pass. v1 keeps its own key vocabulary by construction: it reads +`apiObjectKey` directly and never consults the derived table. + +> **CORRECTION (§8.39).** The first clause was false, and the second clause +> is *why*. No v1 **file** changed, but this wave shipped a **migration that +> rewrites the data v1 addresses by**: the backfill stamps `apiObjectKey` on +> every BSON-keyed custom type, and because v1 "reads `apiObjectKey` +> directly", `getTypeFromStruct` then serves the slug as `Type.Key` and the +> bare hex — the only key v1 has ever served for such a type — stops being an +> index in v1's type cache. Create and update 500/400, and `Search` silently +> drops the type from its filter and answers `200 {"data": []}`. A migration +> that re-points a shipped v1 address is a v1 change whatever the diff says. +> §8.39 fixes it in `cacheType` and states the v1 behaviour change. + +**`cmd/anyblockroundtrip` — can it still run, and what would it need?** Yes, +unchanged, and it is the sweep's most valuable outstanding verification. +The harness exports each object and re-imports it through the same +`storeresolver` wiring, so the vocabulary is applied symmetrically in both +directions and the comparison is still snapshot-vs-snapshot, not +bytes-vs-bytes. Two things to expect on the next run: documents now carry +slugs, so any **saved corpus artifacts** from an earlier run compare +against the old vocabulary and should be regenerated rather than diffed; +and a space holding a **shadow** (a custom slug over a bundled key) is the +one place the round trip can now lose a key — the exporter keeps the honest +stored spelling for the second holder, which imports back correctly, so the +expected result is a pass, but it is the case to watch in the anomaly +report. It was **not run here**: it needs account credentials, which this +work deliberately did not touch. + +**Not done, and why.** OpenAPI regeneration (`make openapi`) is still +pending across Waves 0–1 (Q5); no annotation *shapes* changed here, only +description and example text. The `?ids=`/pins work, the §7.4 write +defaults and the active re-slug-on-revive remain as §8.22 recorded them. + +### 8.38 Wave 1 review: the write half spoke a different vocabulary than the read half (2026-08-13 — decisions as built) + +A three-lens review of Wave 1 (§8.37) reproduced every finding below by +execution rather than by reading. Two were data-corrupting, two more served +addresses that resolve to the wrong entity, and one made two thirds of a +PATCH surface return 400 for every spelling the wave had just taught. All +five were shipped over a green suite. + +**The fixture-blindness lesson, stated plainly, because this is the third +time.** Every blind fixture in Wave 1 used a key the bundled table happens to +invert — `name`, `page`, `dueDate`, `severity`, `created_date`. A test whose +fixture key spells the same in both vocabularies **cannot fail** when the two +vocabularies diverge, whatever it asserts. `TestV2UpdateType` used `name` and +stayed green while `icon_emoji` and `recommended_layout` 400'd. The wrapper +suite stubbed `dueDate` and `createdDate` as *served* keys and so modelled a +server that no longer existed. `TestV2ListingsServeBundledSlugs` asserted only +the row that the guard it was testing already covered. + +The rule this leaves behind, and the one to apply to every future test in this +layer: **a key-vocabulary fixture must use a BSON-keyed entity with a stored +`apiObjectKey`, or a stored key the bundled table resolves elsewhere.** +Anything else proves nothing. Both new test files (`keys_write_test.go`, +`storeresolver/keyvocab_test.go`) say so in their headers. + +#### 1. The write half fell back to the bundled table (critical) + +`creatingResolvers.Options()` set `ResolveFormat`/`ResolveOptions`/ +`ResolveProperties` and **no `Keys`**, so every import channel of the service +fell through to `BundledKeyVocabulary` while the read half exported through +`storeresolver`. Both directions were wrong, for opposite reasons: + +- the bundled table does not know a BSON-keyed relation's slug, so + `manual_property` imported as the **literal stored key** `manual_property`. + Executed: `PATCH …/objects/obj1 {"op":"update_view","columns":{"severity": + {"hidden":false}}}` on a space whose `severity` is a UI property stored as + `6a76…e107` rewrote the dataview's relation key to `severity` — a dataview + naming a key no relation object owns. Columns unbind, filters and sorts + match nothing, silently. Every view op re-imports the whole dataview, so + every view op did this. +- the bundled table over-reaches in the other direction: a space holding a + live relation **stored** under `due_date` had `canonicalizeDocumentKeys` + resolve it correctly at chain step 1 and then `Unmarshal` rewrite it to + bundled `dueDate`. The value landed on the wrong property. Same on the + type-create path, through `applyTypeProperties`. + +Fix: `Keys: r.reads`. `storeresolver.PropertyKey` already implements chain +step 1 (an exact live stored key wins over the slug layer), which is exactly +what makes it safe on every call site — `create.go`'s document import, +`schema_write.go`'s type import, and `stateops.go`'s `insert_blocks`/ +`replace_subtree` fragments and whole-dataview re-import. Verified per site, +not assumed. + +#### 2. `PATCH /v2/spaces/{s}/types/{key}` was broken for every spelling it taught + +`schema_write.go` read the patch map with the **stored** key while the map is +keyed by the **wire** spelling: `typeDetailValue(key, patch.Properties[key])`. +For any key the two vocabularies spell differently the lookup returned nil and +the decode failed. Executed: `{"icon_emoji":"✅"}` → 400 with issue path +`/properties/iconEmoji`, a key the request never sent; `{"recommended_layout": +"todo"}` → 400. Two of the surface's four fields were dead, and only the +spelling the wave had just removed from every document still worked. + +Fixed to `patch.Properties[raw]`, and `typeDetailValue` now takes the caller's +spelling for its issue paths — an error naming a key the caller did not send +is unactionable. `TestV2UpdateType` gained a table over `icon_emoji`, +`recommended_layout` and the camelCase form. + +#### 3. The cascade re-spelled block attributes, which its own rule excludes + +`v2OpBlockCommonProps` gained `icon_emoji`/`icon_image`. Block attributes are +**not** key slots — §7.5a-4 names the exclusion in the same commit. Both +payload defs are `additionalProperties:false`, so a grammar-constrained +decoder could not author a callout icon **at all**, while `GET +/v2/schemas/object` went on serving the format's own schema declaring +`iconEmoji`: two served schemas contradicting each other one request apart. + +Reverted, and — the part that matters — **the exclusion is now enforced by the +build**. `TestSchemaOp` already cross-checked the op schema's block-*type* +enum against `anyblockjson.AuthorableBlockTypeNames()`; the mirror is now +there too: every property name `v2OpBlockCommonProps` publishes must exist in +the format's own block schema (`anyblockjson.KnownBlockProperty`, read out of +the embedded JSON Schema the same way the type enum is). Prose became a test. + +The same over-reach hit `SPEC.md`, where it made the document contradict +itself: the §2c table re-spelled the `index.json` envelope fields while the +example six lines above still showed `iconEmoji` — and `index.schema.json` is +`additionalProperties:false` and rejects `icon_emoji`, so the **table** was +the wrong half. Reverted there, plus the callout row, the id-remap table, and +`spaceDashboardId`, which is a `pb.Profile` field name and not a key slot at +all. SPEC §3 now states the exclusion inline rather than leaving it in +ADDRESSING only. + +#### 4. The emit side had no chain step 1 and no shadow check + +`storeresolver`'s `PropertySlug`/`TypeSlug` returned `bundle.ApiSlug(key)` +unconditionally for a bundled key, never consulting the space. The accept side +(`PropertyKey`) honours both guards. Probed: a space where a BSON squatter +holds `due_date` exported the **bundled** property's value under an address +that resolves to the squatter; a space with a live stored key `due_date` +exported to the v1 relation; the type namespace had the same hole. A served +document labelled a value with a key denoting a different entity — while +`servedKey` applied the guards, so the listing and the document disagreed. + +The emit side now runs one predicate, `keyMaps.roundTrips`, with all three +chain steps: a live stored key wins the spelling (step 1), another live holder +makes it ambiguous (step 2), and the bundled table resolving it elsewhere is +the §7.5a-6 shadow (step 3). It is deliberately the same predicate +`servedKeyOf` applies to a listing row — **the address a document carries and +the address a listing advertises are the same address**, so the two must not +be able to drift. + +Two smaller defects in the same file: `keyMaps.add` cleared a twin slug from +the reverse map but returned before clearing the first holder's forward entry, +so export emitted a slug import refuses to invert; and `keyMaps.slug` never +asked whether a slug was ambiguous. Both closed. + +`storeresolver/keyvocab.go` had **no test file at all** — 176 new lines pinned +by one incidental assertion elsewhere. It has one now, 16 subtests, every +fixture BSON-keyed or shadowed. + +#### 5. `servedKey` advertised a shadowed slug (medium) + +`servedKeyOf` tested other stored holders but never asked whether the bundled +table resolves the candidate elsewhere — the exact predicate the input side +added in Wave 1.4. Executed: with the bundled relation **not installed**, +`ListProperties` served `key="due_date"` for the squatter and +`resolve("due_date")` then 400'd as ambiguous, one request later. The existing +test asserted only the bundled row, which the holder guard already covered, so +the gap was untested. Third guard added; both namespaces pinned. + +#### 6. New shadows were still mintable through v1's rename channel (medium) + +`PATCH /v1/…/properties/{id}` and `/types/{id}` stamp `apiObjectKey` through +`ObjectSetDetails` and never enter `objectcreator`, so they bypass +`ensureUniqueApiObjectKey` entirely. Their only guard was v1's per-space +cache, which has no row for a bundled relation **not installed** in the space +— so renaming a custom key to `dueDate` minted a fresh `due_date` shadow, +through a door beside the one the mint hardening had just closed. The bundled +arm of the union check now runs on both rename paths, and `keys.go`'s claim +about new shadows is restated to name the channel rather than to assert +something broader than what was true. + +**This is the first time this wave touched v1** (§8.37's "v1 is untouched" no +longer holds): `core/api/service/property.go` and `type.go` each gain one +refusal. The v1 **document** is byte-identical after `make openapi` — verified +by checksum — because no annotation changed; only two v2 descriptions did +(`chatDerived` → `chat_derived`, `setOf` → `set_of`). + +#### 7. The last untranslated key slot, and the wrapper's fold + +`typeProperties[].objectTypes` NAMES types and was passing through +untranslated, which would have left one array in a type document speaking a +vocabulary the envelope two lines above it does not. **Decided: it is a +type-key slot and speaks the one vocabulary**, like `type` and `templateFor`. +Export spells, import inverts, an unknown term passes through verbatim (chain +step 1, so a stored-key spelling keeps working). The offline bundle linter +(`cmd/internal/anyblockbatch`) accepts both. APIV2's key-slot inventory and +SPEC §2a now say so. + +`BuildRecommendedLists` — the PATCH-types channel — writes the same §2a array +and inverted nothing, because it took a bare `PropertyResolver` and had no +vocabulary in scope. It now takes the full `Options`, so one type's property +list means the same thing whichever endpoint writes it. Nothing observable +changed through v2's own resolver, which duplicates the chain internally; what +changed is that the two channels can no longer drift apart, and the +package-level test says so with a recording resolver. + +Updating the wrapper suite to the real served vocabulary exposed one live +defect it had been masking: the wrapper's forgiving fold was +`strings.EqualFold`, which does not match `dueDate` to `due_date`. Since the +served vocabulary flipped, `dueDate` is both the most natural guess a model +makes and the spelling every pre-1.3 document used. The wrapper passes an +unfolded key through and the server still resolves it at chain step 4, so the +write lands — what silently stopped is everything the wrapper does with the +key's **format**: the relative-date convenience and the option guard. Loud on +the server, silent in the layer whose whole job is forgiveness. The fold is +now `bundle.FoldApiKey`, the server's own (§7.5a-3). + +**Verification.** Every fix has a test that fails on revert, and every revert +was executed: + +| Fix | Revert run | Fails | +|---|---|---| +| 1 | drop `Keys: r.reads` from `Options()` | 4 subtests, `keys_write_test.go` | +| 2 | `patch.Properties[key]` | 2 subtests, `schema_write_test.go` | +| 3 | re-spell to `icon_emoji`/`icon_image` | `TestSchemaOp` structural guard | +| 4 | unconditional `bundle.ApiSlug`; drop the bundled arm of `roundTrips`; drop `delete(m.slugByKey, first)`; drop the corpse filter; drop `PropertyKey`'s step-1 branch | 5 separate runs, `storeresolver/keyvocab_test.go` | +| 5 | drop `shadowed()` from `servedKeyOf` | 3 subtests, `keys_output_test.go` | +| 6 | drop both `shadowsBundled*Key` calls | 2 subtests, `core/api/service` | +| 7 | drop `typeSlugs`/`typeKeys`; drop `BuildRecommendedLists`'s inversion; revert the wrapper fold | 2 + 1 + 2 subtests | +| collapse | drop `sort.Strings(keys)`; drop the `stored[slug]` arm; drop the guard | 3 runs, `anyblockjson/keyvocab_test.go` | + +**One more defect, found by writing the test rather than by reading.** The +duplicate-slug collapse guard in `buildProperties` needs a deliberately +non-injective vocabulary to exercise, which no bundled fixture can produce — +and the moment one existed, the test failed intermittently. The collapse pass +ran over Go's **map iteration order**, so which of two holders keeps a +contested spelling was a coin flip per run: the canonical form was not +canonical, and export∘import byte-stability was chance on exactly the spaces +that hold a shadow. The pass now runs over the sorted stored keys. The same +test also exposed the guard's missing second arm: the contested spelling can +be another **stored key on the same object**, not only another holder's slug — +emitting it would bind the value to that key on the way back at chain step 1. +Both arms are pinned, the ordering one with a 32-iteration loop. + +Test debt closed alongside: `buildProperties`' duplicate-slug collapse (its +failure loses a value), `IsOutputOnlyProperty`'s bundled +fallback in both spellings, `createObjectType`'s mint wiring (the type +namespace had the helper tested and the wiring not), and the +`shadowedBundledType` branch, which had no test at all. + +**Not done.** The `apiObjectKey` backfill migration is untouched — a design +decision on replacing it with deterministic derivation is pending. +`cmd/anyblockroundtrip` still needs an account to run and was not run. + +### 8.39 Second review round: the migration that re-pointed v1's addresses (2026-08-13 — decisions as built) + +A second three-lens review of the identity layer, again reproducing every +finding by execution. The headline is not a v2 defect at all: **Wave 1's +`apiObjectKey` backfill breaks v1 create, update and search for every custom +type in every existing account**, and it does so *because* v1 "reads +`apiObjectKey` directly" — the very sentence §8.37 offered as proof that v1 +was untouched. That correction is now inline at §8.37. + +#### 1. `cacheType` indexed the slug and dropped the derived key + +v1's type cache (`core/api/service/cache_manager.go`) indexed each type under +`{Id, UniqueKey, Key}`. For a BSON-keyed custom type — `uniqueKey = +"ot-"`, which `getUniqueKeyOrGenerate` mints for **every** type not +created with an explicit unique key — the bare `` was present in exactly +one of those slots: `Key`. `getTypeFromStruct` prefers `apiObjectKey` over the +derived key, so the moment the backfill stamps a slug, `Key` becomes +`my_type` and the hex stops being an index: + +``` +ResolveTypeApiKey("67b0d3e3cda913b84c1299b1") = "" (post-backfill) +CreateObject → 500 internal_server_error +UpdateObject → 400 invalid type key +Search → 200 {"data": []} — prepareTypeFilters DROPS the key +GlobalSearch → the space vanishes from the results +``` + +The search arm is the worst of the three: an integration polling "all my +Invoices" is told, with a 200, that they were deleted. A mixed request loses +the custom type and keeps `page` — partial silent loss, still 200. + +It is also **restart-latent**: `cacheType` adds without evicting, so a live +process keeps serving the pre-backfill slot and the break appears at the next +heart restart. + +**Decided: index the derived key ALWAYS, beside the slug**, with the matching +delete in `removeType`. Properties already have this for free — the property +cache carries a `RelationKey` slot, and a relation's stored key needs no +prefix stripped. Types had no equivalent because `UniqueKey` keeps its `ot-` +prefix. The derived key is written *before* `Key`, so an explicit slug still +wins a slot the two would share. This invents no new address: `` is the +address v1 has served for these types since the beginning. + +**This changes v1 BEHAVIOUR** (the v1 *document* does not move — no annotation +shape changed, and the key-parameter text already says "type key", which both +spellings are): a bare-hex type key that returns 500/400/empty on a +post-backfill account resolves again. The direction is restoration, not a new +contract. The v1 OpenAPI document stays byte-identical. + +#### 2. The import picked by map iteration order + +`build()` in `pkg/lib/anyblockjson/import.go` ranged `doc.Properties`, so two +spellings that canonicalize onto one stored key were last-writer-wins over a +Go map. Forty-eight identical requests: + +``` +POST /v2/spaces/{id}/types {"properties":{"name":"T","icon_emoji":"A","iconEmoji":"B"}} + → stored iconEmoji: map[A:6 B:42] +``` + +This is the exact mirror of the export-side collapse §8.38 fixed. `POST +/objects` was protected because `canonicalizeDocumentKeys` refuses first +(48/48) — but the type-create channel skips that by design, and so does every +direct package caller (`cmd/anyblockroundtrip`, `cmd/anyblockrecover`, +`cmd/internal/anyblockbatch`). The 35k round-trip sweep was safe only +incidentally: its documents come from `Marshal`, which happens to produce +non-colliding spellings. + +**Decided: the refusal belongs in the CODEC, not per caller.** `build()` now +iterates sorted and returns a path-addressed `ValidationError` when two +spellings bind one stored key. The API layer's better-worded refusal stays +where it is and still fires first; the codec is the backstop for the three +`cmd/` tools and the type channel. + +#### 3. The hidden-holder rule lived in one of four namespace builders + +| builder | isUninstalled | isHidden (before) | isHidden (now) | +|---|---|---|---| +| v2 request namespace (`v2/service/keys.go`) | yes | excluded | excluded | +| document vocabulary (`storeresolver/keyvocab.go`) | yes | **counted** | excluded | +| heart mint (`objectcreator/apikey.go`) | yes | counted | **counted, deliberately** | +| backfill candidates (`apiobjectkey.go`) | yes | **counted** | excluded | + +Two executed failures came out of the disagreement: + +- **Dangling key.** A visible relation and a hidden one both slug to + `severity`. `GET /properties` advertises `severity` (v2 excludes the hidden + twin); the document vocabulary saw twins and dropped the slug from *both* + directions, so a `POST` naming `severity` stored `severity` verbatim as a + `relationLink` key **no relation object owns**. 200 OK, no warning. +- **Wrong property.** Installed bundled `dueDate` plus a hidden squatter + holding `due_date`. The listing serves `due_date` for the bundled one, the + resolution chain agrees — and `storeresolver` resolved it to the hidden + squatter's BSON key. The value landed on an invisible relation. + +The backfill made both *likelier*: `listUnslugged` had no `isHidden` filter, +so it stamped slugs onto hidden custom relations — manufacturing exactly the +twins this needs. + +**Decided, and the split is intentional:** + +- **Resolution excludes hidden holders** (`loadKeyMaps` now matches v2). A + hidden entity keeps its stored key in `storedKey`, so chain step 1 still + holds and the emit side still refuses to spell someone else's value with + it; it simply owns no slug. +- **Candidate selection excludes them** (the one migration change made — the + file is otherwise untouched pending the deterministic-derivation decision). + They stay in `listTypesAndProperties`, the namespace checked *against*. +- **The mint keeps counting them.** The two jobs differ. v2's rule is about + RESOLUTION: a hidden holder is invisible and undeletable to a caller, so + letting it block a visible holder's slug makes that slug permanently + unusable through no visible cause. The mint's rule is about CREATION: + minting a second entity onto an occupied slug is precisely what + *manufactures* the ambiguity the other rule has to paper over. Excluding + them there would let a hidden holder's slug be re-minted and put two rows + on one address forever. Counting costs one `_2` suffix on a name collision + the user never sees, and this mint never refuses, so nobody is blocked. + +The divergence is now **visible rather than silent** — see 4. + +#### 4. A 201 returned a key that does not exist + +`POST /v2/…/properties` set `CreateResult{Key: slug}` from the **proposed** +slug and never read back what `objectcreator` stored. Given 3's deliberate +split, that is now a reachable, expected divergence: the mint's namespace sees +holders v2's pre-check does not, so it suffixes — `201 {"key": +"manual_property"}` then `GET …/properties/manual_property` → **404**. The +same shape existed on the type path. + +**Decided: read the stored `apiObjectKey` back out of the create response** +(`storedApiKeyOf`) on both paths. An empty stored slug is authoritative and +falls through to the internal key: it means the mint's walk ran out and the +minted key is the only address there is, which the caller must be told rather +than handed a key that resolves to nothing. A response with no details at all +(the mocked case) keeps the proposal. + +#### 5. The dead rival spelling authority is deleted + +`propertyEntry.publicKey()` / `typeEntry.publicKey()` were dead repo-wide and +returned the stored slug with **none** of `servedKeyOf`'s three round-trip +guards. Methods never trip an unused-symbol check, so the next listing anyone +writes would have picked one up and re-opened the class. Deleted, along with +`isBsonLikeKey`, whose BSON-or-not distinction was that rival rule's whole +basis. There is now exactly one authority for a wire spelling in v2: +`servedKeyOf`. A deletion has no revert test of its own; the guards it +protects are pinned by `TestV2ServedKeyRefusesAShadowedSlug` and +`TestV2ListingsServeBundledSlugs`, which a reintroduced `publicKey` would +fail the moment a listing used it. + +#### 6. Also fixed + +- **The accept half now folds (chain step 4).** `storeresolver.PropertyKey` / + `TypeKey` implemented steps 1–3 and then degraded verbatim, while the v2 + route layer folded — so one request could resolve `Severity` through + `/properties` and, in the same body, store it unfolded as a dataview column + key. Only `update_view` was covered (`canonicalViewKey`). A single folded + candidate resolves; several degrade verbatim (never a guess); hidden + holders do not participate; an exact stored key still wins first. It folds + over both the stored key and the stored slug, exactly as the route layer + does — deliberately, since a fold that differs between the two halves would + recreate the disagreement class this round exists to close. **One widening + to watch on the next round-trip sweep:** a detail key with no relation + object behind it (a stray or local detail) no longer necessarily survives + export∘import verbatim — if it differs from a *live* key only by case or + separator, step 4 now binds it to that live key. A stray whose live twin is + a real relation object is caught earlier by step 1, so this needs a key that + is simultaneously stray and case-twinned with a live one. +- **`bundle/apislug.go`'s `init` has an injectivity guard.** `key → slug` is + lossy and the reverse tables are plain maps, so a bundled key added later + that snakes onto an existing slug would make the reverse table a + per-process coin flip with no signal anywhere. `init` now builds from + **sorted** keys and panics on a duplicate slug or a duplicate fold. Probed + clean today: 194 relations → 194 slugs → 194 folds; 29 types → 29 → 29. +- **`PATCH /types/{t}` refuses two spellings of one detail.** `sortedKeys` + made the winner deterministic, which is not the same as correct — the + caller asked for two values on one detail and one was being dropped. +- **`prepareValues` sorts its input.** Every refusal in it names the first + offending key it meets, so over a Go map the wrapper told an agent + something different about the same body on each run. +- **v1's cross-space property subscription filters `isUninstalled`.** It + filtered `isHidden` only, so a UI-deleted property still listed, still + resolved as an address and still blocked a same-key create in v1 while v2 + had already vacated that slug. The namespace a key lives in cannot depend + on which version asks. (The type and tag subscriptions have the same shape; + changing those moves more of v1's behaviour than this round's finding + covers, and is left named rather than done.) +- **The test-fixture fd leak.** `dsObjectStore.Close` only cancels a context — + the sqlite handles belong to the *provider*, which `objectstore.NewStoreFixture` + never closed. Every fixture held its databases open until the binary + exited, so `-count=N` on a package with a fixture per subtest died on "too + many open files" — taking repetition away as an instrument exactly where + map-order defects need it. The provider is now closed in the fixture's + `t.Cleanup`. Measured: at `ulimit -n 256`, 20 iterations of two v2 test + functions died before the fix and pass after it. + +**Verification.** Every fix has a test that fails on revert, and each revert +was run: + +| fix | revert | fails | +|---|---|---| +| 1 `cacheType` derived key | drop the derived write | `TestCacheType_BsonKeyStaysAddressableAfterTheApiObjectKeyBackfill` (2 subtests: resolve + the silent search drop) | +| 2 codec collapse | drop the `boundBy` refusal / drop the sort | `TestImportRefusesTwoSpellingsOfOneStoredKey` (both arms; the sort arm over 32 runs) | +| 3 hidden holders | drop `hidden` from `keyMaps.add` | `TestHiddenHoldersDoNotOwnSlugs` (3 subtests) | +| 3 backfill | drop the `isHidden` candidate filter | `TestBackfillLeavesHiddenObjectsAlone` | +| 4 read-back | return the proposal | `TestCreateReturnsTheStoredKeyNotTheProposal` (3 subtests) | +| 6 fold | drop the step-4 branch | `TestAcceptHalfFolds` (3 subtests) | +| 6 injectivity | short-circuit `checkApiSlugInjectivity` | `TestApiSlugInjectivityGuardFires` (2 subtests) | +| 6 PATCH duplicate | drop the refusal | `TestV2UpdateType/two spellings of one detail…` | +| 6 `prepareValues` | restore `range values` | `TestPrepareValuesIsOrderDeterministic` (32 runs) | +| 6 v1 corpse filter | drop the filter | `TestCrossSpacePropertyFiltersVacateCorpses` | + +Every new fixture uses a BSON-keyed entity with a stored `apiObjectKey`, a +hidden holder, or a stored key the bundled table resolves elsewhere. This was +the fourth round the blind-fixture problem bit, and the fixtures are now +checked for it before the assertion is written. + +**Not done.** The backfill migration is otherwise untouched — the +deterministic-derivation decision is still pending, and only the `isHidden` +candidate filter was changed. v1's type and tag cross-space subscriptions +keep their `isUninstalled` gap (named above). `cmd/anyblockroundtrip` still +needs an account and was not run; the running server predates this HEAD, so +verification here is unit and handler tests only. + +### 8.40 The corpse policy, applied where it was only half-applied (2026-08-14 — decisions as built) + +> **CORRECTION (§8.41).** This section's framing fact was itself one shape +> short: a corpse has THREE store shapes, not two — the §8.41 tombstone +> (`{id, spaceId, isDeleted}`, the deleting device's shape until its next +> space load) defeats both probes this section introduced, because they +> filter on `relationKey`, a field the tombstone does not have. Fixes 1 and +> 2 below were therefore dead for the rest of the session that follows +> every UI delete. §8.41 re-keys the probes on the derived id, widens the +> refusal to the type namespace, the view/set channels and the archived +> state, and corrects the specific claims below that execution disproved +> (marked inline). + +The round that pinned corpse addressability (`corpse_addressability_test.go`) +left three findings marked as gaps, deliberately, with tests that pinned the +broken behaviour loudly. This section is those three fixed, plus the spec +correction the round's framing fact demanded. + +**The framing fact, first, because two of the three defects are the same +mistake.** A production corpse has **two flags, not one**. A UI delete sets +`isUninstalled=true` (`core/block/delete.go:113-127`) and the *same Apply* +stamps `isDeleted=true` beside it (`smartblock/detailsinject.go:219-226`); +`BeforeDelete` tombstones the index row, the tree survives, and the next +space load re-indexes it with **both** flags and full details. Every plain +store query injects `isDeleted != true` (`database.go:109-123`), so a prod +corpse is hidden from queries even where nothing filters `isUninstalled`. + +Every pre-existing corpse fixture in this repo modelled `{isUninstalled}` +alone — a shape production never has — so any probe that suppressed only +`isArchived` looked correct in tests and did nothing in the field. Both fixed +probes now suppress **both** defaults, and every fixture in this file runs +over **both** shapes. + +#### 1. The §8.29 clone tolerance was dead in production + +`propertyKeyHeldByAnyRelation` is the round-trip escape that lets a pasted +read body create a copy when the body carries a value of a UI-deleted +property. Its query suppressed the injected `isArchived` default and not +`isDeleted`, so it found archived corpses and missed uninstalled ones — the +common case, and the one the tolerance was written for. The advertised loop +`GET → POST` returned 400 `unknown property keys` on a document the API +itself had just served. + +**Fixed** by the explicit no-op `isDeleted Condition None` clause beside the +`isArchived` one (`Condition None` compiles to no filter at all, so this is +pure default suppression). The probe is now one shared helper, +`relationObjectHoldingKey`, which returns the holding relation object rather +than a bool — fixes 1 and 2 need the same row, and a second query shape would +have been a second place to forget a default. + +Before/after, both shapes: flag-only accepted → accepted (unchanged); prod +**400 → accepted**, value landing under the stored key it was served under. + +#### 2. The type read-edit loop minted garbage duplicates — resolve, not refuse + +`GET /v2/spaces/{s}/types/{key}` serves a corpse property inside +`typeProperties` under its stored BSON key with its name: `recommendedRelations` +resolves BY ID, and the by-id path (`GetRelationById` → `GetDetails`) is +unfiltered, so it escapes even the `isDeleted` default. PATCHing that served +list back walked `creatingResolvers.PropertyId`, whose resolution chain is +corpse-aware by design, missed, and therefore **minted a brand-new property** +— name duplicated, `apiObjectKey` the snake-cased hex +(`6_a_7663_db_61_fab_21_cd_4_b_9_e_201`), once per PATCH, forever. + +**Decision: resolve to the holder, never refuse, never mint.** + +- Minting is wrong either way: a 24-hex stored key arriving in a key slot has + never been a legitimate mint request. That much was settled before this + round. +- Refusing would break the loop the guides document. `typeProperties` is a + **whole-list replace**, so the caller's only repair would be hand-stripping + an entry from a body the API served — the §8.34 unactionable-refusal + defect — and doing so would silently DELETE the type's reference to that + relation. A refusal here trades a garbage duplicate for data loss. +- Resolving is an identity: the id returned is the id already sitting in + `recommendedRelations`. Nothing is created, no value moves, no side effect + is reported. It is the `typeProperties` counterpart of §8.29's create + tolerance, and holder-based for the same reason that one is. +- It is deliberately **key-only**. The corpse's *slug* vacated the namespace + (§8-OQ2) and still resolves to nothing here, so naming the slug in + `typeProperties` still mints a fresh property — pinned as its own subtest, + because a resolve that answered the slug would re-aim a vacated address + onto a corpse. + +**Should the READ serve corpse entries at all?** Concluded yes, unchanged. +The type document mirrors the list the type actually stores; dropping corpse +rows would make the documented read-modify-write loop a silent deletion of +every corpse reference it touches. The read stays faithful, and the write +half is what had to learn to read it back. + +Before/after, both shapes: a mint of `6_a_7663_db_61_fab_21_cd_4_b_9_e_201` +→ `recommendedRelations` unchanged at `["rel-corpse-bson"]`, `created` nil. +Both shapes behaved identically here (the by-id read escapes both defaults), +which is why the fix is at the write half, not the read. *(CORRECTED in +§8.41-1: the TOMBSTONE shape does not behave identically — `GetRelationById` +fails on a row with no `relationKey`, the read silently DROPS the corpse +entry, and the loop this fix protects becomes the §8.34 silent deletion by +another door. §8.41 makes the read recover the entry from the surviving +tree and the resolve survive via the derived id. Note also the by-id escape +is relation-specific: the TYPE-side point lookup, `GetObjectType`, filters +`isDeleted` explicitly — "point lookups by id are unfiltered" was never +true across kinds, ADDRESSING §2.3-6.)* + +#### 3. Bundled corpses: refuse the write, loudly and actionably + +Uninstalling a bundled relation removed it from listings and 404'd its +routes, but `due_date` stayed a valid DOCUMENT key in every space — the +bundled vocabulary (chain step 3, `propertyKeyExistsIn`'s `bundle.HasRelation` +arm) answers for a bundled key forever, regardless of any object's state. So +a create landed new data on a property the user had deleted, and a reinstall +would light it back up. "Nothing new lands in an uninstalled property" simply +did not hold for bundled corpses. + +**Decided: refuse** — consistent with this API's standing rule that a write +must not silently land somewhere the caller did not ask for. + +**The distinction that makes it safe is never-installed vs uninstalled**, and +it is drawn from the store, not from the bundle: `uninstalledBundledKeys` +queries relation objects with `isUninstalled=true` (both injected defaults +suppressed, so the prod shape is seen) and keeps only those whose stored key +`bundle.HasRelation` knows. A bundled relation nobody ever installed has **no +relation object at all**, is absent from that set, and keeps working exactly +as before — install-on-write is correct and is the common case in a fresh +space. Conflating the two would have broken ordinary writes in every space, +so the never-installed case is pinned by its own subtest, and so is the +boundary in the other direction: an **archived** (v2-deleted) bundled +relation is *not* in the set and still accepts writes. Whether it should is +an open question this round did not settle; the test pins today's answer so +that widening the probe cannot pass silently. *(SETTLED in §8.41-8: archived +now refuses on the same channels — v2's own DELETE creates the state, and a +property that 404s on its route while accepting writes is the incoherence +this whole policy exists to remove. The pinned test was flipped as the +conscious edit this paragraph asked for.)* + +The refusal names the repair (§8.34): `property "due_date" was removed from +this space — nothing new lands on a removed property`, with a hint pointing +at restoring it or listing the live ones. It spells the **served slug**, not +the canonicalized stored key the validator happens to be holding. + +Channels, and why each is where it is: + +| channel | before | after | +|---|---|---| +| `POST /objects` (document create) | value landed on `dueDate` | 400, repair named | +| `PATCH … set_properties`, key NOT on the document | value landed | 400, repair named | +| `PATCH … set_properties`, key already on the document | edit/unset allowed | **unchanged** — the two-tier rule (§8.17, `checkKey`'s `inDoc`) keeps a document's own values editable, and `unset` is the only cleanup channel a caller has left | +| `update_view` introducing the key | 400 (`unknown property key`) | **unchanged, verified** — view documents spell slugs, and the bundled slug stops resolving the moment the relation is uninstalled, so `validateViewKeys` already refused at its unknown-key branch. A removal check there would have been dead code; the closure is pinned by test regardless of which branch closes it *(CORRECTED in §8.41-2: true only when slug ≠ key — dueDate, the one key every test here used, is one of the few. For the 41 bundled relations whose derived slug EQUALS the key — `tag`, `status`, `description`, … — the slug never stops resolving and this channel accepted all of them, 40/40 in the executed matrix. The removal check is now wired.)* | + +The asymmetry with §8.29's tolerance is deliberate and lives in the entities, +not the policy: a **custom** corpse's stored key is a BSON id that can never +be reinstalled or re-derived, so a document value on it is inert freight the +tolerance carries; a **bundled** corpse's key is reinstallable, so a value +landing there resurrects into a property the user deleted the moment it comes +back. Both classes appear in one fixture, and the subtest that proves the +custom tolerance survives the bundled refusal is what keeps that line honest. + +#### 4. ADDRESSING §2.3-6 corrected + +The finding read "uninstalled relations remain fully visible … nothing +filters `isUninstalled`". That describes the flag-only fixture world. It now +carries the two-flag / tombstone / reindex reality, states that the injected +`isDeleted` default is what actually hides corpses in production while v2's +explicit `isUninstalled` filters are belt-and-braces, and names the two +residual visibility channels that do survive (unfiltered point lookups by id; +probes that suppress the defaults). The correction ends with the rule this +round paid for twice: any claim about corpse visibility must name which of +the two shapes it is about. + +**Verification.** Every fix has a test that fails on revert, and each revert +was run: + +| fix | revert | fails | +|---|---|---| +| 1 clone tolerance | drop `isDeleted Condition None` from `relationObjectHoldingKey` | `TestV2CloneToleranceSurvivesTheProdShape` **prod leg** (plus the prod legs of the type-echo and custom-corpse subtests — flag-only legs stay green, which is the point) | +| 2 typeProperties resolve | drop the `relationObjectHoldingKey` arm from `PropertyId` | `TestV2TypePropertiesCorpseEchoResolvesToItsHolder/PATCHing…` on **both** shapes — the `6_a_7663_…` duplicate reappears | +| 3 create refusal | drop the `removedPropertyIssue` arm from `validatePropertyKeys` | `TestV2UninstalledBundledPropertyRefusesWrites/create refuses…` both shapes | +| 3 PATCH refusal | drop the arm from `stateops` `checkKey` | `…/PATCH refuses it off-document…` both shapes | +| 3 never-installed boundary | widen `uninstalledBundledKeys` to `isUninstalled Condition None` | `…/an ARCHIVED bundled property is outside this refusal` | + +Fixture discipline: every corpse fixture in this file is BSON-keyed **with** a +stored `apiObjectKey` and runs over both store shapes — the one shape that +can tell the two vocabularies apart, and the only pairing that can catch an +`isDeleted`-default mistake. *(WIDENED in §8.41: "both" became **three** — +the tombstone leg joined `corpseShapes`, because a two-shape fixture +structurally cannot express the failure both fixes above had.)* The single +exception is stated in the test +itself: the bundled corpse **cannot** be BSON-keyed (its key is `dueDate` by +definition and its slug is derived in code, never stored), which is precisely +the entity class the refusal is about; the BSON-keyed corpse rides along in +the same fixture to prove the refusal does not leak into the tolerance. + +**Not done, deliberately.** The read-emit/write-refuse split stays rejected — +emitting a corpse's slug would put it into every post-uninstall export, and +those documents silently re-bind when the vacated slug is re-minted, which is +exactly what the corpse policy exists to allow; today's degrade-to-stored-key +is what pins a value to its entity. No `?include=uninstalled` discovery +surface. The backfill migration is untouched. v1's type and tag subscriptions +keep their missing corpse filter (masked in production by the `isDeleted` +default; adding it would drop UI-deleted types from v1's listings, a wider +behaviour change than this round covers) — its gap-marked test stays as-is. +No served schema or annotation changed, so `make openapi` was not needed and +both documents are byte-identical. The running server predates this HEAD; +verification here is unit and handler tests only. + +### 8.41 The corpse policy under three lenses: the tombstone window, the slug==key class, and the type namespace (2026-08-14 — decisions as built) + +A three-lens review of §8.40 executed the fixes instead of reading them, and +found that the round's worst defects had survived because the fixtures +**structurally could not express the failures**. Three separate instances: +every corpse fixture had two store shapes where a corpse has three; every +bundled-key fixture used `dueDate` where 41 of 194 bundled relations behave +differently; and every space-id fixture was dot-free where real space ids +are dotted. This section is the fixes, the two decisions the review settled, +and the corrections §8.40 and ADDRESSING §2.3-6 needed. + +#### 1. The tombstone window disabled §8.40's own fixes + +**The missing shape.** `spaceindex.DeleteObject` strips a deleted object's +index row to `{id, spaceId, isDeleted}` — no `relationKey`, no +`resolvedLayout`. That is the store's answer on the deleting device from the +delete until the next space load: normally the rest of the app session. Both +§8.40 probes (`relationObjectHoldingKey`, the removal set) query +`relationKey == key` + layout, so both missed the tombstone **on their first +filter** — not on a suppressed default, which is why the `Condition None` +lesson didn't help. Executed: in the window, a custom-corpse clone 400'd +again (§8.29 undone), a removed-bundled create landed the value again, and +the typeProperties fix was masked only because the READ broke first — +`GetRelationById` fails on a keyless row, `typeProperties` silently dropped +the corpse entry, and the documented read-modify-write loop DELETED the +type's reference: the §8.34 outcome §8.40 explicitly rejected, reached +through another door. + +**The fix keys on what the tombstone cannot erase.** A derived object's id +is a pure function of (space, kind, internal key) — ADDRESSING §2.4 — so +the row's ADDRESS survives the row's fields. The `ObjectCreator` port grew +`RelationIdByKey` (the `TypeIdByKey` twin, `clientspace.GetRelationIdByKey` +underneath), and: + +- `relationObjectHoldingKey` falls back, on a query miss, to a point lookup + at the derived id — a row there, tombstone included, is the holder. It + also now **prefers a live row** over a corpse when several hold one key + (§8.40's `Limit:1`-no-sort depended on a caller-ordering convention + stated nowhere) and returns errors instead of swallowing them — a probe + error must never read as "not held" on the mint path. +- `bundledPropertyRemoved` / `bundledTypeRemoved` are the per-key removal + verdicts: the query-built set, plus the tombstone probe (row at the + derived id, `isDeleted`, no key field) for the window the set cannot see. + A missing row still means never-installed — install-on-write is intact. +- the READ half recovers what the index lost: for a type read, + `seedTombstonedTypeProperties` reads the corpse relation's **live + object** (the tree survives a UI delete by design, §2.4-5) and seeds the + store resolver, so `typeProperties` serves the same bytes in all three + shapes and the PATCH echo stays an identity. A read failure degrades to + the pre-§8.41 drop for that entry, never to an error. + +**The derived-id assumption, verified.** Every relation/type creation path +derives the object id from `rel-` / `ot-`: `createRelation` and +type creation build the state with `NewDocWithUniqueKey`; the import path +(`objectid/derivedobject.go`) derives from the snapshot's unique key, and +when it re-mints (unique key held by a deleted object) it re-mints the +INTERNAL KEY too, so key↔id consistency holds for the new row as well. The +one caveat: `isDeletedObject`'s check there queries by `uniqueKey`, which a +tombstone also lacks — an import colliding with a tombstoned corpse derives +the same id; `PutTree` then meets `ErrTreeExists` (outside the installer's +tolerance), which is the pre-existing §2.4-5 behavior, not new exposure. A +relation whose row predates the unique-key era and matches no derivation +would be invisible to the tombstone fallback — but such a row also cannot +BE tombstoned into anonymity meaningfully, since nothing could ever find it; +the query-based legs still serve its full-detail shapes. + +`corpseShapes` now runs THREE legs, and the fixture stubs derive +deterministic ids (`drv-rel-`, `drv-ot-`) with the tombstone rows +placed at them — the same relationship production rows have to their keys. + +#### 2. The slug==key class: the hole §8.40's own test fixture hid + +41 of 194 bundled relations have `ApiSlug(key) == key` — `tag`, `status`, +`description`, `assignee`, `priority`, … `dueDate` is one of the keys where +slug ≠ key, and every §8.40 verification used it. The consequence: §8.40's +"the bundled slug stops resolving, so `update_view` already refuses" was true +only for the slug≠key class. Executed: `tag`, `status`, `description`, +`assignee`, `priority` × {columns, groupBy, filters, sorts} → **40 of 40 +accepted**, landing in `dataview.properties` and `view.columns` of removed +properties. `validateViewKeys` (viewops) now runs the same removal gate as +create and PATCH — with the §8.17 `preKnown` escape intact (a view already +showing the key stays editable), and with the slug≠key spelling's refusal +upgraded from a misleading "unknown property key" to the removal message. +Every bundled-key test now covers BOTH classes by rule. + +#### 3. The bundled clone loop: refusal stays, and now names the repair that works + +`GET` of an object holding a removed bundled property serves +`{"due_date": …}` (nothing contests the slug once the corpse vacates it); +`POST` of those exact bytes 400s — §8.29-F2 re-created for the bundled +class. Decided: **the refusal stays, and the message now names the actual +repair**. Why not the alternatives: + +- *Degrade the read to the stored key* (`dueDate`): insufficient — the + stored key refuses too (removal is keyed on the relation, not the + spelling), and it would poison every export with a spelling that + re-canonicalizes right back to the same refusal. +- *Accept the paste like the custom tolerance*: wrong — the asymmetry is in + the entities (§8.40): a custom corpse's key resolves nowhere, so carried + values are inert; a bundled corpse's key is REINSTALLABLE, so every + accepted clone is new resurrection freight the reinstall lights up. +- *A provenance signal* ("this value came from a GET"): indistinguishable + from a fresh write without server-side state; not worth its cost. + +So the loop cannot be made to round-trip for this class, and the refusal +says what to do instead: `remove "due_date" from the request — values +objects already hold stay readable, and reappear if the property is +restored; …`. The old `restore it in the app` is gone (inactionable for a +headless caller). Channel-table row for the record: + +| channel | before | after | +|---|---|---| +| `GET` object holding removed bundled property → `POST` those bytes | 400 naming a repair that does not repair | 400 naming the working repair (remove the key; the source object keeps its value) | + +#### 4. The refusal reaches typeProperties — with the echo escape + +`POST`/`PATCH /types` with `{"key":"due_date"}` while dueDate is removed +pointed the type's `recommendedRelations` at the corpse (`created: null`, +silently). `PropertyId`'s bundled arm now consults the removal verdict and +refuses — **except** when the PATCHed type's lists already reference the +holder (`echoPropertyIds`, primed by UpdateType from the type's current +recommended lists): the type's own GET/PATCH echo resolves as an identity, +because refusing it would force-delete the reference — the same §8.34 +reasoning as §8.40's custom-corpse decision, now applied consistently. +`POST /types` has no prior references and always refuses. The §8.40-era +resurrection (this path used to REINSTALL the deleted relation) stays +closed: the refusal precedes every install/mint RPC, and the tests fail on +any unexpected one. + +#### 5. The type namespace, closed the same way + +`POST /objects {"type":"task"}` with bundled `task` uninstalled → accepted, +object created in the removed type, `GET /types/task` 404ing beside it — +the identical self-contradiction, entirely unhandled (there was no type +removal set at all). Now: `bundledTypeRemovalSet` + `bundledTypeRemoved` +(same three-shape coverage, same never-installed boundary via the derived +id), consulted by `validateDocumentRefs` for `/type` and `/templateFor`, +and by `CreateSet` — which already refused (a set needs an installed type +object) but as "unknown type key" with a did-you-mean; it now says +*removed*. `removedTypeIssue` steers to the live type list; the repair +differs from the property one because a create cannot drop its type. + +#### 6. Sets, options, archived + +- **`POST /sets`** validated filter/sort keys against the type's + recommended lists — resolved by id, never stripped of deleted relations, + i.e. the DEFAULT state after any UI delete. The removal gate now runs + after the membership pass. (Tombstone window: the recommended-list + resolution cannot spell the key at all, so it falls out of the reference + set and the has-no-property branch refuses — a 400 either way, pinned.) +- **Relation options** had no `isUninstalled` filter anywhere — the + injected `isDeleted` default was the SOLE defence, in the one entity + class the corpse rounds never audited. `ListRelationOptions` now excludes + `isUninstalled` explicitly (spaceindex — shared with v1, whose behavior + is unchanged for every shape a live index can hold, since the default + already hid those rows). +- **Archived refuses writes on the same channels as uninstalled** — the + §8.40 open question, settled: `DELETE /v2/…/properties/{key}` archives, + the route then 404s, and a `POST` still wrote to it; the API's own + delete verb must not create that state. The removal sets now include + `isArchived` rows (types too), and §8.40's boundary-pinning test was + flipped as the conscious edit it asked for. The archived tolerance for + values a document already holds is untouched: the PATCH in-document + escape and unset both survive, and the custom-key §8.29 tolerance never + keyed on flags at all. + +#### 7. Small print made honest + +- **The wrapper's route fallback** (`restRoute`) stopped at `.` inside real + dotted space ids, mangling hints into `the HTTP API.28y6…/properties` — + invisible because every steer fixture used dot-free `space1`. The regex + now consumes dots followed by route characters and still stops at + sentence-ending dots; the new removal hints got `restVocab` rows; dotted + ids are in the fixtures. +- **Message/path coherence**: a removal refusal's envelope now says + `removed property keys` (or `unknown and removed …` when mixed) — the key + is known, "unknown" was a lie the issue text contradicted — and issue + paths spell the key as the CALLER sent it (create and set_properties keep + a canonical→sent spelling map), not as canonicalization rewrote it. +- **`propertyKeyHeldByAnyRelation`'s comment** claimed a constraint the + code never enforced ("a document that legitimately carries a value"). + The code is intentionally a bare existence probe — no provenance signal + exists, rows with only `isDeleted` pass too — and the comment now says + exactly that instead of implying a check. +- **`typeKeysById`** was a plain query, so its corpse branch (internal-key + spelling) was dead in production — the injected default emptied it of + prod corpses and the per-row point-lookup fallback served them instead, + untested. It now suppresses both defaults; the corpse-row test runs all + three shapes through the ROW BUILDER, and pins the tombstone window's + only honest answer: an empty type on the row (nothing to spell). + +**Verification.** Every fix has a test that fails on revert; each revert was +run: + +| fix | revert | fails | +|---|---|---| +| 1 tombstone fallback (holder probe) | drop `derivedRelationRow` fallback from `relationObjectHoldingKey` | clone-tolerance and typeProperties-echo **tombstone legs** only — every full-detail leg stays green, which is the §8.41 point | +| 1 tombstone fallback (removal verdict) | drop the tombstone arm from `bundledPropertyRemoved` | `TestV2UninstalledBundledPropertyRefusesWrites` create/PATCH/view **tombstone legs** | +| 1 read recovery | drop `seedTombstonedTypeProperties` from `GetObject` | typeProperties GET **tombstone leg** (the entry vanishes) | +| 1 prefer-live | first-match selection instead of live-preferred | `TestV2HoldingKeyPrefersLive` (corpse row sorts first by id, so id-ordering alone also fails it) | +| 2 view channel | drop the removal gate from viewops `validateViewKeys` | `TestV2RemovedBundledSlugEqualsKeyClass` view legs (all four channels), while the dueDate column test stays green — the class split, demonstrated | +| 4 typeProperties refusal | drop the removal gate from `PropertyId`'s bundled arm | `TestV2TypePropertiesRefusesRemovedBundledKey` POST and PATCH subtests | +| 4 echo escape | drop `echoPropertyIds` | the echo subtest turns into a refusal | +| 5 type namespace | drop `refuseRemovedType` from `validateDocumentRefs` | `TestV2RemovedBundledTypeRefusesWrites` create + templateFor legs | +| 5 type tombstone | drop the tombstone arm from `bundledTypeRemoved` | its **tombstone legs** only | +| 6 sets | drop the removal gate from list_create `validateViewKeys` | `TestV2SetsRefuseRemovedBundledProperty` flag-only + prod legs | +| 6 options | drop the `isUninstalled` filter from `ListRelationOptions` | `TestListRelationOptionsExcludesRemovedOptions` (the flag-only option serves again) | +| 8 archived | drop `isArchived` from `corpseFlagged` | both ARCHIVED subtests (property + type) | +| 7 route regex | restore `[^\s,;.)]*` | the dotted-space-id steer test (`the HTTP API.28y6…` reappears) | +| 10 messages | restore the `unknown property keys` envelope / canonical paths | the create-leg coherence assertions | +| typeKeysById | drop the `Condition None` clauses | the corpse-row test's **prod leg** | + +**Not done, deliberately.** v1's missing corpse filters stay (per §8.40 — +masked by the injected default in every shape a live index holds). +`typePropertyKeys` (the sets reference set) does not learn the tombstone +window: its narrowing there only ever REFUSES more, never lands data. The +tombstone window's object rows serve an empty type rather than a derived +guess — the store has nothing to spell and inventing a spelling from the id +is not recoverable. No served schema or annotation changed, so +`make openapi` was not needed and both documents are byte-identical. The +running server predates this HEAD; verification is unit and handler tests. + +### 8.42 Object DELETE: creator provenance and the own-output rule (2026-08-14 — decisions as built) + +Plan 3.3, built to `core/api/APIV2_OBJECT_DELETE.md` (the design record; +§ references below are into it). `DELETE /v2/spaces/{space_id}/objects/ +{object_id}` is registered: archive semantics (Bin, reversible in the app — +v1 parity and uniformity with the type/property DELETEs), C8 idempotency + +write rate limit + `V2DeleteObject` analytics, C9 `?dry_run=true` as the +deletability probe (full verdict incl. provenance, no write). `?permanent= +true` stays reserved and unimplemented. + +**The rule.** Deletion is permitted only for objects the calling API key +created. The record is the `integrationName` field on the object's CREATING +change (`pb.Change` field 10 — wire number as the attribution spec defined; +the VALUE was revised by the second review round, §8.44: the RAW session +app name, stamped verbatim and compared exactly, replacing the normalized +slug whose many-to-one collapse and lossy-empty class were both executed +against — the tolerance normalization bought was deliberately dropped, +because for an authorization comparison tolerance is a liability). Stamped +by heart per-apply at creation — never accepted from a request, never +persisted on the state, so a later local edit cannot inherit it. +Enforcement reads BOTH clauses from validated change storage via a +history-tree build (§10): signed root identity = this account, AND first +content change carries this account's identity + the caller's exact app +name. Details are never consulted — a detail is overwritable by any member +with write access, which is the forgery the requirement excludes. + +**Fail-closed, and its stated consequence.** No recorded key → 403 +`not_created_by_this_key`, for every caller, permanently: **DELETE does not +clean up objects created before it shipped** — everything pre-existing +(including today's v2-created eval fixtures), everything created in the +app, by import, or by another member stays undeletable through v2, by the +settled §8 decision (no backfill, no grandfathering). The two eval +documents in the test account still need one manual archive. Any future +"delete arbitrary objects" capability is a separate product decision +(§17.2), not a gap here. Every error path refuses too: provenance read +failure, nil provenance dependency, ambiguous history — none reach the +archive RPC (pinned by strict-mock tests). + +**Surface details as built.** The refusal names what IS recorded (§9.5's +three variants + a fourth for a nameless caller, each carrying the dry-run +probe hint); type/property/option targets steer to their own routes BEFORE +the provenance read; re-delete of an archived object is a 200 no-op with a +warning; the grant conjunction is ordered (space_not_granted / +write_not_granted fire before ownership, pinned by expectation-free +mocks). One asymmetry stated rather than implied (F8): `DELETE +/types/{key}` and `/properties/{key}` archive on the write grant ALONE — +"own output only" is a property of THIS route, not of v2 deletion; the +schema-delete own-output question stays open in §17.1. v1 is untouched except that its creations now carry the stamp for +free (§8a — both surfaces converge in objectcreator and share +ensureAuthenticated): v1's DELETE stays the unrestricted grandfathered +escape, and v1's OpenAPI document is byte-identical. Key issuance now +requires an app name (CreateApp and the challenge flow, §11.7) so no new +key can mint itself into permanently-unprovenanced output — with the +raw-name revision that guard is complete (no non-empty name can lose its +record), and issuance also bounds the name at 128 bytes +(`domain.MaxIntegrationNameLen`; reject, never truncate — no bound existed +anywhere on AppName before, and the raw name now rides every creating +change). + +**Deliberately not built** (attribution's, later — §11): the integration +object + icons, `createdVia`/`lastModifiedVia`, StoreChange/ChatMessage +fields, history surfacing, gRPC session labeling, any UI. The §13 caching +suggestion is moot since the raw-name revision: the middleware installs +the session's AppName itself — no derivation exists to cache. + +**Correction (second review round, F1 — replaces this section's earlier +"derived-tree objects can never pass the root clause", which was FALSE).** +There are two derive payload shapes: `derivePersonalPayload` builds a +SIGNED root with the account identity, and `objectcache/tree.go` picks it +whenever `personalSpaceId == space.Id()` OR `params.UseAccountSignature` — +which `objectcreator` sets for every FileObject, in every space. Executed: +a file object on that path yields `accountMatch=true`. So the root clause +alone does not exclude system/derived objects (and `IsDerived` is false on +the personal shape — no cheap flag recovers it); latently, a derived tree +whose root exists locally with no content change yet could take its FIRST +content change — stamp included — from an API request (`cacheLoad` sets +`IsNewObject` unconditionally; `smartBlock.Init` refuses only a non-empty +doc; derived ids are deterministic). What the code now guarantees instead: +`DeleteObject` runs a POSITIVE sbType allowlist — user content only +(`Page`, `Template`, `FileObject`, `ChatDerivedObject`, +`DiscussionObject`, derived from the creation surface's +`objectTypeKeysToSmartBlockType`) — before the provenance read, so +Workspace/Archive/Home/Widget/SpaceView/Participant/Profile/Date and every +other system surface refuses regardless of what provenance says (the +steered schema trio keeps its more useful per-route steer). Provenance +answers "whose is it"; the allowlist answers "is this deletable content". + +### 8.43 Wave 2.1a — find-as-locator on replace_text (2026-08-14 — decisions as built) + +The first locator slice (TOKENS §5, plan 2.1a): `replace_text`'s `id` is +now optional, and when omitted `find` doubles as the locator. This is +shipped behaviour moved down a layer, not new behaviour: the wrapper's +`edit_text` has resolved an omitted `block` from the find snippet since +§8.21 — measured there, making the id optional took small-model tool +selection from 7/8 (gemma4:e4b) and 6/8 (e2b) to 8/8 — and §5.5's +argument for moving it is also the correctness win. The wrapper's +`locateBlock` was a read-then-patch TOCTOU: GET the document, resolve +client-side, PATCH by id, with the document free to move in between. +In-API resolution runs under the object lock and the race disappears. + +**The rule as built (`locator.go` resolveByFind, §5.3 verbatim).** The +find text must be contained in exactly ONE block's text, or the op +refuses — never a guess: + +- **Zero matches** → 404-class C6: "no block contains %q — copy the find + text exactly, including inline markup (text is markdown source: ** [ ] + etc. count)", with the outline-read steer in the issue hint. The + exact-copy phrase is the id path's shipped repair text; the + markdown-source note is the wrapper's measured one — the snippet may + have missed only because text is markup source. +- **Several matching blocks** → `ambiguous_input` listing ≤8 candidates, + each as `block (): "<~30 chars context>"` (the wrapper's + measured refusal shape: 54 tokens, repaired first-try), plus "… and N + more" past the cap. Candidates are FULL stored ids — the applier's view + is the canonical document, and a full id is always a valid retry value. +- **Several occurrences within the one matched block** → the existing + more-context refusal, verbatim, naming the RESOLVED block id (a valid + retry value): that multiplicity is `replace_all`'s (and later `nth`'s) + territory, not a resolution failure. Consequently `replace_all` composes + with the locator within one block and **never widens it across blocks** + — on a two-block match it still refuses. + +Only text-bearing blocks participate in the scan (code/embed included, +§8.4): replace_text can only edit those, so a block the op would refuse can +neither capture a match nor make a unique one ambiguous. Table-cell text +is not scanned — cells are not entries of the blocks array (set_cell's +territory), same as the wrapper's locate. + +**Mid-batch freshness is by construction, and pinned.** Resolution reads +`a.doc()` — the same live view id-suffix resolution uses, which +replace_text maintains in place (M7) and every rebuilding op invalidates — +so op *i* locates against op *i−1*'s edits on both view paths. Two tests +pin the two directions: an op-0 edit that CREATES op-1's only match (a +stale view would 404), and an op-0 edit that makes op-1's find ambiguous +(a stale view would resolve it uniquely — the silent wrong match; the +fresh view demands the refusal naming both blocks). Dry-run and apply +resolve identically at apply time on the same code path (C9 advisory). +Resolution adds ZERO renders — `TestApplierRenderCounts` pins a 50-op +locator batch at begin + final, so an id-less batch keeps M7's +O(document) product. + +**Schema and surface.** The served op schema drops `id` from required +(`find`/`replace` remain), documents the locator on the `id` and `find` +descriptions, and the served example is now the id-less form — the +cheapest correct loop is what the example teaches. The PATCH handler +description carries one sentence on it; v1's OpenAPI document is +byte-identical. + +**The wrapper dropped its double-read in the same pass.** `edit_text` +keeps its exact interface; with `block` absent it simply omits the op's +`id` (and passes no ref fields, so the ambiguity-rewrite retry — which +would have nothing to rewrite — never fires on a locator refusal). +`locateBlock`, `snippetContext` and their constants are gone; the +windowing tests moved to `locator_test.go` with the function. The server's +refusals reach the tool register through the existing two translators: +restVocab already rewrote the outline steer ("run read with mode=outline +…"), and opsVocab gained two rows ("retry with id naming" → "retry with +block naming", "or give the block id" → "or pass block") beside the +replace_all strip it already had. Net effect on the wire: the happy path +is ONE request instead of two, and a locate failure costs one PATCH-shaped +refusal instead of one GET — the refusal texts the §8.21 benchmark +measured survive nearly verbatim. + +**What §5 got right on contact, and the one deviation.** The §5.5 cost +estimate held (resolveByFind is ~50 lines + the context excerpt; no new +parser). The one place the built thing deviates from the wrapper it +mirrors: the wrapper's candidate list spelled served LABELS (its locate +read served the compact shape); the server lists canonical full ids, which +its view holds and which resolve exactly — the label spelling would have +required threading the serving layer's relabeler into the applier for a +cosmetic saving on an error path. + +### 8.44 Object DELETE under two lenses: the raw-name revision and six findings (2026-08-14 — decisions as built) + +The §8.42/§8.43 build went under a two-lens review before anything was +pushed; wire format and naming were therefore still free, and the round's +largest outcome spent that freedom. Everything below is on this branch, +each fix pinned by a test verified to fail under a simulated revert. + +**The design change (Roman's decision) — the stamp is the RAW app name, +compared exactly.** `pb.Change.integrationKey` (a normalized slug) became +`pb.Change.integrationName` (the session's `AppName`, verbatim; wire +number 10 unchanged, `ChangeNoSnapshot` in lockstep). Two executed +findings died at that root: **F2** — normalization was many-to-one +("Claude/Desktop", "CLAUDE DESKTOP", "Claude.Desktop" all collapsed to +`claude-desktop`, and a key paired under one visibly different name +archived another's output end-to-end, a strictly weaker consent story than +the conceded identical-name case) — and **F3** — normalization was lossy +("привет", "🙂", "!!!" all slugged to `""`, so a non-Latin-named key's +objects were permanently unprovenanced and undeletable by their own +creator, with no signal at pairing). The tolerance the slug bought +(re-pair as "claude desktop" still matched) was DELIBERATELY dropped: for +an authorization comparison, tolerance is a liability — it is precisely +what creates F2. `IntegrationKeyFromAppName` was deleted, not parked: the +future integration object hashes the raw name for its unique key (display +comes from the `name` detail, as for every object — the identity work's +shape), so the slug has no future caller and keeping it would be a rival +spelling authority. New with the revision: `AppName` had NO length bound +anywhere; issuance (CreateApp + the challenge flow's effective name) now +rejects — never truncates — names over `domain.MaxIntegrationNameLen` +(128 bytes). The §11.7 empty-name guard is now sufficient as issued: no +non-empty name can lose its record. Regression pins: the middleware +carries "Claude/Desktop"/"привет"/"🙂" raw; DELETE refuses the F2 pair +"Claude Desktop" vs "Claude/Desktop" naming both; both-sides-"привет" +archives. + +**F1 (high) — §8.42's derived-safety claim was false; fixed with a +positive allowlist.** Recorded in full as the Correction inside §8.42: +account-signed derived roots exist (personal-space derives; every +FileObject via `UseAccountSignature`), so the root clause never excluded +system objects — `DeleteObject` now refuses everything outside +`Page`/`Template`/`FileObject`/`ChatDerivedObject`/`DiscussionObject` +before the provenance read. + +**The one-line fixes.** **H1**: `translateOpsError` now rewrites +`Issues[i].Hint` (deRest always did) — the locator refusals put the repair +in the hint, so the "or give the block id"→"or pass block" opsVocab row +was dead code and a model following the raw hint emitted a schema-invalid +call. **H2**: `AccountLocalLinkNewChallenge` maps `application.ErrBadInput` +→ `BAD_INPUT` (extracted as `accountLocalLinkNewChallengeErrorCode` so the +row is testable); the nameless challenge answered UNKNOWN_ERROR while its +sibling CreateApp answered BAD_INPUT. **F4**: the archive-refusal match +now also catches `CanDeleteFile`'s "can't delete other's file" — a +permanent refusal that fell to a retry-shaped 500 two lines under the M2a +citation; and the dry-run CONTRACT is stated (§9.6): it verifies what the +route owns (existence, steer, allowlist, grant, provenance), archive-time +restriction checks run only on the real call. **F5**: archive success is +judged over the requested ids, not the GC cascade — a refused target +whose orphan file archived no longer yields a 200 receipt (fixed in +detailservice; v1 inherits). **F6**: `Apply` consumes the stamp at +capture — one stamped push per stamped state is structural, not an +InitObject-applies-once caller invariant. + +**Recorded, not fixed** (spec §7/§13/§17): `anonymize.Change` does not +anonymize `integrationName`, so the raw name reaches anonymized debug +exports (recommend covering it); proto3 string non-presence makes +"old client" and "non-API session" permanently indistinguishable on the +wire; F7 — dry_run + the naming refusal is a new API-queryable channel for +enumerating per-object creator integrations under a write grant; F8 — +type/property DELETEs archive on the write grant alone ("own output only" +is this route's property, not v2 deletion's — §17.1 stays open). + +### 8.45 Wave 2.1b — `match` as the id alternative on update_block and delete_block (2026-08-15 — decisions as built) + +The second locator slice (TOKENS §5.1's next two verdicts, plan 2.1b): +`match` — an exact substring of the block's text — addresses the block +instead of `id` on `update_block` (the checkbox-toggle case: `{"op": +"update_block", "match": "Draft timeline", "set": {"checked": true}}`) and +on `delete_block`, where §5.1 marks one-match-or-refuse as load-bearing +*because* the op is destructive. §5.3's rule is unchanged and was not +re-derived: exactly one block or refuse, resolved per-op against the +applier's live document view under the object lock. + +**One resolver, generalised — not a second one.** 2.1a's `resolveByFind` +became `resolveByText(doc, text, field, path, scope)` and serves all three +ops. Two parameters carry everything an op contributes: + +- **`field`** names the caller's own slot in the refusals ("copy the + **match** text exactly…", "add surrounding text to **match** until it + appears in one block only"). An `update_block` told to edit `find` is + told to edit a field it does not have — the repair has to speak the + vocabulary the caller wrote. +- **`scope`** is the candidate set: `replace_text` keeps its text-bearing + gate (§8.43 — a block it could not edit must neither capture its match + nor make a unique one ambiguous), while `update_block`/`delete_block` scan + **every** block, because they address any block. The two scopes coincide + today — the exporter writes `text` only on the types `TextBlockType` + covers — so this is a stated rule rather than an observable difference; + it is stated because the failure it prevents is asymmetric. An excluded + block cannot capture a match, but it also cannot make a wrong match + AMBIGUOUS, and on `delete_block` that is a silently deleted wrong + subtree. Narrowing is only ever safe where the excluded blocks could not + be the intent AND the op would refuse them anyway. + +**Decision 1 — `id` and `match` together are REFUSED, never ranked.** +`match` has exactly one job (addressing), so any precedence rule leaves +one of the two fields silently inert, which is the failure shape this +surface spent five review rounds removing; and reading the loser as a +content *precondition* instead ("update b5, but only if it still says X") +would invent a second meaning for `match` at the point of conflict, off +§5.2's three-field vocabulary. `replace_text` is not a counter-example: its +`find` is the text to splice first and the locator only when `id` is +absent, so there is nothing to rank there either. **Neither channel is +refused too**: an id-less `update_block` used to resolve the empty string +and report `block "" not found`, a 404 naming nothing. Both refusals reuse +the shipped vocabulary for alternative channels (`insertPayload`'s +blocks-or-markdown pair): `ambiguous_input` for both, `validation_failed` +for neither, addressed at `ops[i]`. + +**Decision 2 — `delete_block` + `recursive` under a locator.** The +descendant guard now names the **resolved** id (`block "blockParent1" has +1 descendant block — pass "recursive": true …`): a locator caller never +sent an id, and `block ""` is not a value it could retry with, while the +resolved full id always is. The receipt is unchanged — `match` + +`recursive` deletes the subtree and `diff_stats.blocks_removed` counts it +exactly as the id form does. The ambiguity refusal is usable for a +destructive retry by construction, since its candidates are full stored +ids; a test **replays one of the listed candidates** and asserts it +deletes that exact block, so "the list is usable" is pinned rather than +assumed. + +**Decision 3 — matching on text the op is about to change.** Resolution +runs **before** the op's own `set` — the only coherent order (the block +has to be found before it can be changed), and what makes both §5.1's +checkbox case and a match-then-rewrite rename expressible at all. Across a +batch the same rule reads forward: op *i* matches what op *i−1* **wrote**, +never what it overwrote. Both directions are pinned — an op-0 rename whose +new text op 1 matches (a stale view would 404 the batch), and the same +rename whose OLD text op 1 matches (a stale view would resolve it and edit +a block whose content the batch had already replaced). + +**Within-block multiplicity is not a refusal here — the one place §5 did +not transfer.** §5.3's third bullet ("several occurrences within the one +block → the existing more-context refusal") is written from +`replace_text`'s vantage and does not generalise: that refusal lives in +`applyReplaceText`, not in the resolver, and it exists only because +replace_text must splice ONE occurrence. `update_block`/`delete_block` act on +the block, which a twice-occurring snippet identifies perfectly well — +refusing would demand disambiguation of a question the op never asks (and +`nth`, 2.1c, is a document-order index over BLOCKS, so it would not even +be the escape). The served `match` description says so outright. + +**Mid-batch freshness, and the render bound.** Resolution reads `a.doc()` +— the same live view id-suffix resolution uses — so both view paths stay +correct, and it adds **zero renders**: a match-addressed `delete_block` +batch costs exactly what the id-addressed one costs (begin + one rebuild +per structural op + the final after-document), which `TestApplierRenderCounts` +now asserts by comparing the two. Dry run and apply resolve identically at +apply time (C9 advisory). + +**Schema and surface.** Both ops publish ONE shared `match` def +(`v2OpMatchPropDef`) and drop `id` from `required` — an op that accepts a +locator cannot go on requiring an id, or a schema-constrained decoder can +never write the locator form at all. The served examples are the locator +form (`update_block` is §5.1's checkbox case verbatim; `delete_block` keeps +`recursive` in the example, which is the other thing that op has to +teach). A new cross-check, `TestMatchLocatorIsPublishedExactlyWhereItWorks`, +probes every op through the real decoder and requires the schema half and +the runtime half to advertise the same set — §8.30's bug class, in both +directions, derived rather than listed. v1's OpenAPI document is +byte-identical. + +**The wrapper gains nothing here, and that is not a gap.** `check_item` +and `delete_block` are the tool-level equivalents, but neither has a +double-read to drop: unlike `edit_text` (whose `locateBlock` GET §8.43 +retired), both have always passed a REQUIRED `block` straight to the op's +`id`, so each is already ONE request. The only benefit left would be +making `block` optional with a text locator — a tool-surface change that +needs a second argument (reusing `block` for "an id or some text" is +exactly the silent precedence this slice just refused a layer down), on +two `TierLarge` tools, with no measurement behind it: §8.21's optional- +`block` win was measured for `edit_text` at the SMALL tier, and the +small-tier rerun (plan Q4) is still blocked on the Ollama host. The API +capability is there for every client the moment an eval shows it pays — +which is §5.5's point. Nothing regresses either: `block` is a required, +non-empty-checked wrapper argument, so the "give id or match" refusal is +unreachable from the wrapper and no `match` vocabulary can leak into a +tool that has no such argument. + +--- + +### 8.46 C2 re-stated: v2's own vocabulary is snake_case (2026-08-17 — decisions as built) + +**C2's old text was wrong, and had been for two waves.** It read "one +vocabulary: the format's — camelCase, no snake_case". Three facts +contradicted it: + +1. **Path and query params were always snake.** `{space_id}`, `dry_run`, + `has_more` — every route v2 ever registered. +2. **Wave 1.3 re-spelled every property key to a snake_case slug** + (ADDRESSING §7.5a-4, which says in as many words that it "amends C2's + letter"). The single largest name class on the surface stopped being + camelCase. +3. **The AnyBlock format is being swept to snake_case on its own branch.** + The half of C2 that pointed at the format as the source of camelCase + stopped pointing anywhere. + +What was left was ~30 DTO field names and the 14 PATCH op names: the last +camelCase in v2's own surface, following a convention nothing else did. +Human decision (Roman, 2026-08-17): **snake_case everywhere.** + +**What moved.** Two categories, both v2's OWN: + +- **The DTO field names** (`v2/model/*.go` json tags, 31 tag sites over 29 + distinct names): `author_id`, `blocks_added`/`_removed`/`_changed`/ + `_moved`, `blocks_text`, `created_at`, `created_blocks`, `created_views`, + `diff_stats`, `edited_at`, `expires_at`, `grammar_examples`, `key_status`, + `last_state_id`, `message_count`, `mime_type`, `next_after`, + `next_before`, `oldest_unread_order`, `oldest_unread_mention_order`, + `properties_changed`, `reacted_by`, `reply_to`, `space_id`, + `unread_mentions`, `unread_messages`, `unread_reaction_order`, `up_to`. +- **The 14 op names**: `set_properties`, `update_block`, `replace_subtree`, + `insert_blocks`, `move_block`, `delete_block`, `replace_text`, + `set_cell`, `update_view`, `insert_view`, `move_view`, `delete_view`, + `add_items`, `remove_items` — plus the two op FIELDS still in the old + spelling, `set_cell.table_id` and `insert_view.copy_from` (the same + vocabulary; `replace_text.replace_all` was already snake). + +This is a real contract change — op names are accepted on input and echoed +in errors — and it is free exactly once, before anything ships. + +**What did NOT move, and why.** + +- **The format's own names.** Anything inside `blocks`/`properties` is the + AnyBlock document, which v2 forwards verbatim; it is renamed on the + anyblock branch, not here. The **query surface's `mimeType`/`size` field + aliases** are the format's file-block field names too (object.go + `v2FieldAliases`), so they stay and follow that branch — which means + `POST /files` answers `mime_type` while `?fields=mimeType` still asks in + the format's spelling until the two branches meet. Recorded, not fixed + here. +- **Anything shared with v1.** `core/api/pagination` and `core/api/util` + are parsed into BOTH documents; no v2 model type embeds either (v2 has + its own `ListResponse`), and both already spell snake (`has_more`), so + nothing was touched and **v1's OpenAPI documents are byte-identical**. + `util/grant.go`'s `space::` app-link scope string is v1's + and stayed. +- **Analytics event ids** (`V2CreateObject` and siblings) — internal, and + one event stream shared with v1. + +**Carve-outs that disappear.** C9's `dry_run` footnote was a *recorded +carve-out* only because C2 said camelCase; it is now the plain rule. The +three `//nolint:tagliatelle` directives on `key_status`/`created_at`/ +`expires_at` are gone with it — `.golangci.yml` has always configured +`tagliatelle` as `json: snake` for `core/api`, so the linter wanted this +before anyone did. + +**The version lives in the path, not in names.** Stated in C2 now because +it is the same question one level up: `/v2` carries the version, so no +schema, operationId, op or field spells it. + +**Two guards were added**, because the rename's failure mode is a partial +sweep: + +- `v2model.TestJSONTagsAreSnakeCase` walks the DTO package's **own source** + (go/ast over every non-test file) and asserts every json tag is + snake_case. No hand-kept type list, so a DTO added later cannot drift in + by not being listed. +- `v2service.TestOpVocabularyIsSnakeCase` asserts every served op name is + snake_case **and** that each pre-rename camelCase spelling is now + REFUSED (`unknown op "setProperties"`), so a caller on the old vocabulary + fails loudly at its first op rather than half-working. + +**On the GBNF.** The brief's worry — a grammar still accepting strings the +server now rejects — does not arise: the wrapper's grammars +(`wrapper/gbnf.go`) constrain tool ARGUMENT objects, whose names were +already snake, and the one grammar v2 serves is the compact filter string's +EBNF, whose vocabulary is the format's (SPEC §6.2.1). No grammar has ever +carried an op name. `TestExamplesAcceptedByOwnGBNF` and +`TestServedOpExampleValidatesAgainstItsOwnSchema` both still pass, so every +served example remains an instance of the schema served beside it. diff --git a/core/api/APIV2_ADDRESSING.md b/core/api/APIV2_ADDRESSING.md new file mode 100644 index 0000000000..8a68b4897b --- /dev/null +++ b/core/api/APIV2_ADDRESSING.md @@ -0,0 +1,1327 @@ +# AnyBlock JSON — addressing: one identity story for five reference kinds + +Status: **design dossier** (research + recommendation, no normative force) · +2026-08-08 · GO-7383 · revised same day after three review rounds +(normative label minting, write-side defaults flipped, compatibility +constraint removed; the slugs-always surface adopted in §7.5a; the +internal-key strategy argued to (b) and then **flipped to (a) on +falsifying evidence** — the reversal is kept visible in §7.5 rather than +rewritten away). Companion to `SPEC.md` (v0.7); feeds a +SPEC revision and closes SPEC §15.3. Every claim about current behaviour is +verified against source at the cited line; prior art was researched against +vendor documentation (§6). + +**Verdict, up front.** Keep readable strings in every value slot — option +*names*, type/property *keys* — and generalize the §9a refs legend into a +per-kind **pin table**: an envelope map from label to internal identity, +with the §9a total resolution rule. **A label is an opaque map key**: it +must be unique within the document and nothing else; its readability is a +courtesy to the reader, not a mechanism, and resolution never parses one +(§7.1). Write paths flip to **strict-by-default wherever a stale read can +exist** (PATCH): an unknown option name is a loud 400 with did-you-mean +unless the request says `create: true`; an ambiguous name is always a 400; +and **only options may ever be created implicitly** — an option's name is +its entire definition, while properties, types and objects carry rich +content only an explicit create can supply (§7.4). +On the surface, one rule with no exceptions (§7.5a, adopted): **API +v2 addresses every type and property by the snake_case api-key slug** — +`dueDate` is `due_date` on the wire, and bundled, API-created and +UI-created keys are indistinguishable to a caller; the document format's +key vocabulary follows the surface (a deliberate cascade into SPEC §3). +On the internal-key question the decision **flipped on review evidence** +(§7.5): every new type and property mints a BSON internal key — v1's +identity layer — because derived readable keys converge concurrent +different-intent creates into silent format merges (no guard exists, and +none can exist above the derivation — §7.5-1) and make delete-then- +recreate a structural dead end (the derived tree persists — §7.5-2); +the caller's key lives only in the hardened slug, which §7.5a made the +entire visible surface anyway. +**Nothing here has shipped** — AnyBlock JSON and API v2 have no users, +no exported documents, no third-party consumers — so no default below was +chosen for compatibility; each is chosen because it is right, and this is +the cheapest moment there will ever be to choose it (§7.6). + +--- + +## 1. The problem + +AnyBlock JSON serves two masters: + +1. **Lossless round trip** — backup, export/import, migration + (`snapshotdiff`, `cmd/anyblockroundtrip`; production acceptance ≥ 99.86%, + `ANOMALIES.md`). Needs identity that survives renames and duplicates. +2. **API v2 and agents** — an LLM reads and writes the format. Needs + guessable, human-readable, token-cheap identifiers. A 59-char CID costs + ~24 tokens and models mutate opaque ids in flight (short handles ≈ −89% + id errors — `docs/AgentApiV2Research.md` §3.6). A 24-hex property key is + unusable by a small model. + +The collision happens at the identifier layer, because there are **four ways +to address most things** — internal id, internal key, api key (normalized +slug), display name — across **five reference kinds**: type keys, property +keys, select/status/tag option values, object references, block references. +Two of the five (objects, blocks) already have a settled answer; the other +three do not, and the defects are live. + +## 2. Ground truth today (verified) + +### 2.1 The matrix + +| Kind | Internal identity | Format (SPEC v0.7) | v1 API | v2 API | +|---|---|---|---|---| +| type | uniqueKey `ot-`; key = bundled word (`task`) or 24-hex BSON (UI-created) | key, `ot-` trimmed (`export.go:159-180`) | `apiObjectKey` slug, BSON fallback (`core/api/service/type.go:190-199`, `core/api/util/key.go:49-57`) | raw internal key; never reads `apiObjectKey` (grep: zero hits under `core/api/v2/`) | +| property | relation key: bundled camelCase (`dueDate`) or 24-hex BSON (`objectcreator/relation.go:47`) | stored key verbatim (§3) | `apiObjectKey` slug, BSON fallback (`property.go:553-562`, `key.go:38-45`) | raw relation key (`v2/service/discovery.go:276-284`) | +| option | option object id (derived from uniqueKey `opt-`; bare 24-hex in legacy data) | display **name**; silent raw-id fallback (`export.go:383-390`) | `apiObjectKey` slug + id (`tag.go:219`, `key.go:61-69`) | name only (`discovery.go:292-319`) | +| object | space-local CID (~59 chars) | full id, or refs-legend label (§9a) | id | id; compact refs legend by default (C4) | +| block | doc-local id (24-hex or author-chosen) | id, optional on input (§9) | n/a | full id on edit reads; 5-char suffix labels in outline; unique-suffix match on writes (`v2/service/object.go:404-429`) | + +Blocks and objects are the *solved* kinds and the template for the rest: + +- **Blocks** (APIV2.md C4, §7.1): full ids where round-tripping matters, + short suffix labels in read-only shapes, and `matchBlockRef` on every + write path — exact id first, else unique suffix, **ambiguity is a loud + 400** naming the remedy, zero matches a loud 404. +- **Objects** (SPEC §9a): the `refs` legend — an authoritative opaque map, + labels chosen by export (id suffixes) or by humans/agents (any label), + with a **total resolution rule**: in the map → that id; not in the map → + it *is* a full id. No shape heuristics. Lossless, because the legend + inverts. + +### 2.2 The two live defects (the unsolved kinds) + +**D1 — the silent id fallback.** `exporter.optionName` returns the raw +stored value when the resolver cannot map an option id to a name +(`export.go:383-390`): + +```go +func (e *exporter) optionName(key, id string) string { + if e.opts.ResolveOptions != nil { + if name, ok := e.opts.ResolveOptions.OptionName(domain.RelationKey(key), id); ok { + return name + } + } + return id +} +``` + +So the format **already emits a mix of names and ids in the same slot**, and +a consumer cannot tell which it is holding — there is no marker. On +re-import the id is a name like any other (`dataview.go:545-553`, no +id-lookup), and the API's create-missing resolver +(`v2/service/resolver.go:128-162`) then **mints a real option literally +named `6a7663db…`**. This is the sharpest symptom of the whole problem: a +lossless-looking pipeline that manufactures garbage vocabulary, silently. +It is not confined to file export — v2 API object reads wire the same +resolver (`v2/service/object.go:191`), and the view-op commit path +re-serializes whole dataview blocks through it (the `readOnlyOptionResolver` +comment in `resolver.go:325-331` names "a dangling option reference exported +as its raw id" as a live case). + +**D2 — duplicate names resolve by store order.** Duplicate option names are +legal (nothing in `objectcreator/relation_option.go` checks name uniqueness; +production data has them — `ANOMALIES.md` #6, 7 objects on `tag`). +`storeresolver.OptionId` returns the **first** option whose `Text` matches, +in `ListRelationOptions` order (`storeresolver.go:106-113`) — an order the +API contract nowhere defines. Resolution of a twin name is a coin flip; +round-tripping an object that referenced the losing twin silently re-points +it (the one accepted loss in the ≥ 99.86% figure). Scope matters here: +**options belong to one property** — every lookup is `(propertyKey, name)` +— so the same name on two *different* properties ("High" on `status`, +"High" on `priority`) is two unrelated options and entirely normal. The +defect is twins **within one property**; nothing in this dossier treats +cross-property name reuse as a duplicate. + +### 2.3 Findings beyond the known picture (worse than assumed) + +1. **`apiObjectKey` is not unique.** `injectApiObjectKey` + (`objectcreator/util.go:18-26`) derives the slug from the create-time + name with **no collision check**, and none of its callers + (`relation.go:44`, `object_type.go:34`, `relation_option.go:49`) check + either. Two properties named "Manual property" both get + `manual_property`. v1's tag cache then keys one map by id, uniqueKey + *and* slug (`cache_manager.go:114-116`) — the duplicate slug silently + overwrites: last write wins. **Pointing v2 at `apiObjectKey` as-is + imports the duplicate-name ambiguity from the option layer into the key + layer.** The in-flight v2 fix is a good interim; it is not the answer. +2. **`apiObjectKey` is frozen at birth *and* mutable by hand** — the worst + combination for an identity. It is derived from the name at creation and + never recomputed, so after a UI rename it matches neither the current + name nor anything an agent would guess; yet v1 lets any client re-point + it at any time (`type.go:288-298`, `property.go:282`, `tag.go:174`), so a + stored reference pinned to it can be silently re-aimed. As an *address* + (a git branch) that is fine; as *stored identity* (what a document + carries) it is unsafe on both axes. +3. **The slug layer is unaudited.** The option path has a second injection + branch that transliterates but forgets to snake_case + (`relation_option.go:51-53`), diverging from `injectApiObjectKey` one + call above it. Cosmetic, but evidence that nothing enforces slug + discipline. +4. **The BSON pass-through is the deliberate v1 policy**, not an accident: + `ToPropertyApiKey`/`ToTypeApiKey`/`ToTagApiKey` all detect a 24-hex key + and return it verbatim (`key.go:38-69`) — v1 documents the giving-up in + its own comments. +5. **SPEC §2a's format-conflict guard is unimplemented.** The SPEC + promises "a conflict with an existing property's format is an error at + the wiring level"; the wiring never checks: `creatingResolvers. + PropertyId` returns the existing relation on a key hit with the + declared format ignored (`resolver.go:357-363`), and `createRelation` + validates only that the format is present and a valid enum + (`relation.go:24-30`) — never against an existing same-key relation. +6. **A corpse has THREE store shapes, and `isDeleted` is what actually + hides two of them.** *(Corrected 2026-08-14 twice — the original text + described the flag-only shape as if it were the only one; the first + correction promoted the two-flag shape to "the persisted shape" as if + it were the only one; execution then found the third, and the third is + the one no fixture in the repo had — §8.41.)* + + `deleteDerivedObject` sets `isUninstalled=true` + (`core/block/delete.go:113-127`), and the **same Apply** stamps + `isDeleted=true` beside it: `injectDerivedDetails` writes `isDeleted` + whenever `isUninstalled` is present on the state + (`smartblock/detailsinject.go:219-226`). `BeforeDelete` then + **tombstones** the index row — `spaceindex.DeleteObject` strips it to + `{id, spaceId, isDeleted}`, no `relationKey`, no `resolvedLayout` — + and because the tree itself survives (§2.4-5) the next space load + re-indexes it with **full details and both flags**. Three shapes, + each real in a different place: + + - `{isUninstalled}` **flag-only** — never persisted by a live index + (`isDeleted` is a `source: local` relation, re-derived on every + load), but it IS the shape a **snapshot/export** of a corpse + carries, since export strips local relations. "The shape production + never has" is exactly the shape production *exports*. + - `{isUninstalled, isDeleted}` + full details — the **steady state** + after the next space load, and the *immediate* shape on any device + that received the delete **by sync**: the receiving index re-derives + the row from the tree and never passes through a tombstone. Which + shape a device sees in the delete's first session is + device-dependent; the data is the same. + - `{id, spaceId, isDeleted}` — the **tombstone**, on the deleting + device from the delete until its next space load: normally the rest + of the app session. Every key- or layout-filtered query misses it on + its first filter, defaults suppressed or not; the only handle is the + **id**, which for derived objects is computable from the key + (§2.4), and the only full description is the surviving tree. + + That matters for what is visible to whom. Every plain store query + injects `isDeleted != true` (`database.go:109-123`), so a corpse in + either `isDeleted`-bearing shape is **already invisible** to ordinary + queries — including the ones under `core/api/` that filter nothing + themselves. The explicit `isUninstalled` filters v2 added + (`livePropertyFilters`, `liveTypeFilters`) are belt-and-braces there: + they pin the corpse policy to the flag that *means* "the user deleted + this", instead of leaning on an injected default that any query + suppressing it — or any point lookup that never had it — loses. For + **relation options** the same statement was NOT true until §8.41: + `ListRelationOptions` filtered layout and `relationKey` only, so the + injected default was the **sole** defence for the one entity class the + corpse rounds never audited (the explicit `isUninstalled` exclusion is + now in). Note also `deleteRelationOptions` tombstones every option of + a deleted relation, so a corpse select/multiSelect has no resolvable + options and its values read back as raw ids. + + The residual channels that bypass the defaults are **five, not two**: + + - **Point lookups by id** read details directly (`GetRelationById` via + `GetDetails`), so a corpse referenced by a type's + `recommendedRelations` is still served in `typeProperties`, both + flags and all (§8.40) — but this is **not uniform across kinds**: + `GetObjectType` checks `isDeleted` explicitly and answers "type was + removed", so the type-side point lookup IS filtered where the + relation-side one is not. §8.40's "both shapes behaved identically + here" is right for relations and wrong for the type path. + - **Probes that suppress the defaults** (`Condition None` — the + round-trip tolerance, the removal sets) see full-detail corpses + again, and must suppress **both** `isArchived` and `isDeleted` or + they see only the flag-only shape (the §8.40 defect) — and even + then they never see the tombstone (the §8.41 defect; those probes + now fall back to the derived id). + - **`FetchRelationByKey`** is a **key**-addressed read that runs + `QueryRaw` with a hand-built filter — `QueryRaw` never calls + `NewFilters`, so no default is ever injected. Not a by-id lookup, + and not covered by any "point lookups" wording: a corpse resolves + here by its unique key in both full-detail shapes. + - **`QueryByIds`** injects nothing either, and feeds v1 subscription + dependency resolution, export and history — corpse rows ride into + all three. + - **The raw scans** — `HasIds`, `ListIds`, `IterateAll`, + `QueryIterateRaw` — enumerate documents with no filter layer at all. + + The original finding, corrected twice: "nothing filters it, so a + corpse remains fully visible" holds only for the flag-only shape. For + the `isDeleted`-bearing shapes, a plain query hides the corpse on the + injected default alone, and what remains visible is the five channels + above plus the derivation layer, where a same-key create meets the + surviving tree with no store query involved at all (§7.5-2). Any claim + about corpse visibility must name **which of the three shapes** it is + about — and any probe that claims to see corpses must say what it + does in the tombstone window, where the row it is looking for has no + fields to match. + +### 2.4 How unique keys derive object ids (verified — it decides §7.5) + +The review asked to confirm the parallel-create hazard behind BSON internal +keys. Confirmed, and sharper than folklore: + +- A derived object's change payload is a **pure function of (smartblock + type, internal key)** — `createChangePayload`, + `objectcache/payload.go:18-28`. +- In a **shared space**, the tree derives from (space_id, that payload) + with **no account key, no timestamp, no randomness** — + `derivePayload`/`DeriveTree`, `payload.go:30-38`, `tree.go:90-95`. So the + object id is a pure function of (space, kind, internal key): **any member + deriving the same unique key computes the same object id.** (The personal + space adds the account sign key — `payload.go:40-48` — but has one + account, so per-space determinism holds there too.) Ordinary objects, by + contrast, are created from a random 32-byte seed plus a timestamp + (`payload.go:50-58`) and can never collide. + +Four consequences: + +1. **Convergence is the install mechanism.** Bundled types/relations + (`rel-dueDate`, `ot-task`) install idempotently *because* every device + derives the same object. +2. **Concurrent same-key creates converge.** A second local create of an + existing key fails on put; two members creating the same key offline + each succeed locally and their trees **merge on sync into one object**, + conflicting details resolving in CRDT order — one name/format silently + wins. Same key ⇒ same object is a space-level invariant, not a race + outcome. +3. **This is exactly why UI creates mint BSON.** A name-derived readable + key would make two users' unrelated "Status" properties merge into one + object; the UI buys distinctness with opacity (`relation.go:46-47`; + options always `opt-` via `getUniqueKeyOrGenerate`, + `objectcreator/util.go:32-44`). +4. **v1 and v2 already embody the two candidate key strategies.** v1 + `POST /properties` never sets the relation key — the create mints a BSON + and the caller's key lands only in `apiObjectKey` + (`core/api/service/property.go:208-229` + `relation.go:46-47`): that is + strategy (a) live, twin slugs and all. v2's create-missing pins the + document's key as the stored relation key (`resolver.go:386-401`), and + v2 `POST /types` and `POST /properties` derive identity from the + caller's key (`schema_write.go:235-240`, `schema_write.go:455`): that + is strategy (b) live. §7.5 decides between them — for (a). +5. **Derived objects are never destroyed — deletion is a flag, and the + tree persists.** UI delete sets `isUninstalled=true` and keeps the + tree (`delete.go:113-127`); v2's DeleteProperty/DeleteType merely + archive (`ObjectSetIsArchived` — `schema_write.go:372, 536`); the + bundled reinstall path flips the flags back and **reuses the same + object with whatever content it accumulated** (`installer.go:210-232`). + Re-deriving a "deleted" key therefore cannot mint a fresh object: + `PutTree` on the surviving tree returns `ErrTreeExists`, which only + the installer tolerates (`installer.go:128`; `objectcache/tree.go:57-59` + propagates it everywhere else). Same key ⇒ same tree, forever — the + fact that decides §7.5. + +## 3. Scenario analysis + +What each consumer actually needs, and what breaks when the wrong identity +is picked. "Name" below means display name for options, readable slug for +types/properties. + +| Scenario | Identity that works | What breaks if you pick wrong | +|---|---|---| +| Full-account backup → restore (same space) | **id / stored key**. Everything resolves; renames since the backup are survived only by id. | Names: a rename between backup and restore mints a twin option and re-points values to it (silent); twin names collapse (D2). This is *the* case names cannot serve. | +| Export → import into another account | **name / self-contained key**. Foreign ids resolve to nothing — id-only documents are dead on arrival; create-missing (SPEC §3, §2a) rebuilds vocabulary from names/keys. | Ids: values dangle or, worse, collide with unrelated local ids. This is *the* case ids cannot serve. | +| API v2 read | name + key (C2), compact object refs (C4). | Raw BSON keys: unusable, unguessable, ~10 tokens each — v2's live state for UI-created properties. Raw ids in name slots: D1 reaches the API read surface. | +| API v2 write (agent authors JSON) | name + key, with loud resolution (§7.4). | Ids: agents mutate them in flight (−89% errors from short handles). Names alone with silent create-missing: the rename race (§3.1) and twin ambiguity (D2). | +| Small-model authoring (wrapper tier) | names only, zero opaque tokens, no legend in sight (APIV2.md §7.1: "the model never sees or emits a 24-hex id"). | Anything composite or tagged: a 3B model strips suffixes and mangles unions; both forms then need accepting forever. | +| Third-party integration / sync | **stable id + name**, both, per reference (the Kubernetes ownerReference shape). Sync must *detect* renames, not perceive a new entity. | Name-only: every rename is a delete+create to the integration. Id-only: cross-tenant sync impossible. | +| Diff / merge two documents | stable anchors (ids/keys); names as values. | Name-as-identity: a rename diffs as remove+add everywhere the value occurs; twins misalign the diff. | + +The pull is real and structural: backup wants ids, transfer wants names, +agents want names, sync wants both. **No single-vocabulary design serves +all rows.** The only designs that survive the table are those that carry a +readable value *and* an id, and they differ only in where the id lives +(inline, composite string, legend, sidecar, second profile). + +### 3.1 The rename problem, exactly + +A user renames option "High" → "Critical" (id `o1` unchanged). What happens +per design, for the two critical windows: + +| Design | Backup taken before rename, restored after | Agent read "High" yesterday, writes today | +|---|---|---| +| **Names only (today)** | Restore resolves "High" → nothing → **mints a twin option "High"**; restored objects point at the twin, live objects at "Critical". Silent divergence. | Write "High" → twin minted, silently (`created` side-effect is reported but nothing steers the agent to look). | +| **Ids only** | Restore resolves `o1` → correct. | Agent cannot plausibly author ids; DOA. | +| **Composite `High#6a76`** | Suffix resolves → correct; stale name half is cosmetic (Stack Overflow slug-URL semantics). | Only if the agent echoes the suffix — small models won't; bare "High" must stay legal → the race returns. | +| **Inline `{id,name}`** | id wins → correct (Notion semantics). | Same caveat: agents author bare strings; both forms legal forever. | +| **Pins + strict writes (recommended)** | Pin `"High": "o1"` resolves by id → correct; the label is cosmetic. | Document paths (create) carry pins → resolve by id. Bare ops (`set_properties`) hit the strict default: "High" no longer matches → **400 with did-you-mean ("Critical") and the `create:true` remedy** — loud, before damage (§7.4). | +| **Two profiles** | Backup profile (ids) → correct. | Agent profile is names-only → the race, unmitigated, plus a reader fork. | + +Property/type renames are the benign half **because the stored key never +changes on rename** — `name` is a detail, the relation key/uniqueKey is +immutable. The format's stored-key addressing is already rename-proof for +these two kinds; the problem there is purely that UI-created keys are +unreadable BSON. Only *options* conflate display name with identity in the +format today. + +## 4. The design space + +Each candidate: mechanism → what kills it (or why it survives). + +**A. Envelope mode selector** (`"addressing": "ids"|"names"|"both"`). +One flag, reader forks on it. Dies because neither pure mode serves even +one full scenario row (§3): backup-in-ids is cross-account-dead, +agent-in-names is rename-dead — so real use always picks `both`, and then +the selector is dead weight on top of whatever encodes "both". Also makes +every consumer conditional (two parsers in every tool). + +**B. Per-kind selectors** (types by key, options by id, … declared in the +envelope). Same death as A with more axes: the modes multiply +(4 kinds × 3 modes), documents stop being one language, and every reader +carries the product. Per-kind *rules* are right; per-kind *modes* are not. + +**C. Per-value inline tagging** (`"High"` vs `{"id": "o1", "name": "High"}`). +The Notion shape; unambiguous and rename-safe where the object form is +used. Dies on four counts: every value slot becomes a union (JSON-Schema +`oneOf` in exactly the schemas C13 promised to keep strict/flat — the +constrained-decoding floor the flat encoding was built for); token cost is +paid per value occurrence, not per distinct value; agents author the bare +form anyway so both live forever (two ways to say one thing, against the +format's own canon §4); and C2 loses "option names, everywhere" — the slot +no longer holds one vocabulary. + +**D. Composite string** (`"High#6a76"`). Readable, self-describing, +compact. Dies as a *value* encoding: needs escaping (`#` is legal in option +names — "C#" is a real tag), needs a parser in every consumer, pollutes +equality (a filter comparing the stored name against the composite), and +small models strip or mangle the suffix so bare names must stay legal — +returning the ambiguity it was built to kill. **Survives as label +cosmetics**: inside a legend, `High#6a76` is an opaque map key — no +escaping, no parser, no equality problem (§7.1). + +**E. Refs legend, generalized (pins)** — the §9a mechanism applied to +options, property keys, type keys. Values stay bare readable strings; an +envelope map pins each label to its internal identity; resolution is total +(in-map → pinned id/key; absent → the string is a name/key). Survives +everything: lossless when pinned (legend inverts, iCal-UID property), +readable always, rename-safe where pinned, cross-account portable (pins +strip cleanly; names/keys remain), duplicate-safe (twin names get distinct +labels), and the *absence* of a pin is itself information — the D1 +mix-of-names-and-ids becomes expressible and therefore fixable. Costs: an +envelope field, a SPEC section, and legend tokens on reads (zero in the +common case — see §7.4). This is the recommendation; full mechanism in §7. + +**F. Dual emission / sidecar id map.** A parallel `optionIds` map keyed by +value, or a separate sidecar file. The in-envelope variant *is* E keyed by +name instead of label — strictly less expressive (cannot represent twins). +The separate-file variant dies immediately: agents don't fetch sidecars, +files separate, and the atomicity of one-document-one-truth is the point of +the format. + +**G. Two serialization profiles** (backup profile: ids/keys everywhere; +agent profile: names). The honest-sounding answer, and protobuf (wire +numbers vs ProtoJSON names) proves the pattern ships. Dies on three counts. +(1) The backup profile still cannot be id-only, because backup-grade +artifacts are exactly what gets imported cross-account (use-case bundles, +space sharing) — so the "backup profile" needs names too, i.e. a both-form, +i.e. E or C anyway. (2) The protobuf lesson cuts the other way: protobuf's +*human* serialization is the rename-unsafe one — having two profiles does +not give the agent profile an id story, it just names the gap. (3) Every +tool in the middle (verifier, differ, importer) either forks or handles +both. What *is* right about G survives in E: pin-present vs pin-stripped +**are** the two profiles, produced by emission policy over one format with +one resolution rule — the shared core G never manages to state. + +**H. Stored slugs as identity** (address options by their `apiObjectKey`, +frozen at birth). Rename-stable and readable-ish. Dies because a +frozen-at-birth slug diverges from the display name after any rename — the +agent reads `high` while the UI says "Critical", the worst of both +readability worlds — and because today's slug layer is non-unique and +mutable (§2.3). Slugs are the right *API address* once hardened (§7.5); +they are not the right *document value* for options. + +**I. Kind-prefixed opaque ids** (Stripe `opt_…`, `rel_…`). Self-identifying +tokens would make the D1 mix at least detectable. Rejected as the design: +it hardens the id vocabulary the agent surface is trying to leave. Worth +stealing only as hygiene *if* ids ever get re-minted — not a near-term +lever, given ids are CIDs/derived and not ours to reshape. + +## 5. Scoring + +Criteria per the brief. `●` good · `◐` partial · `○` fails. "Failure mode" +is the tie-breaker: **a design that fails loudly beats one that fails +silently** — the lesson of D1. + +| Design | Unambiguous | Lossless | Rename-safe | Cross-account | Small-model guessable | Token cost | Impl/migration cost | Failure mode | +|---|---|---|---|---|---|---|---|---| +| Names only (today) | ○ (D1, D2) | ○ | ○ | ● | ● | ● | — | **silent** | +| Ids only | ● | ● | ● | ○ | ○ | ○ | low | loud but dead-end | +| A. mode selector | ◐ (per doc) | ◐ | ◐ | ◐ | ◐ | ◐ | med + reader fork | mixed | +| C. inline `{id,name}` | ● | ● | ● | ● | ○ (unions) | ○ (per occurrence) | high (schemas, C13) | loud | +| D. composite value | ◐ (echo-dependent) | ◐ | ◐ | ● | ○ (suffix loss) | ◐ | med (escaping, parser) | silent when suffix dropped | +| **E. pins + strict writes** | **●** | **●** | ● (docs; bare ops fail loud, §7.4) | **●** | **●** (authors never see them) | ◐→● (a legend line per non-bundled key and per distinct select value; zero on fully-bundled docs; `?pins=` opts down) | **med-high, additive** (incl. slug hardening + v2 create rework, §7.5) | **loud** | +| F. sidecar | ◐ (no twins) | ◐ | ◐ | ● | ● | ◐ | med | silent (sidecar lost) | +| G. profiles | ◐ (per profile) | ● / ○ | ● / ○ | ○ / ● | ○ / ● | split | high (everything ×2) | split | +| H. stored slugs | ○ today (§2.3) | ◐ | ● | ◐ | ◐ (stale slugs) | ● | med (hardening + backfill) | silent (shadowing) | + +## 6. Prior art + +What was actually researched (vendor docs, not summaries), what it +contributed, what was rejected. + +**Shaped the answer:** + +- **Notion** — the closest complete analog. Property values carry `{id, + name}`; *"id may be used in place of name"* on writes; the id *"remains + constant when the property name changes"*. Select options: **names are + unique case-insensitive by schema constraint** (Notion deleted the twin + problem rather than solving it), and writing an unknown name + **auto-creates the option, gated on write scope**. Contribution: the + id-primary/name-secondary duality works and users accept auto-vivify; but + Notion pays for it with unions in every value (design C's costs). We take + the semantics and move the id out of the slot. +- **Airtable** — `returnFieldsByFieldId` is a *read-shape* toggle (a mode + selector that works because it is per-request, not per-document); writes + accept name or id interchangeably; and **unknown select options are a hard + error unless `typecast=true`** — auto-creation is opt-in "to ensure data + integrity". Contribution: the strict/permissive switch belongs on the + *write request*, not in the document — directly §7.4's verb-bound default + plus `create` flag. +- **Kubernetes** — `metadata.name` is a reusable human handle; + `metadata.uid` is identity; `ownerReferences` store **both** and GC + honors the reference only when the uid matches, so a reused name can + never re-bind a reference. Contribution: the pin table is exactly + "store the uid next to the name"; a resolved pin whose target is gone + must *fail*, not re-bind to a newer namesake. +- **git** — abbreviated SHAs are shortest-*unique* prefixes, checked + against the actual object corpus, auto-lengthening as it grows, and an + ambiguous abbreviation is a **hard error, never a guess**. Contribution: + the label-minting rule (§7.1), the write-side ambiguity 400 (§7.4), and + the existing `matchBlockRef` behavior it validates. +- **JSON-LD `@context`** — a document-level legend (term → IRI) declared + once and applied everywhere is standardized, mainstream technology. + Contribution: precedent for pins as an envelope concept; nothing else + (it addresses vocabulary, not mutable user data). +- **Linear** (`ENG-123`) — the readable key is **frozen at assignment**, + never recomputed from the mutable title. Contribution: the `apiObjectKey` + hardening shape — mint once, never re-derive (§7.5). +- **Stack Overflow URLs** (`/questions/{id}/{slug}`) — id authoritative, + slug cosmetic, stale slug redirects. Contribution: the reading of pinned + labels — the name half may go stale; the pin resolves; nothing breaks. +- **iCalendar UID** (RFC 5545) — a mandatory persistent id exists purely so + re-import matches instead of duplicating. Contribution: the backup + scenario's requirement stated as a 25-year-old MUST. +- **HTML heading anchors** — the canonical *anti-pattern*: address derived + live from display text; edit the heading, silently break every inbound + link. This is precisely names-as-identity for options today, and the + reason H (live-derived slugs) is rejected. +- **Excel structured references** — rename-safe *names* are achievable only + inside a live single-writer app that intercepts the rename and rewrites + all references transactionally. Contribution: the explanation of *why* + this problem exists at all — anytype-heart's live state is Excel; a + serialized document is not, so the document must carry ids. + +**Rejected:** + +- **Discord snowflakes / render-time resolution** (ids only in content, + names resolved at render) — requires a live resolver at read time; a + backup file has none. +- **Protobuf field numbers** — validates two-profiles in the abstract, but + its own human profile (ProtoJSON, keyed by mutable names) is the + rename-unsafe one; nothing to borrow beyond the warning (see G). +- **MCP / tool-schema conventions, llms.txt** — surveyed and empty: no + published mechanism for token-cheap stable identifiers; the field + recapitulates id + display-name pairing. Confirms we must originate the + answer, not borrow it. +- **Slack channel id/name duality** — accepts both, but Slack itself warns + channel ids can change (Connect re-prefixing); a caution to state our + ids' immutability explicitly, otherwise inapplicable. +- **ENS/DNS** — name→address resolution with no document/reference duality; + wrong problem shape. +- **SQL surrogate-vs-natural keys literature** — decades without a general + winner; a meta-lesson (the answer is contextual, per-kind) rather than a + design. +- **CSV import header-matching** (Flatfile/Dromo-class fuzzy matching) — + not a design to adopt but the floor to remember: cross-account import + *is* name reconciliation; any design that drops names from the document + ends up rebuilding this machinery, badly. + +## 7. Recommendation + +**Adopt design E with strict writes: readable labels in every value slot, +one per-kind pin table in the envelope, the §9a total resolution rule, loud +failure everywhere a resolution can go wrong. Fold the existing `refs` +legend in as the object kind of the same concept. Keys follow strategy (a): +every new type and property mints a BSON internal key, the caller's key +lives in the hardened unique slug, and the slug is the only key any +surface speaks (§7.5, §7.5a).** + +One clarification the review demanded, stated once and relied on +throughout: **the pin-map key for each kind is that kind's existing C2 +vocabulary term** — display *names* for options, *keys* for properties and +types. That is not a new duality introduced by pins; it is C2's own +per-kind vocabulary ("property **keys**, option **names** — everywhere"), +with one resolution rule laid over all four kinds. + +### 7.1 Mechanism (normative sketch for the SPEC revision) + +**The tenet.** A label is an **opaque map key**. It must be non-empty and +unique within its namespace — nothing else. Its readability is a courtesy +to the reader, not a mechanism: resolution never parses a label, `#` has no +grammar, there is no suffix syntax to unescape. A string either +exact-matches a pin key, or it is a bare term of whatever the slot's C2 +vocabulary says — an option name in a value, a key in a key position, a +full id in an id position (§9a, unchanged). This sentence dissolves most +questions below: every "what if the name contains/looks like X" case is +answered by "nothing is ever inferred from a label's shape". + +Envelope gains one field, `pins`, placed where `refs` is today (legend +precedes use, §2): + +```json +"pins": { + "types": { "recipe": "6b21f0e3cda913b84c1299aa" }, + "properties": { "manual_property": "6a7663db61fab21cd4b9e745" }, + "options": { "status": { "High#4f2a": { "id": "bafy…4f2a", "name": "High" } } }, + "objects": { "roman": "bafyreidfmzjh…" } +} +``` + +- `pins.types` — label → internal type key. Covers `type`, `templateFor`, + the envelope `key` of a type document, `typeProperties[].objectTypes`. + Labels are the snake_case slugs (§7.5a). **Population (§7.5's (a) + decision):** canonical exports pin **every non-bundled key** — all new + keys are BSON, and a bare slug rides a mutable `apiObjectKey`, exactly + the silent re-aim class this dossier exists to kill; bundled keys + travel bare (the derived table resolves them) *except* a suffixed + bundled label (twin collision with a custom slug), which cannot resolve + through the table and carries a pin like any other. API reads may opt + down via `?pins=`. +- `pins.properties` — label → stored relation key. Covers `properties` map + keys, `typeProperties[].key`, dataview `properties[].key` / `groupBy` / + column `property` / sort and filter `property`, the `property` block's + `key`, link-block `properties` entries. Labels are slugs; population is + the same rule as `pins.types`: every non-bundled key pinned in + canonical exports, bundled bare unless suffixed, `?pins=` opt-down on + reads (§7.5a-5). +- `pins.options` — per property label: label → option entry. Covers + select/multiSelect values in `properties`, filter `value`s, sort + `customOrder` entries, and §2a vocabulary entries. +- `pins.objects` — the current `refs`, folded into the family (decided, + §8). Same charset, same §9a rules, string entries only. + +**Namespaces and charsets.** Labels are unique per namespace: one namespace +each for types, properties, objects; **one per property for options** — +mirroring the store, where every option lookup is scoped by its property. +"High" on `status` and "High" on `priority` live in unrelated namespaces: +neither is a twin, neither gets a suffix, and no rule below ever compares +labels across properties. Option, +property and type labels are arbitrary non-empty JSON strings of at most +64 Unicode code points (property/type labels SHOULD additionally be +identifier-shaped — letters, digits, `_` — so they remain typable in the +§6.2.1 filter-string grammar, which takes bare identifiers; a +non-identifier label is reachable only through the structured form, the +grammar's existing rule for colliding keys). Object labels keep §9a's +`[A-Za-z0-9_-]{1,64}` because they ride inside markup positions (mention +attributes, link destinations) that arbitrary strings would break. + +**Entry forms.** `pins.types`, `pins.properties`, `pins.objects`: always +`"label": ""` (strings — on a miss these kinds fall back to the +**label**, not a name, so the entry needs no payload; the normative miss +rules are below). `pins.options`: `"label": ""` when the label +equals the option's exact current name, otherwise +`"label": {"id": "", "name": ""}` — `name` present-but-empty +means the option's name *is* empty; `name` absent means unknown (a dangling +id). The object form exists only for options because options are the only +kind whose pin-miss fallback needs a *name* to resolve or create by — +property and type creation only ever proceeds from a definition +(`typeProperties`, a type document), which carries its own name and +format, so their pins stay bare strings. + +**Label minting (export) — normative.** A pure function of the namespace's +(base, identity) pairs; entities are processed in ascending internal- +identity order so suffixing cascades deterministically; **store order can +never influence a label** (order-dependence is what made D2 a coin flip). + +1. `base` := the entity's C2 term: an option's display name; a property's + or type's **slug** — for bundled keys the derived table entry + (`snake_case(key)`, §7.5a-1), for every non-bundled key the stored + `apiObjectKey` (which for API-created ones equals the caller's + snake-normalized key by mint — §7.5), and only for pre-backfill keys + with no stored slug `snake_case(transliterate(name))` derived at + export time and never written back; an object's name slugified into + the object charset. +2. A suffix is **required** when: `base` is empty; `base` exceeds 60 code + points (truncate to 60 first); or `base` is exactly equal + (case-sensitive) to another entity's base in the namespace — in which + case **every** holder of that base gets a suffix; none keeps the bare + name. Otherwise `label := base`. +3. `suffix` := the shortest trailing run of the entity's internal identity, + minimum 4 characters, such that `base + sep + suffix` collides with no + other label and no other base in the namespace; lengthen until unique + (the git rule). `sep` is `#` for options/properties/types and nothing + for objects with an empty base (a bare id-tail — exactly today's §9a + suffix labels), `-` otherwise. +4. Case-sensitivity is exact everywhere, matching the store's own + comparison (`storeresolver.go:108`): `high` and `High` are distinct + names, distinct bases, distinct labels — no suffixes. (Notion dedupes + options case-insensitively; Anytype does not, and the format follows + the store.) + +Per-case behaviour — mint on the left, what the resolver does on the right +(resolution is always the same exact-match lookup; only the minting and +the miss-handling differ): + +| Case | Minted label | Resolution | +|---|---|---| +| unique name `High` | `High` | map hit → id. | +| twins `High`, `High` | `High#4f2a`, `High#9c1e` — both suffixed | map hits → the right twin each. A *bare* `High` on a write is ambiguous → 400 listing both labels (§7.4). | +| empty name | `#77d0` | map hit → id. On pin-miss: the entry's `name` is `""`, which cannot create (`createRelationOption` rejects empty names) → identity kept verbatim + warning. | +| name contains `#` (`C#`) | `C#`, verbatim | map hit. Never parsed; a minted label that would collide with it lengthens its own suffix instead. | +| name shaped like an id | verbatim | map hit. A bare id-shaped string in a value is a *name* — no shape heuristics, and post-D1 export never emits a bare id there, so the case cannot arise from our own output. | +| names differing only by case | both bare, no suffixes | distinct map keys, distinct store names — nothing to disambiguate. | +| name > 60 code points | first 60 + `#suffix` (suffix mandatory) | map hit; the full name rides the entry's object form. | +| dangling id (D1) | `#4f2a`, entry `{"id": …}` with no `name` | map hit → id unresolvable → identity kept verbatim + warning. **Never an option created from an id.** | + +**Pin-miss semantics by kind (normative).** The table above is the option +rule; the flip to (a) makes the property/type miss a *hot path* — every +cross-account import of a canonical document misses on foreign BSON pins — +so it is specified, not left to the implementer: + +- **Options**: resolve-or-create by the entry's `name` under the §7.4 verb + rules; no name → identity verbatim + warning (the table above). +- **Properties and types**: a pin whose stored key resolves to nothing + **degrades to its label** and re-resolves through the §7.5a-5 chain, + with a warning naming the label and the lost key. It never writes the + pinned key verbatim — a BSON in a key slot is the D1 shape — and never + hard-errors on its own, which would break cross-account restore. + Whether the chain's step 4 then *creates* is the §7.4 kind axis: + creation proceeds only from a definition (`typeProperties`, a type + document — minting a fresh BSON with `apiObjectKey` = label, §7.5); + a bare reference follows the §8.1 policy (API: reject with + did-you-mean; package-level import: the label passes through as the + key for the wiring to reconcile, today's §3 degradation). Import + wiring therefore lands **definitions before references**, so a batch + resolves its own vocabulary — chain step 2 hits the slugs the batch's + definitions just minted. +- **Objects**: §9a unchanged — never created; unresolvable refs dangle + with a warning. + +**Writer rules** (a hand-authored or agent-edited document): a label is +only a label if it has a pin entry — a string without one is a bare term of +the slot's vocabulary, full stop. When authoring pins: any label within the +kind's charset and length; unique in its namespace; it MUST NOT equal a +bare unpinned term of the same kind and scope used elsewhere in the +document (the legend-wins rule would capture that term — validation +rejects the document naming both sites); use the object form whenever the +label is not the option's exact current name. + +**Worked example** (two same-named options, one unnamed option, one +unnamed object): + +```json +{ + "version": 1, + "type": "task", + "pins": { + "properties": { "priority": "6a7663db61fab21cd4b9e745" }, + "objects": { "roman": "bafyreidfmzjh…", "x7ke": "bafyreiuv…x7ke" }, + "options": { "status": { + "High#4f2a": { "id": "bafy…4f2a", "name": "High" }, + "High#9c1e": { "id": "bafy…9c1e", "name": "High" }, + "#77d0": { "id": "bafy…77d0", "name": "" } + } } + }, + "properties": { "name": "Q3 report", "priority": 2, + "status": ["High#4f2a"] }, + "blocks": [ { "type": "paragraph", + "text": "Review with Roman, cf. (untitled)" } ] +} +``` + +Same-space restore: every pin resolves by identity — the twins stay twins +(the `ANOMALIES.md` #6 loss class closes), the unnamed option survives. +Cross-account import: the option ids miss; `High#4f2a` and `High#9c1e` +fall back to their entries' `name` and **collapse into one created +"High"** (correct there — the twins are indistinguishable to a human in +the target space); `#77d0` has an empty name, cannot be created, and is +dropped with a warning naming it. The property pin: same-space, +`priority` resolves through its pin to the BSON relation; cross-account +the BSON misses and the pin **degrades to its label** — `priority` +re-enters the §7.5a-5 chain and resolves against the vocabulary the +batch's own definitions just created (a type document's `typeProperties` +entry minted a fresh BSON stamped `apiObjectKey: priority`), warning if +the batch carries no such definition; the BSON is never written as a +key. Object pins follow §9a (never created — `roman` resolves or dangles +with a warning). Every outcome is visible in `created`/`warnings`; +nothing happens silently. + +**The D1 fix on export:** `optionName` misses stop emitting the raw id as a +name. The value becomes a minted `#suffix` label pinned to the raw id, plus +a C11 warning on API reads. The mix of names and ids in one slot becomes +representable, so it stops being invisible. + +**Emission policies (this is the whole "profiles" story):** + +| Shape | Pins | +|---|---| +| Canonical export / backup (`Marshal` default) | **all** — every option value, every non-bundled key (plus any suffixed bundled label). Lossless; a backup restored after a rename re-points correctly. | +| API v2 default read | all (the whole-document round trip is a document path and inherits the protection; cost = one legend line per non-bundled key and per distinct select value — zero on fully-bundled documents); `?pins=min|none` opts down. **Superseded: APIV2.md §8.27 removed the document WRITE path (PUT), so v2 has no round trip left to protect on the default read — TOKENS §6 puts pins in the export shape only.** | +| Outline / prompt / example shapes | none (matches `OmitIds`+labels today). | +| Agent-authored documents | none — pins are `x-output-only` in the schema; authors write bare names and keys, exactly as now. | + +Pin-stripping (the deliberate portability lever) is defined, not ad hoc: +replace each pinned label with its entry's `name` where known, drop values +whose entries carry no name (warn), then delete `pins`. One format, one +resolution rule, two emission policies — G's honest core, without the fork. + +### 7.2 What each scenario gets + +- **Backup/restore:** lossless including twins, unnamed entities and + renames (pins invert). The last accepted round-trip loss class + (`ANOMALIES.md` #6) closes. +- **Cross-account:** unchanged mechanics (names/keys + create-missing on + import), now with an explicit dial: keep pins for fidelity, strip for + laundering. +- **API read:** one vocabulary, revised — option names unchanged, keys + re-spell to slugs (§7.5a-4 amends C2's letter; 153 bundled keys change + spelling); BSON keys disappear behind slug labels; D1 becomes a warning + instead of garbage. +- **API write:** document creates (POST) inherit pin protection; bare ops + get the §7.4 strict default. *(PUT was the other document write path; + it was removed — APIV2.md §8.27.)* The rename race on bare ops now fails loudly + *before* damage instead of minting twins. +- **Small models:** see nothing new on the read/author side; on writes, + a typo'd option name becomes a path-addressed 400 with candidates + instead of silent garbage — strictness *helps* the small tier (§7.4). +- **Integrations/sync:** every reference obtainable as (label, identity) — + the ownerReference shape — without per-value unions. +- **Diff/merge:** pins give the differ stable anchors; a rename diffs as + one pin-line change, not N value changes. + +### 7.3 SPEC.md changes + +1. §2 envelope: `refs` becomes `pins` with four kind maps (fold decided, + §8); canonical order and pruning rules. +2. §3: option values become **labels** under the resolution rule; the + silent fallback clause is deleted; duplicate-name collapse leaves §11's + normalization list (it becomes a fidelity guarantee instead). +3. §9a: rewritten as §9 "Identity and pins" — the tenet, the namespaces + and charsets, the minting algorithm, the entry forms, the writer rules + (§7.1 above); block labels unchanged. +4. §2a: vocabulary entries may be labels; "duplicate names are a validation + error" becomes duplicate *labels*; twin-named options become + expressible. +5. §6.2: filter values / customOrder reference the same rule. +6. §11: round-trip guarantee strengthened (pinned documents round-trip + twins, unnamed options and dangling option ids byte-stably). +7. §12: pin validation — charset/length per kind, label uniqueness per + namespace, the label-shadows-bare-term error, `x-output-only`. +8. §13: `Options` gains a `Pins: all|min|none` emission knob and the + strip operation; §15.3 closes with a pointer here. +9. §3 **key vocabulary flips to the slug** (§7.5a-5): the "camelCase + stored keys… so documents resolve offline" rationale is rewritten — + offline resolution now rides the bundled derived table plus pins for + every non-bundled (BSON) key, with the space slug lookup as the + in-account path (§7.5's (a) decision); the well-known properties + table re-spells (`icon_emoji`, `icon_image`, `due_date`); every + example in SPEC and FLAT follows. +10. APIV2.md ledger edits: **C2 revised** (snake_case keys + camelCase + envelope + recorded carve-outs, replacing "no snake_case" — §7.5a-4), + R9's op default (§7.4), C4's `refs` → `pins`; plus the mechanical + sweep — `schemas.go` examples, served EBNF examples, SKILL.md, §8.x + notes, eval-harness tasks. + +Package semantics stay resolver-driven (create-missing remains what the +wired resolver does — SPEC §3 is unchanged as *import* semantics); the API +chooses resolvers per verb, which is where §7.4 lives. + +### 7.4 Write-side resolution: strict where a stale read exists + +The review's sharpest point, accepted in full: **an absent pin on a write +was the one silent direction left in the design.** The original draft kept +R9's create-missing default and bolted on an opt-out; that reproduces D1's +failure shape (silent vocabulary invention) at the exact moment the agent +is most likely to be wrong — writing back something it read before a +rename. Two orthogonal axes replace it. + +**The kind axis — what may be created implicitly: options, and nothing +else.** The principle (the human's, and it is the right one): implicit +creation is legitimate only when the referencing string *is* the complete +definition. An option's content is its name (plus an assignable color) — +a bare name defines it fully, and every create is preceded by an +exists-by-name check within the property (`resolver.go:133`; it is what +makes §8.13's retries convergent). Properties carry a format and target +types; types carry layouts and recommended-property lists; objects are +whole documents — **a bare reference can never supply that content, so a +bare reference must never create one.** They are created only from +explicit definitions: `POST /properties`, a type document's +`typeProperties` entries (which are definitions — key, name, format — not +references), `POST /objects`. This gives §8.1's shipped +create-vs-reject table its principled justification, and it is why the +option column below has a permissive mode at all while the other kinds +never do. + +**The verb axis — when implicit option-creation is permitted.** Airtable's +`typecast` shows where the switch belongs: on the write request. We go one +step further and bind the *default* to the verb, because the risk boundary +is the stale-read window, and that window only exists when modifying state +previously read: + +| Kind | POST (create/import — nothing was read) | PATCH (read-modify-write; PUT was the other column until APIV2.md §8.27 removed it) | +|---|---|---| +| option names in values | **create-missing** (the §8.1/R9 behavior): bounded (`v2MaxCreatedOptionsPerPatch` = 64), validated-before-created, reported in `created`, previewed by dry runs — `guardCreateMissing`, APIV2.md §8.13, all unchanged | **strict**: unknown name → 400 `unknown_option`, did-you-mean over the property's labels, and the remedy named verbatim ("retry with create:true"); `create: true` restores create-missing under the same bound and ordering | +| option names in `typeProperties` | always creates — declaring a vocabulary IS the create statement | same (editing a declared vocabulary is explicit intent) | +| **ambiguous** bare option name (twins) | **400 always**, listing the minted labels — never resolved by store order, never a third twin, with or without `create:true`. Closes D2 on the write path; reads never resolve bare names (export emits labels) | same | +| property keys in object `properties` | reject + did-you-mean (§8.1, unchanged) | same | +| type keys | reject + did-you-mean (§8.1, unchanged) | same | +| object references | **never created from a reference — any path, any flag, ever** (normative; today this is true by accident, now it is a rule). An unresolvable ref is kept verbatim with a C11 warning — never a 400 (cross-space and not-yet-synced references are legitimate), never a create | same | + +Consequences, stated honestly: + +- **What it costs small models: one extra turn, rarely, and only on raw + REST.** The wrapper tier pays nothing: its planned pre-validation pass + (APIV2.md §7.4, the A2 guard) checks option names against the live list + before sending, and the wrapper sets `create: true` deliberately where + creation is the intent. A raw-REST small model writing a genuinely new + tag eats one 400 whose text names the exact remedy — the + generate→validate→repair loop is the format's own operating premise + (SPEC §12). In exchange the small tier gains real protection: today a + typo'd option name silently mints garbage; now it returns candidates. +- **Import and POST keep one-shot semantics.** The discovery examples + (`schemas.go`) — `"status": ["In progress"]` on a fresh space — still + work first try; cross-account import still rebuilds vocabulary. The + sets-create path (POST /sets) keeps R9's ahead-of-data option creation. +- **This supersedes R9's blanket default for ops** — an APIV2.md + decisions-ledger edit. It is possible precisely because nothing has + shipped; after GA it would be a breaking behavior change. +- The PATCH prewarm/lock machinery (`prewarmCreateMissing`, + `guardCreateMissing`) is unchanged in shape — it simply runs only when + `create: true` is present. + +### 7.5 The key strategy — argued to (b), then flipped to (a) on evidence + +History, kept visible: the first pass here weighed (a) — BSON internal +keys always, the caller's key living only in the slug (v1's live shape) — +against (b) — the caller's key as the internal key (v2's live shape) — and +chose (b), crediting it with retry-idempotency-via-convergence and a +cleaner namespace. A review then falsified both credits and produced two +facts the weighing had missed. Each is verified below; together they flip +the decision. The framing correction stands unchanged: the pin-map key +being a *slug* for properties/types while options use *names* is C2's own +per-kind vocabulary, not a new duality. And a second §7.5a consequence +now frames the weighing: once the surface is slug-only, the internal-key +choice is **invisible to callers**, and the resolution *mechanics* — the +§7.5a-5 chain, the label rules — are identical under either strategy. +What is **not** identical is pin population and backfill scope: (a) pins +every non-bundled key in canonical exports and needs backfill for every +pre-slug custom key, where (b) would have pinned and backfilled less — a +real cost, small and bounded, and the decision survives it. The deciding +axis is object-lifecycle semantics, where the evidence is one-sided. + +**What (b) actually costs — two data problems, verified in source:** + +1. **Silent format merge, with no guard and no guard possible.** The + "sequential format-conflict guard (§2a wiring error)" this section + previously leaned on **does not exist**: `creatingResolvers.PropertyId` + returns the existing relation on a key hit with the declared format + ignored (`resolver.go:357-363`), and `createRelation` validates only + that a format is present and a valid enum value (`relation.go:24-30`) — + never against an existing same-key relation (§2.3-5). Sequential + *explicit* creates are stopped by the existence guard + (`propertyKeyExists`, `schema_write.go:431`) — an existence check, not + a format check. And the concurrent case cannot be guarded at any + layer above the derivation: format is not part of the derivation + payload (`{SmartBlockType, InternalKey}` — `payload.go:18-26`), so two + members offline-creating `priority` as `select` and as `text` converge + into **one object whose format resolves in CRDT order**. The loser's + objects then hold select-shaped values on a text relation — silent + data corruption, not a naming inconvenience. + +2. **Delete-then-recreate is a structural dead end.** Derived objects are + never destroyed (§2.4-5): same key ⇒ same tree, forever. Traced + end-to-end, recreating a deleted key lands in one of three traps. + After a **v2 delete** (archive — `schema_write.go:536`), the existence + guard cannot see the corpse — every store query silently injects + `isArchived != true` (`database.go:109-123`) — so the create proceeds + to `PutTree` on the still-existing tree and dies on a raw + `ErrTreeExists` (propagated by `objectcache/tree.go:57-59`; only the + installer tolerates it, `installer.go:128`). After a **UI delete** + (uninstall), nothing filters `isUninstalled` (§2.3-6), so the guard + refuses "already exists" — steering the caller to PATCH an object the + user deleted. The only "success" shape is the reinstall path's + **resurrection with the old format, name and options** + (`installer.go:210-232`). Under (b) there is no API-layer fix, + because the caller's key *is* the derived tree. + +**The two credits (b) held, withdrawn on re-examination:** + +- *"Free retry idempotency via convergence"* — misattributed. §8.13's + "convergent on retry" comes from `OptionId` doing an exists-by-name + lookup **before** creating (`resolver.go:133`) — a lookup pattern, and + options are BSON-keyed (`opt-`, `objectcreator/util.go:32-44`), + never name-derived. Property-create retries are covered by C8's + `Idempotency-Key` and by the existence guard — both of which every + strategy needs anyway. Derived-key convergence contributes nothing at + the API layer. +- *"Bundled keys cannot retire, so (a) hides a mixed namespace"* — an + artifact of pre-§7.5a framing. Once the surface is slug-only, internal + keys appear nowhere; a namespace nobody can see cannot be "mixed". The + bundled table serves bundled slugs, the store serves the rest, and the + caller cannot tell — which is §7.5a's whole point. + +**What (a) costs, with the fix already specified:** twin slugs on +concurrent creates — a NAMING problem. The machinery is already in this +dossier: suffix-disambiguated minting (§7.1), the union collision check +at mint (§7.5a-6), ambiguity-loud lookups (400 listing candidates), and +the optional deterministic re-slug sweep (§8-OQ3). Two members +offline-creating `priority` yield two distinct, healthy relations whose +*slugs* collide; the collision is visible, loud, and repairable — no +value is ever reinterpreted. + +| | (b) caller key = internal key | (a) BSON identity + slug surface | +|---|---|---| +| concurrent same-key, different intent | one object; format merges in CRDT order — **silent corruption** | two objects; twin slugs — **loud ambiguity**, repairable | +| concurrent same-key, same intent | converge (nice, rarely load-bearing) | twin slugs; repair collapses or suffixes | +| delete, then recreate the key | `ErrTreeExists` raw error / "already exists" corpse / resurrection with old content | **clean create, fresh BSON**; slug policy names it | +| retry idempotency | C8 + existence guard (convergence adds ~nothing) | C8 + existence guard — identical | +| document / pin mechanics (§7.5a-5) | identical | identical — but (a) pins more (every non-bundled key) and backfills more; small, bounded | +| failure mode | **silent, data-level** | **loud, name-level** | + +**Decision: (a).** Every new type and property mints a BSON internal key +(options already do); the caller's key becomes the `apiObjectKey` slug, +snake-normalized at mint; internal keys never surface (§7.5a). By the +dossier's own tie-breaker — a design that fails loudly beats one that +fails silently — this is not close: (b)'s failures are silent and touch +data, (a)'s are loud and touch names. The irony is worth recording: +**v1 had the right identity layer all along** (`property.go:208-229`) and +lacked only slug discipline; v2's key-pinning creates — the former (b), +`resolver.go:386-401`, `schema_write.go:235-240, 455` — are what now +change. + +**What (a) requires that this dossier had not yet specified:** + +1. **The slug layer is identity-bearing for the API, and its integrity + is load-bearing, not hygiene**: union uniqueness at mint (stored + slugs + stored keys + bundled-derived slugs), ambiguity-loud lookup + everywhere (400 listing candidates), and the §8-OQ3 repair sweep as + the concurrency backstop. +2. **Archived and uninstalled objects vacate the slug namespace** — this + requirement assumes §8-OQ2's lean (vacate + re-slug-on-revive) and is + what an implementer builds unless that lean is overturned; overturning + it edits this item and nothing else. Delete-then-recreate is (a)'s + headline win, so the mint-time existence probe must deliberately skip + corpses — note today's guard has it exactly backwards (blind to + archived, blocked by uninstalled — §2.3-6/§7.5-2) — and reviving an + archived object whose slug has been re-taken re-slugs the *revived* + object with a suffix, loudly. +3. **v2 code changes** (the strategy-(b) remnants): stop writing + `RelationKeyRelationKey` from the caller's key + (`schema_write.go:455`), stop deriving type uniqueKeys from document + keys (`schema_write.go:235-240`), and `creatingResolvers.PropertyId` + mints a BSON and sets `apiObjectKey` from the document's key instead + of pinning it as the relation key (`resolver.go:386-401`). +4. **Implement the format check SPEC §2a promises** at the wiring: a + `typeProperties` entry whose declared format contradicts the resolved + relation errors, path-addressed (§2.3-5). Under (a) it covers the + remaining sequential-declaration case; the concurrent case no longer + exists, because keys no longer collide. +5. Backfill for existing objects (lazy on first API touch vs migration + sweep) — its own GO issue, and **a prerequisite for §7.5a's + slugs-always surface over old spaces** (a pre-`apiObjectKey` BSON + relation otherwise has no stable bare-op address; §7.5a-6). It + **gates build step 3's bare-op surface for old accounts** — §7.6 + states the ordering. Until it runs, the exporter derives labels at + export time (deterministic within a document, pinned, so documents + never depend on the store having a slug). +6. Re-pointing a slug stays possible — the §8-OQ1 lean (keep mutable, + address-only), assumed here; overturning it edits this item and + nothing else. It is v1 compat (v1 *is* shipped, unlike everything + else here) and document-safe by construction: documents pin to stored + keys, so re-aiming a slug can never re-aim a stored reference — the + slug is a branch, the key is the SHA. + +### 7.5a The surface rule: the slug is the only key the API speaks (adopted) + +Human decision, evaluated and **adopted with three modifications**: *API v2 +addresses types and properties by the api-key slug, always — one mechanism, +snake_case, no exceptions.* `dueDate` is `due_date` on the wire; bundled, +API-created and UI-created keys are indistinguishable to a caller — nobody +has to know which kind they hold. The rule is orthogonal to the +internal-key strategy — it holds under (a) and (b) alike, with identical +resolution mechanics and differing only in pin population and backfill +scope (§7.5) — which is what let §7.5 weigh lifecycle semantics as the +deciding axis; its (a) decision now says what sits beneath the surface. +Stated once, +for a reader to act on: **every place API v2 names a type or property — +path, body, filter string, sort, field list, document key slot — it speaks +the snake_case api key and nothing else; internal keys never appear on the +wire.** The six questions the review posed, answered: + +**1. The mapping is total and collision-free today — but derived, not +stored, for bundled keys.** Installed bundled relations and types *do* +receive `apiObjectKey` — installs route through +`createRelation`/`createObjectType` (`installer.go:109-134`), which call +`injectApiObjectKey` with the bundled key — **but** old spaces predate the +detail and `systemobjectreviser` does not backfill it (its revised-keys +list, `systemobjectreviser.go:30-43`, has no `ApiObjectKey`), so the +stored detail cannot be the authority for bundled keys. The authority is a +**fixed derived table in code, both directions, built from the bundle**. +Collision check, run against the real bundle with the real `strcase` +library: **194 bundled relation keys → 194 distinct snake slugs; 29 +bundled type keys → 29 distinct; zero collisions even under +case-and-separator folding.** No blocker. The same run proves string +inversion is not the reverse mechanism — `mediaArtistURL` → +`media_artist_url` → `ToLowerCamel` yields `mediaArtistUrl`, and +`_score`/`_final_score` do not round-trip — so the reverse is a table +lookup, never a case transform. Visible churn: 153 of 194 relation keys +and 5 of 29 type keys (`objectType`, `relationOption`, `spaceView`, +`diaryEntry`, `chatDerived`) change spelling on the wire. + +**2. Reverse lookup without a cache: one bounded query per request, never +one per reference.** `apiObjectKey` is an ordinary detail (hidden, +longtext, `source: details` — `bundle/relations.json:1725`) and is +queryable through the same details-query path every v2 listing already +uses (`ListProperties` filters on `resolvedLayout` identically, +`discovery.go:246-265`); there is no dedicated index, so per-reference +point queries would each pay a scan. The right shape is the per-request +resolver pattern the codebase already has: `storeresolver.loadRelations` +primes id↔key maps from one listing (`storeresolver.go:47-72`) — but +`model.Relation` carries no `apiObjectKey`, so the listing switches to a +details query returning `relationKey` + `apiObjectKey` + name + format, +building slug↔key maps both directions once per request, bounded by the +space's relation count (tens to low hundreds). ANOMALIES #9 already +mandates prime-from-listing with cached point-lookup fallback; this is the +same discipline with one more column. + +The query-per-request shape is **provisional, pending measurement — not a +principled stance**: a v2 subscription/cache will likely arrive for +efficiency eventually (the human's expectation, recorded). Constraints +when it does: **lazy and per-space, warmed after auth** — v1's +all-spaces pre-auth warm-up sits badly with scoped keys (§8.9/§8.10 space +grants); it must **fail toward a store query, never a stale answer** — a +stale slug→key map resolves a write against the wrong property, which is +precisely the silent-failure class this dossier exists to kill; and +types/properties are the cacheable layer (small, slow-changing, +invalidated by their own object events) while objects are not. + +**3. The forgiving layer is separator-insensitive and lives server-side.** +Under slugs the likely model miss is `dueDate` for `due_date` — a +separator difference, not a case one. Rule: exact match always wins; the +fallback folds (lowercase, strip `_` and `-`) and matches the folded key +set; **two keys folding together → 400 `ambiguous_input` naming both, +never a guess** (the git rule; the fold check over the bundle is clean +today, so a future collision fails loud instead of silently re-pointing). +Server-side, not wrapper-side: every tier benefits (raw REST, CLI, MCP), +it is one implementation instead of N, and C4 already blesses server-side +leniency for block suffixes. The wrapper's case-fold becomes a subset. + +**4. Scope: keys are snake; the envelope stays camel — deliberately.** +"Snake_case everywhere" means the *user vocabulary*: type keys and +property keys, wherever they appear. Envelope and DTO field names stay +camelCase per C2 (`space_id`, `etag`), with `dry_run` and `has_more` the +existing recorded carve-outs (§8.8, C10); enum *values* stay lowerCamel — +`objectType` the layout **value** coexists with `object_type` the type +**key**, and that is intended. v1 has always mixed snake keys into a camel +envelope without saying so; v2 writes it down. This **revises C2's +letter** — its cell currently reads "no id/key duality, no snake_case" — +while keeping its spirit (one vocabulary, no duality): a decisions-ledger +edit, free because nothing shipped. C2's original camelCase rested on +"the format's stored keys"; the BSON class made stored-keys-as-surface +untenable, and the uniform slug is the repair. + +**5. Documents follow the surface — pin population is §7.5's to decide.** +(The earlier headline here read "and pins shrink further" — superseded by +the (a) flip, which pins every non-bundled key in canonical exports.) C2 +("one vocabulary: the format's") cuts both ways: the API serves AnyBlock +documents, so key slots in the *format* carry slugs too — `"due_date"`, +`"icon_emoji"` — and SPEC §3's "camelCase stored keys" rule is overturned +(a deliberate cascade, §7.3; the biggest consequence the decision's +one-line form hides). A bare key term (a string with no pin entry — §7.1) +resolves through a four-step exact-lookup chain — no shape heuristics, +ambiguity at any step fails loud: (1) exact stored-key match (legacy +readable custom keys, the `artist` class); (2) space slug lookup over +`apiObjectKey` (every non-bundled key — under §7.5's (a) decision API- +and UI-created keys are BSON alike, and both carry a mint-time slug); +(3) the bundled derived table — offline-safe, since it ships in +`pkg/lib/bundle` with every reader; (4) miss → the §8.1 per-kind policy. +Population follows §7.1: canonical exports pin every non-bundled key +(the lossless internal identity) plus any suffixed bundled label; +bundled keys otherwise travel bare; API reads may pin-min via `?pins=`, +since chain step 2 resolves slugs in-account without pins. The +coordinator's portability reading is confirmed: slugs are *more* +portable than stored keys — meaningful in an account that never saw the +original; cross-account create-missing **mints a fresh BSON and stamps +the slug as its `apiObjectKey`** (§7.5), so the readable slug is what +crosses accounts while the BSON stays disposable plumbing — the +laundering conclusion survives with the mechanism corrected — and even a +pin-stripped document still restores in-account through chain step 2. + +**6. What gets worse — named honestly.** + +- **The cascade into the format is the real scope.** Every SPEC/FLAT + example, golden file, `filterstring` example and served EBNF example + (`due_date < currentWeek()`), `schemas.go` worked example, SKILL.md, + §8.x note and eval-harness task re-spells its keys. Mechanical, wide, + and only free right now. +- **Mint-time uniqueness must check a union.** A UI property named "Due + Date" slugs to `due_date` — colliding with bundled `dueDate`'s derived + slug. The §7.5 hardening check must test new slugs against stored + slugs, stored keys *and* bundled-derived slugs (v1's cache check + partially covers this; the heart-side mint checks nothing — §2.3-1, + sharpened). +- **Backfill is promoted from follow-up to prerequisite** for old spaces: + a pre-`apiObjectKey` custom BSON relation has no stored slug, and + deriving one from the *name* at read time is the HTML-anchor + anti-pattern (unstable under rename). Until backfill runs, such keys + resolve in documents via export-time pins but have no stable bare-op + address. +- **Debugging vocabulary diverges from the wire**: logs, store dumps and + CRDT changes say `dueDate`/BSON where the API says `due_date`. Real but + small — v1 callers live with exactly this today; the discovery listing + can carry both columns. +- **Snake-at-mint now normalizes the SLUG, not the internal key.** Under + §7.5's (a) decision the internal key is a fresh BSON; what v2 must + normalize to snake_case at mint is the `apiObjectKey` it stamps from + the caller's key (v1 already does — `type.go:225`, `property.go:218`), + on *create only* — resolution never rewrites anything. Derived-key + convergence for API creates is gone by design (§7.5); in its place, + `dueDate2` and `due_date2` normalize to one slug, so the sequential + second create is refused by the union uniqueness check and the + concurrent one becomes a twin slug caught loudly — the (a) failure + shape, names not data. + +**Verdict: adopt, with the three modifications** — the derived-table +authority for bundled keys (1), the server-side folding fallback with +loud collisions (3), and snake-at-mint on v2 creates plus the +union collision check (6). + +### 7.6 What remains to migrate, and the build order + +**Migration inventory — nothing user-facing exists.** No exported user +documents, no third-party consumers, no wrapper installs. The complete +list: the package's golden files and `testdata/` regenerate; the round-trip +corpus reruns under `cmd/anyblockroundtrip` (expect the anomaly-#6 class to +flip from accepted-loss to pass, and the acceptance bar to move to 100% on +that class); the §7.5a re-spelling sweep (SPEC/FLAT examples, `schemas.go`, +served EBNF examples, SKILL.md, §8.x notes, eval-harness tasks); APIV2.md +takes the ledger edits (C2's key casing, C4's `refs` → `pins`, R9's +op-default); SPEC.md takes §7.3. The one store-touching item is the §7.5 +requirement-5 backfill (`apiObjectKey` for pre-slug custom keys in old +accounts — the single place where "nothing shipped" does not apply, +because the *stores* exist even though the format's consumers do not). +That is all. Every choice in this dossier +gets an order of magnitude more expensive the day a third party ships a +consumer — **this is the moment to make the format right.** + +Build order (status marks 2026-08-08 — APIV2.md §8.22 is the as-built +record): + +1. **Kill D1** (small, self-contained): `optionName` miss → minted + `#suffix` label + pin + warning; import side: pinned-id verbatim + passthrough + warning. *[open — needs step 2's pins]* +2. **SPEC revision + package** (§7.3): pins, the minting algorithm, entry + forms, the label-shadowing validation; the slug key vocabulary (§9-item + in §7.3) with the bundled derived table (both directions) and the + §7.5a-5 resolution chain; regenerate goldens; rerun the corpus. + *[open — except the bundled derived table, which SHIPPED early in + `pkg/lib/bundle/apislug.go` (both directions, collision-verified, + fold layer included) because step 3's union check needs it]* +3. **The slug surface + the (a) identity layer** (§7.5/§7.5a): retire the + strategy-(b) remnants — stop writing `RelationKeyRelationKey` from the + caller's key (`schema_write.go:455`), stop deriving type uniqueKeys + from document keys (`schema_write.go:235-240`), mint BSON + slug in + `creatingResolvers.PropertyId` (`resolver.go:386-401`); snake-at-mint + for the slug **with the union collision check** (stored slugs + stored + keys + bundled-derived slugs — the check ships WITH the mint it + guards, never after it: a custom "Due Date" colliding with bundled + `due_date` must be impossible from the first minted slug) and + ambiguity-loud lookups; the per-request slug↔key resolver + (details-query listing); the server-side folding fallback with loud + collisions; the re-spelling sweep; the §2a format check the SPEC + already promises (§7.5 requirement 4). **Ordering:** for fresh spaces + this step is self-contained; over old spaces its *bare-op* surface is + gated on step 5's backfill (§7.5 requirement 5) — pre-slug custom + keys stay reachable through documents and pins in the interim, and + the gate is only about bare ops naming them. + *[SHIPPED except the re-spelling sweep: the mint + union check, the + input chain incl. fold, the §2a format check and the ambiguity-loud + lookups are live (APIV2.md §8.22), and a five-lens review pass + (§8.23) unified every mint and query channel onto the one chain — + union check fold-inclusive on all paths, document-body forgery + channel closed, search/list/set inputs canonicalized, guards + fail-closed; outputs serve the slug only where the stored key is + BSON and the slug round-trips — the full slugs-always output + (bundled keys re-spelling to snake on the wire, the SPEC §3 + vocabulary flip, schemas/goldens/SKILL/eval) is the remaining sweep + and needs its own change; view-op set channels stay stored-key-only + until then]* +4. **Write-side defaults** (§7.4): strict on PATCH, `create: true`, + the ambiguous-name 400; APIV2.md ledger edits; wrapper pre-validation + + explicit create intent. *[open]* +5. **Slug lifecycle** (§7.5): the corpse policy (archived/uninstalled + vacate the namespace; fix the inverted existence guard — §2.3-6, + §8-OQ2) and the backfill (lazy vs sweep — its own GO issue) that + un-gates step 3 for old accounts; the §8-OQ3 repair sweep if + telemetry warrants it. *[corpse policy SHIPPED (vacate + both live + defects fixed + loud ambiguity floor); open: the active + re-slug-on-revive half of the §8-OQ2 lean (no v2 revive endpoint + exists; the loud floor covers UI bin-restores), the backfill, the + §8-OQ3 sweep]* + +## 8. Open questions — and the ones the no-compatibility constraint closed + +**Closed** (decided in this revision; recorded so the closure is visible): + +- ~~Fold `refs` into `pins`?~~ **Folded.** The only argument against was + the shipped v2 C4 read shape — which has no consumers. One legend + concept, four kind maps. +- ~~Pin-all vs pin-min on API reads?~~ **Pin-all**, `?pins=min|none` + opt-down. The only argument for min was token caution; clean documents + pay ~nothing, and pin-all is what protects the PUT loop. *(Reopened by + APIV2.md §8.27: the PUT loop is gone, so the argument for pin-all on the + DEFAULT read is gone with it — export-shape-only, per TOKENS §6.)* +- ~~Legacy id-as-name rescue on import?~~ **Dropped.** It was + compatibility machinery for documents that were never produced outside + this repo; the D1 fix means they never will be. Dev artifacts + regenerate. +- ~~Options' own slugs?~~ **Decided:** option identity in documents is + name + pin; `apiObjectKey` on options stays a v1-only surface, v2 never + adopts it, and the hardening covers it only for v1's sake. +- ~~The (b) convergence acceptance?~~ **Mooted by §7.5's flip to (a):** + new creates never converge — each mints a fresh BSON; the residual + convergence is bundled installs, where it is the intended mechanism + (§2.4-1). The question dissolved rather than being answered. + +**Open** (need a human decision): + +1. **`apiObjectKey` mutability** — narrowed, not closed: v1 *is* shipped, + so freezing breaks released behavior; the slug is address-only, so + mutability is survivable — but §7.5a raises the stakes (the slug is + now the *entire* key surface: re-pointing one changes what every + subsequent request means, though never what stored documents mean, + since they pin stored keys), and under §7.5's (a) it is also the only + readable handle a property has. Freeze at mint (Linear) vs keep + re-pointing with the union uniqueness check. *Lean: keep mutable, + address-only, documented — revisit if telemetry shows re-pointing in + the wild. §7.5 requirement 6 assumes this lean; overturning it edits + that requirement and nothing else.* +2. **The corpse policy — archived/uninstalled objects and the slug + namespace** (§7.5 requirement 2): corpses vacate the namespace so + delete-then-recreate mints cleanly; reviving an archived object whose + slug was re-taken re-slugs the revived one with a suffix, loudly. + Alternatives: block the create and steer to unarchive (loses (a)'s + headline win), or refuse revival into a taken slug. Also covers + fixing today's inverted guard (blind to archived, blocked by + uninstalled — §2.3-6). *Lean: vacate + re-slug-on-revive. §7.5 + requirement 2 assumes this lean and is what gets built unless it is + overturned; overturning it edits that requirement and build step 5, + nothing else.* +3. **Twin-slug repair**: is the loud-ambiguity lookup error (the floor) + enough, or add a deterministic re-slug sweep (suffix the younger by + internal-key order — convergent, no coordination)? Elevated by the + (a) decision: twin slugs are now the *only* concurrency artifact + left. *Lean: floor first, sweep if telemetry shows real collisions.* +4. **Uniform strictness for integration scopes** — narrowed by §7.4: + PATCH is strict for everyone; the remaining question is whether + integration-scoped keys should make POST strict too (Airtable is + uniform-strict). Touches the API-key-scoping design, not this format. +5. **Identifier-shaped property/type labels: SHOULD or MUST?** Largely + settled by §7.5a — slugs are identifier-shaped by construction, and + every minted label is a slug (possibly `#`-suffixed for twins, which + the filter grammar cannot type — the structured form is the escape + hatch, the grammar's existing rule). Remaining question: MUST for + hand-authored labels too? *Lean: MUST for minted, SHOULD for + hand-authored.* diff --git a/core/api/APIV2_LAYOUT_PLAN.md b/core/api/APIV2_LAYOUT_PLAN.md new file mode 100644 index 0000000000..8f48e10449 --- /dev/null +++ b/core/api/APIV2_LAYOUT_PLAN.md @@ -0,0 +1,287 @@ +# API v2 — package split and two OpenAPI documents + +Status: **as built** · 2026-08-06 · GO-7383 · companion to `APIV2.md` (v0.4) and +`APIV2_SURFACES.md` (v0.2). + +Two changes, done together because they are entangled: move v2 into its own +package tree, and generate one OpenAPI document per API version instead of one +mixed document. The second is the reason the first is worth doing now — the +"clean documentation for agents and users" goal (`APIV2_SURFACES.md` §11) is +not reachable while a v2 reader has to scroll past v1 endpoints, and the +schema-name collision that blocks de-prefixing v2's models only disappears +once the documents are separate. + +## 0. Why now, and why not just tidy later + +**The invariant is the point, not the tidiness.** v2 already shares *nothing* +with v1 at the type level: `V2Service` is its own struct with its own +dependencies, and no `v2_*.go` service file references the v1 `*Service` or +its cached getters. That rule lives only in prose today — same package, so +nothing stops the next file from reaching across. A separate package makes it +a compile error. This is the pattern that has worked repeatedly on this +project (the route conformance test, the one-Go-table tool pin, the GBNF +acceptance suite): rules that are only written down decay; rules the build +enforces do not. + +**Scale justifies it.** v2 is ~10k LOC across 31 non-test files. +`core/api/service/` is 19 v2 files interleaved with 19 v1 files. + +**Before Phase 8, not after.** Phase 8 adds auth, file download, the chat +stream, the tag/template tails and the conformance test. Landing those in the +new layout costs nothing; moving them afterwards means doing the move twice. + +**Not while a workflow runs.** This touches nearly every v2 file. Any agent +holding a stale path will conflict. The tree must be clean and no workflow +in flight. + +## 1. Target layout + +``` +core/api/ + core/ apicore ports — SHARED, unchanged + util/ error helpers — SHARED, unchanged + pagination/ offset/limit plumbing — SHARED, unchanged + server/ gin engine, auth middleware, route registration — SHARED + docs/ + v1/ generated: docs.go, openapi.{json,yaml} + v2/ generated: docs.go, openapi.{json,yaml} + handler/ service/ model/ — v1, unchanged + v2/ + handler/ from core/api/handler/v2_*.go + service/ from core/api/service/v2_*.go + model/ from the V2* types in core/api/model/v2.go + doc.go the v2 swagger general-info block + router.go v2 route registration, called by server/ + wrapper/ the task-tool wrapper — already separate, unchanged + eval/ harness — unchanged +``` + +*As built, §9.1: this is the shape that shipped, except that `docs/v1` and +`docs/v2` carry no `docs.go` — see §9.3.* + +What deliberately stays shared: the apicore ports (both versions consume the +same middleware surface), `util`/`pagination` (no version semantics), and +`server` — one gin engine and one `ensureAuthenticated` serve both versions, +and splitting that would mean two auth paths, which is the opposite of the +goal. + +## 2. Step 1 — the mechanical move (one commit, zero behavior change) + +1. `git mv` the 31 non-test files + their tests into `core/api/v2/{handler, + service,model}`; drop the now-redundant `v2_` filename prefix + (`v2_search.go` → `search.go`). +2. Rename packages; fix imports. v2 packages: `v2handler`, `v2service`, + `v2model` (import aliases keep call sites readable). +3. Move v2 route registration out of `server/router.go` into + `core/api/v2/router.go`, exporting one `RegisterRoutes(engine, deps)` that + `server` calls. The shared middleware stack stays in `server`. +4. **Do not rename any exported type in this commit.** Type de-prefixing is + step 5; keeping it separate is what makes this diff reviewable as a pure + move. + +**Verification.** `go build ./...`, the full suites, gofmt, and +`git diff --stat -M` showing renames rather than delete+add. The existing +route-table test must pass untouched — it walks `engine.Routes()`, so it +proves the move did not change a single registered path. + +## 3. Step 2 — a general-info block per document + +v1 keeps the existing block in `core/api/service.go` (`@title Anytype API`, +`@version 2025-11-08`, `@host`, `@securitydefinitions.bearerauth`). + +v2 gets its own in `core/api/v2/doc.go` — same contact/license/host, its own +`@title` and `@version`, and a `@description` that says what v2 *is* (the +agent-oriented API, the C1-C13 conventions, where the discovery endpoints +live) rather than repeating v1's sentence. + +No `@BasePath` juggling is needed: all 44 v2 `@Router` annotations already +carry absolute `/v2/...` paths, and v1's carry `/v1/...`. + +## 4. Step 3 — two generated document sets + +`makefiles/tools.mk` grows a second invocation. Sketch, to be verified by an +actual run: + +```make +openapi: setup-swag + @deps/swag init --v3.1 -q -d core/api -g service.go \ + --exclude core/api/v2 --instanceName v1 -o $(OPENAPI_DOCS_DIR)/v1 + @deps/swag init --v3.1 -q -d core/api/v2,core/api/util,core/api/pagination \ + -g doc.go --instanceName v2 -o $(OPENAPI_DOCS_DIR)/v2 + # then the per-document prefix-strip pass (jq + sed), once per output dir +``` + +Notes that will bite if forgotten: + +- `-d` is comma-separated and **the general-info file must live in the first + directory** — hence `core/api/v2` first for the v2 doc. +- The strip rules (`apimodel.` → ``, `pagination.` → ``, `util.` → ``) are + per-document and must be extended with the new v2 model package name. + A missed rule leaves package prefixes in schema names — visible, not + silent. +- Shared packages get parsed into both documents. That is intended: each + document must stand alone. +- Fallback if the package split were ever abandoned: `swag --tags` filters by + tag, including negation (`!v2`). Directory exclusion is cleaner and is what + this plan uses. + +## 5. Step 4 — serve both documents + +`server/router.go` currently serves `/docs/openapi.yaml` and +`/docs/openapi.json` from the single generated package. + +- Add `/v1/docs/openapi.{yaml,json}` and `/v2/docs/openapi.{yaml,json}`. +- **Keep `/docs/*` serving v1 unchanged.** It is the path + developers.anytype.io and existing integrations use; silently repointing it + at v2 would break them, and repointing it at *nothing* is worse. +- Both `docs/v1` and `docs/v2` packages get blank-imported so their templates + register under their instance names. + +## 6. Step 5 — de-prefix the v2 model types (the payoff, and optional) + +Once the documents are separate, `V2SearchRequest` → `SearchRequest`, +`V2ObjectRow` → `ObjectRow`, and so on: the collision that forced the `V2` +prefixes only existed inside one shared components map. Mechanical rename, +own commit, and the v2 document's schema names come out clean — which is what +an agent or a human reads. + +This step is genuinely optional and can be deferred; steps 1-4 stand on their +own. But it is cheapest immediately after the move, before Phase 8 adds more +`V2*` names to rename later. + +## 7. Verification gate + +1. `go build ./...`, `go test ./core/api/... ./pkg/lib/anyblockjson/... + ./cmd/anytype/...`, gofmt clean, tree clean. +2. The route-table test passes unchanged (proves no path moved). +3. **`make openapi`, run by a human** — agents on this project are instructed + not to run it. Then the decisive check: **the regenerated v1 document must + be identical to today's, modulo the instance name and output path.** A + clean diff is the proof that splitting the packages did not alter v1's + published contract. Any real difference is a bug in the move, not a + cosmetic artifact, and should be treated that way. *(Corrected in §9.4: + today's document is the COMBINED one, so the check is that the v1 HALF is + unchanged — the commands are there.)* +4. The v2 document contains every registered `/v2` route. Worth pinning with + the same route-walking pattern the conformance test uses, so a new route + without annotations fails CI instead of quietly missing from the docs. + +## 8. Risks + +| Risk | Mitigation | +|---|---| +| Huge diff obscures a real change | Pure-move commit with no renames; verify with `-M` and the unchanged route test | +| v1 document silently changes | The byte-diff gate in §7.3 — this is the whole reason that gate exists | +| Strip rules missed for the new package | Visible in schema names on first generation; check both documents | +| Import cycles (`server` → `v2` → `server`) | v2 exposes `RegisterRoutes`; dependencies point one way, server → v2 | +| Concurrent agent work conflicts | Do not run during a workflow; tree clean before starting | + +## 9. As built (steps 1-5 landed) + +Five commits, one per step. What differs from the plan above, and why. + +### 9.1 The layout that shipped + +``` +core/api/ + core/ util/ pagination/ SHARED, unchanged + server/ one gin engine, auth, rate limit, analytics + handler/ service/ model/ v1, unchanged + v2/ package apiv2 router.go, middleware.go, doc.go + handler/ package v2handler + service/ package v2service + model/ package v2model + docs/v1/ docs/v2/ openapi.{json,yaml} — data only, no docs.go + wrapper/ eval/ unchanged +``` + +Package names are `v2handler` / `v2service` / `v2model`, matching the +`apicore` / `apimodel` precedent already in this tree (directory `core`, +package `apicore`). No import aliases are needed anywhere, and a +mis-import of v1's `service` reads visibly differently from v2's. + +### 9.2 The adapters stay in package `api` + +`objectreadadapter.go`, `objectcreateadapter.go`, `objectmutateadapter.go` +and `chatsubadapter.go` did **not** move. `chatsubadapter` is v1-only +(it feeds the /v1 chat stream), which settles that one; the three object +adapters are v2-only but still belong here: + +- package `api` is this tree's **composition root** — the only package + that touches `*app.App` and heart internals (`block/cache`, + `objectcreator`, `space`, `chatsubscription`, `fileobject`); +- what they produce are implementations of the **`apicore` ports**, and + `apicore` is explicitly shared by both versions — so an adapter sits on + the shared side of the line by construction, not the v2 side; +- keeping them out of `core/api/v2` is what keeps that tree free of + heart-internal imports. v2 is HTTP plus logic over ports, which is + exactly what lets every v2 package be tested against `mock_apicore`. + +The reasoning is repeated at the construction site in `service.go`. + +### 9.3 Three things §4's Makefile sketch got wrong + +Verified against swag v2.0.0-rc4's source and a `make -n openapi` +expansion (nothing generated — that is still a human's job): + +1. **`--instanceName` prefixes the output files too**, not just the + registered doc name: `gen.writeJSON`/`writeYAML`/`writeDoc` all do + `filename = InstanceName + "_" + filename`. The post-processing reads + `v1_swagger.json` / `v2_swagger.json`, not `swagger.json`. +2. **`--exclude` is not needed for the docs directories.** swag's walker + skips any directory literally named `docs`, so the generated + documents are never parsed back into themselves. `--exclude + core/api/v2` is still needed for the v1 run, and matches on the walk + path, so the repo-relative form is correct. +3. **`--outputTypes json,yaml` skips `docs.go`.** This is a deliberate + deviation: nothing in this binary ever read swag's global registry — + `/docs/openapi.*` serves `go:embed`ed bytes and `/swagger/*` is a + redirect — so the 302 KB generated `docs.go` was a second copy of the + document compiled into every binary, and two documents would have + made it two more. `core/api/docs` is now data only, and the dead + blank import in `server/router.go` is gone. + +### 9.4 §7.3's byte-diff gate, corrected + +The plan says "the regenerated v1 document must be identical to today's". +That is not right as written: today's document is the **combined** one, +and the v1 run now excludes `core/api/v2`, so the /v2 paths and the `V2*` +schemas legitimately disappear from it. The gate that actually means +something is *the v1 half is unchanged*: + +```sh +git show HEAD:core/api/docs/v1/openapi.json > /tmp/openapi-before.json # before make openapi +make openapi +jq -S '.paths |= with_entries(select(.key | startswith("/v2") | not)) + | .components.schemas |= with_entries(select(.key | startswith("V2") | not))' \ + /tmp/openapi-before.json > /tmp/v1-before.json +jq -S '.' core/api/docs/v1/openapi.json > /tmp/v1-after.json +diff /tmp/v1-before.json /tmp/v1-after.json # must be empty +``` + +Any real difference is a bug in the move, not a cosmetic artifact. + +### 9.5 What is still pending + +- **`make openapi` has not been run.** `core/api/docs/v2/openapi.{json,yaml}` + are committed **placeholders**: valid OpenAPI 3.1 documents with no + paths and an `info.description` that says so, present only because + `go:embed` needs the files to exist. `/v2/docs/openapi.*` answers 200 + with an empty spec until a human regenerates — visible, not silent. +- **§7.4's doc-coverage assertion** (every registered /v2 route appears + in the v2 document) is not written: it cannot pass against a + placeholder. It lands with Phase 8's conformance test, as sequenced. +- **The service and handler names still stutter**: `v2service.V2Service`, + `v2service.V2ChatMessagesQuery`, `v2handler.CreateObjectV2Handler`. + These are Go-internal names, not published schema names, so they were + left out of step 5's rename — but they are cheapest to fix now, before + Phase 8 adds more. + +## 10. Sequencing + +1. This plan (steps 1-5) — before Phase 8. **Done**; see §9, and §9.5 for + what is still pending (`make openapi` itself, and the §7.4 assertion). +2. Phase 8 — lands directly in `core/api/v2/`, and its conformance test gains + the doc-coverage assertion from §7.4. +3. The auth work (`ApiKeyScopingResearch.md`) is independent of the layout and + can proceed on its own schedule. diff --git a/core/api/APIV2_OBJECT_DELETE.md b/core/api/APIV2_OBJECT_DELETE.md new file mode 100644 index 0000000000..23e9a19ac1 --- /dev/null +++ b/core/api/APIV2_OBJECT_DELETE.md @@ -0,0 +1,869 @@ +# API v2 object DELETE (plan 3.3) — creator provenance and the delete surface + +Status: specification, not implemented. 2026-08-14, branch `go-7383-apiv2-phase0`. + +Covers plan item 3.3 (`DELETE /v2/spaces/{space_id}/objects/{object_id}`, specced +with Phase 1, never registered — APIV2.md §3, APIV2_PLAN.md Wave 3) together +with the requirement that makes it safe to ship: + +> Deletion is permitted only for objects the calling API key created. The +> identity of the creating key must be recorded immutably — the way +> `createdDate` is — not in a detail, because a detail can be overwritten by +> anyone with write access, which would let a caller forge provenance and +> delete objects it did not create. + +Everything below marked **traced** was established by reading the code paths +end to end on this branch (`7957f49ed`) and in `any-sync@v0.12.16` (the go.mod +pin); nothing here required executing probes — the claims are about data-flow +and wire formats, all statically checkable, and each carries its file:line. +The one deliberate scope rule, per direction received while this was written: +**lay the foundation shared with integration attribution, ship only what +DELETE needs, and do not build the integrations feature now.** §11 is the +minimal build; §12 is the proof that attribution can be added on top without +changing the on-wire format. + +Two decisions from Roman (2026-08-14) are folded in as settled: **legacy +objects are fail-closed, final** — no backfill, no grandfathering, deletion +applies only to objects created after this ships through the API (§8) — and +**v1 creations carry the stamp**, because the trace shows it is free (§8a: +both surfaces converge in `objectcreator` with the shared middleware's ctx +carriers already in hand). + +The self-test this spec applies to itself: *would adding integration +attribution later require changing the on-wire format?* Answer: **no** — the +field DELETE ships (§5: `integrationName`, the RAW app name — revised from +the attribution spec's normalized slug after the two-lens review; the +attribution DOC needs a matching one-line revision, the wire does not) is +the change-level identifier attribution consumes, and §12 lists what +attribution adds around it (all additive; its integration object derives +its unique key by hashing this value). If the §4 carrier were a hash-only +value or a root-change field, that answer would flip to yes, which is the +main reason not to. + +--- + +## 1. Prior art, and how this spec relates to each + +Three documents border this work. This spec **consumes the first, supersedes +one paragraph of the second, and is disjoint from the third**: + +1. **`docs/IntegrationAttribution.md`** (sibling worktree + `../anytype-heart_integration`, branch `integration-attribution`; spec + approved in discussion, unimplemented). It already defines the exact + primitive this feature needs: an optional per-change integration + identifier — it spelled the value as a normalized slug + (`integrationKey`); this spec, after the two-lens review, ships it as + the RAW app name (`integrationName`, §5) and that document owes a + one-line revision when attribution lands — stamped by heart into the + change payloads it owns (`pb.Change`, `pb.ChangeNoSnapshot`, + `pb.StoreChange`), under a self-asserted, member-signed trust model. + **This is the shared foundation. This spec does not invent a parallel + mechanism; it ships the change-level field and its stamping plumbing (the + subset DELETE needs) and adds the first *enforcement* consumer.** The + attribution features proper — the derived per-space integration object, + `createdVia`/`lastModifiedVia` relations, the ChatMessage field, icons, UI + — are explicitly **not built now** (§11/§12). + +2. **`docs/superpowers/specs/2026-08-06-api-key-scoping-design.md`** §3 + "Deletes — recorded direction": delete-own-only via a **device-local + `appHash` detail in objectstore**. That paragraph is superseded by this + spec; §4 option C records the comparison and why the synced, signed record + wins. Everything else in the scoping design stands, and most of its P0/P1 + machinery is already on this branch and is load-bearing here: the resolved + session (`ApiSessionEntry` with `AppName`/`KeyId`/`Scope`/`Grant`, + `core/api/server/middleware.go:131-139`), the ctx carriers + (`middleware.go:186-195`), the `/v2` grant gate and route registry + (`core/api/v2/authz.go`), and the conformance walk. Its open question — + whether schema deletes (`DELETE /types`, `/properties`) follow the same + own-output rule — **stays open** (§17). + +3. **`docs/DerivedDeleteConsistency.md`** plans changes to *derived-object + uninstall store semantics*. No interaction: DELETE /objects archives (§9) + — it never calls `deleteDerivedObject`, `BeforeDelete`, or + `spaceindex.DeleteObject`, and it refuses type/property ids with a steer + to their dedicated routes. If `?permanent=true` ever ships (reserved, + §9.7), it enters the real-deletion pipeline where that document's + findings (the §12 favorite-guard bug, GO-7433 tombstones) apply. + +## 2. How `createdDate` is actually immutable — the trace + +The requirement says "exactly the way `createdDate` does", so first establish +what that way is. `createdDate` is **not** a synced detail. It is a local +projection of the any-sync **root change** (the tree header), re-derived at +open: + +```go +// core/block/source/sourceimpl/source.go:371 +func (s *treeSource) GetCreationInfo() (creatorObjectId string, createdDate int64, err error) { + header := s.ObjectTree.UnmarshalledHeader() + createdDate = header.Timestamp + if header.Identity != nil { + creatorObjectId = domain.NewParticipantId(s.spaceID, header.Identity.Account()) + } + return +} +``` + +`injectCreationInfo` (`core/block/editor/smartblock/detailsinject.go:104-151`) +copies these into the `creator`/`createdDate` details at state build. Both +relations are `source: derived` in `pkg/lib/bundle/relations.json` — they live +in **local details**, never in the synced detail set, so no member can push an +overwrite of them through sync. The detail is a cache; the authority is the +header. + +The header itself is immutable for three separately-enforced reasons, all in +`any-sync@v0.12.16`: + +**(a) The object id is a hash of the root change.** +`changeBuilder.BuildRoot` +(`commonspace/object/tree/objecttree/changebuilder.go:154-193`) marshals + +```go +change := &treechangeproto.RootChange{ + AclHeadId: …, Timestamp: payload.Timestamp, Identity: identity, + ChangeType: …, ChangePayload: payload.ChangePayload, SpaceId: …, Seed: …, +} +``` + +signs it (`payload.PrivKey.Sign(marshalledChange)`), wraps payload+signature +in `RawTreeChange`, and the **object id is +`cidutil.NewCidFromBytes(marshalledRawChange)`**. Altering any byte of the +root change — timestamp, identity, payload — produces a different id, i.e. a +different object. `cidutil.VerifyCid` is re-checked on every unmarshal +(`changebuilder.go:95`). + +**(b) Every change is signature-verified against its declared identity.** +`changebuilder.go:119`: `ch.Identity.Verify(raw.Payload, raw.Signature)` → +`ErrIncorrectSignature`. Nobody can emit a change claiming an identity whose +key they do not hold. + +**(c) Every peer checks the identity's ACL write permission.** +`objecttreevalidator.go` `validateChange`: + +```go +perms, err = state.PermissionsAtRecord(c.AclHeadId, c.Identity) +… +if !perms.CanWrite() { err = list.ErrInsufficientPermissions; return } +``` + +run by `ValidateFullTree`/`ValidateNewChanges` on every device and node that +ingests the tree. + +Two more facts the design below leans on (both traced): + +- **Non-root changes share the immutability class.** `changeBuilder.Build` + signs and CID-addresses every change the same way + (`changebuilder.go:227-284`); the tree is an append-only DAG — a change can + be *followed*, never rewritten or removed from replicas. So "the first + content change of the tree" is exactly as tamper-proof as the root, with + one difference in *visibility*: the root change is stored and synced + **plaintext** (nodes must read `SpaceId`/`ChangeType`/`Identity` to route + and validate; `BuildRoot` involves no read key), while non-root change + payloads are **encrypted with the space read key** + (`changebuilder.go:250`, `payload.ReadKey.Encrypt(payload.Content)`) — + readable by space members only, opaque to infrastructure. +- **Derived trees have no signed root.** `BuildDerivedRoot` + (`changebuilder.go:196`) emits a root with no identity and no signature + (that is what makes ids convergent across members), and `validateChange` + skips `IsDerived` changes. Types, relations and relation options created + in heart are derived trees (`objectcreator/smartblock.go:143-149`); + **regular objects are created trees** (`smartblock.go:160` → + `CreateTreeObject` → `createPayload` with the account `SignKey` and a + random seed, `core/block/object/objectcache/payload.go:50-64`). For a + regular object the root header therefore always carries the creating + *account's* identity — this is the `creator` detail's source, and half of + the DELETE check comes for free from it. + +**The precise immutability statement**: a root-change field is unforgeable by +*everyone* (the id commits to it); a non-root change field is unforgeable by +*everyone except the signing identity itself* (b+c reject any other author), +and un-rewritable even by the author after the fact (append-only + CID). "The +signing identity itself" here means the user's own account key on the user's +own device — which is the party doing the enforcing, so self-forgery is +outside the threat model by construction (a local process that could forge +the stamp could equally call the archive RPC directly; see §6). + +## 3. The shared primitive (what both features actually need) + +From the attribution spec's own data model, the genuinely shared piece is +exactly one thing: **an identifier of the writing agent, carried on the +change payload, stamped server-side by heart from the authenticated session — +never accepted from the request body.** Both features need from it: + +- stamped only by heart, from the session (unforgeable via the API surface); +- signed into the member's change (unforgeable by other members); +- stable across key re-issue for the same integration (rotation, §8); +- resolvable to the calling credential for comparison. + +What only DELETE needs: the read-back at delete time (§10), the 403 surface +(§9), and the route. What only attribution needs: per-change stamping on +*every* labeled write (DELETE needs only the creating change), the derived +integration object + icon, `createdVia`/`lastModifiedVia` relations, the +ChatMessage/StoreChange fields, history surfacing, UI. Nothing in the second +list touches the wire representation of the first (§12). + +## 4. Where the record lives — the carriers compared + +| | (A) root `ChangePayload` (extend `model.ObjectChangePayload`) | (B) first content change (`pb.Change.integrationName`) — **recommended** | (C) device-local `appHash` detail (scoping design §3) | (D) synced detail | (E) per-key ACL identities | +|---|---|---|---|---|---| +| Immutable | yes — id commits to it (§2a) | yes — signed, CID-addressed, append-only (§2) | no CRDT record at all; objectstore row, wiped/rebuilt with the store | **no — any member with write access can overwrite; this is the forgery Roman's requirement excludes** | yes, cryptographically per key | +| Forgeable by other members | no | no (signature+ACL, §2b/c) | n/a (local) | yes | no | +| Survives store rebuild / reindex | yes | yes | **no — provenance lost, objects become undeletable by their own creator** | yes | yes | +| Survives account recovery on a new device | yes | yes | **no** | yes | yes | +| Visible to sync nodes (infrastructure) | **yes — root changes are plaintext (§2)** | no — encrypted with the space read key | no | no | partially (ACL is plaintext) | +| Visible to space members | yes | yes | no | yes | yes | +| Read cost at delete time | zero (`UnmarshalledHeader()` is in hand) | one history-tree read from local storage (§10) | one store lookup | one store lookup | zero | +| Covers derived trees (types/properties) if ever wanted | **no — derived roots are unsigned and their bytes determine the convergent id; adding a per-creator field would fork ids or be meaningless** | yes — their creating change is signed like any other | yes | yes | yes | +| Compatible with the attribution spec's field | no — different slot; attribution would still add the change field → two mechanisms | **identical — it IS the attribution field** | no — second mechanism | n/a | no — attribution explicitly non-goals ACL sub-keys (any-sync + coordinator work, new key-distribution UX) | +| Old clients | unknown field inside `ObjectChangePayload`, ignored; new-object ids simply computed over more bytes | unknown proto field, ignored; changes are never re-marshalled by other clients (append-only) | n/a | n/a | protocol change, migration | + +**Recommendation: (B).** It is the only carrier that is simultaneously +immutable, member-signed, store-rebuild-proof, invisible to infrastructure, +and *the same field the attribution spec already defined* — the reuse the +steer asks for costs literally nothing because the two features wanted the +same bytes. (A) deserves one more sentence, because the requirement names the +root change explicitly: the root header **already carries the account +identity and timestamp** — `creator` and `createdDate` derive from it, and +the DELETE check consumes that root identity as its first clause (§10). What +the root cannot carry cheaply is *which key*: the payload bytes are plaintext +to nodes (a per-integration identifier would leak to infrastructure — the +one place the attribution trust model does not disclose it), and the slot +cannot cover derived trees at all. So the account half of provenance stays in +the root change, exactly as today; the key half lives one change deeper, in +the same immutability class, behind the space's encryption. That satisfies +the requirement's substance — immutable, unforgeable-by-members, not a detail +— while deliberately not putting the new field in the literal root; this +paragraph is the flag, per the brief, that the literal reading was considered +and set aside for cause. + +(C) is not merely weaker, it fails the stated requirement twice: provenance +dies with the local store (fail-closed, but permanently — re-pairing cannot +repair it), and it can never serve attribution (nothing syncs), so shipping +it would guarantee a second mechanism later. (D) is the premise of the +requirement. (E) is the only *stronger* option — per-key cryptographic +identity — and is out of scope for the same reasons the attribution spec +non-goaled it; nothing in (B) blocks layering it later (a sub-key identity +would land in `Change.Identity` itself, orthogonal to the payload field). + +## 5. One representation for both: the raw name, compared exactly + +**REVISED after the two-lens review (2026-08-14; Roman's decision).** The +first build of this section chose a normalized slug of the app link's +`AppName` (lowercase, `[a-z0-9_-]`, collapsed, trimmed, capped at 64, +derived by a shared `IntegrationKeyFromAppName`). The review executed two +findings against it and both died at the same root, so the representation +changed: **the stamped and compared value is the raw `AppName`, verbatim, +and the field is named for what it holds — `pb.Change.integrationName` +(wire number 10, unchanged).** + +Why the slug fell: + +- **Normalization is many-to-one (F2, executed).** `"Claude/Desktop"`, + `"Claude:Desktop"`, `"CLAUDE DESKTOP"` and `"Claude.Desktop"` all + collapsed to `claude-desktop`, and a key paired as `"Claude/Desktop"` + archived an object created by `"Claude Desktop"` end-to-end. The consent + dialog shows the user two visibly different strings while the system + treats them as one principal — a strictly weaker consent story than §6's + conceded identical-name case. +- **Normalization is lossy the other way (F3, executed).** `"привет"`, + `"🙂"`, `"!!!"` all normalized to `""`, so a user pairing a + Cyrillic/CJK/emoji-named app got a key whose objects were permanently + unprovenanced and undeletable by their own creator, with no signal at + pairing time. + +**What the slug bought, and why giving it up is correct.** Normalization +bought *tolerance*: re-pairing as `"claude desktop"` still matched +`"Claude Desktop"`. For an **authorization** comparison, tolerance is a +liability — it is precisely what creates F2. Exact match is the point; +rotation continuity (§8) survives because re-pairing under the byte-same +name still matches, and the refusal message names the recorded name so the +repair is discoverable. + +The reasoning that still stands from the first version: the record's +unforgeability does not come from the value — it comes from the signature +and the ACL check on the change that carries it (§2) — and a hash adds +nothing DELETE can use. The one thing the slug was genuinely for — a +charset-safe unique key for attribution's future per-space integration +object — is served differently: **the integration object hashes the raw +name for its unique key** (it need not be human-readable; display comes +from the object's `name` detail, exactly as every other object works — +the same shape the identity work settled on: opaque internal key, readable +display separate). `IntegrationKeyFromAppName` therefore has no caller on +any path and was **deleted** rather than kept as a rival spelling +authority; attribution adds its hashing helper when it lands. + +Properties, decided deliberately: + +- **Granularity is the byte-exact app name, not the key instance.** Two + keys paired under the identical name are the same principal (§6 records + this as consent); keys under names differing in ANY byte — case included + — are different principals. `appHash` exact-key granularity stays + rejected for the reasons the first version gave (pseudonymous identifier + synced forever, every re-issue orphans the key's output). +- **The value is stamped by heart from the session, never read from the + request, and never rewritten.** No truncation, no case fold — a rewrite + anywhere recreates the many-to-one collapse. +- **Empty AppName ⇒ no stamp.** Unchanged — and with the raw name this + rule is *complete*: the §11.7 issuance guard rejecting an empty `AppName` + is now sufficient (under the slug there was a gap: a non-empty name could + still slug to empty; that class is gone by construction). +- **The name is bounded at issuance.** No bound existed anywhere on + `AppName`; the raw name now rides every creating change and appears in + debug exports, so `CreateApp` and the challenge flow reject names over + `domain.MaxIntegrationNameLen` (128 bytes — double the old slug cap) — + reject, never truncate. The stamp site deliberately does not re-check: + a legacy key minted before the bound keeps working, and the recorded + value always equals the session's name exactly. + +## 6. The unforgeability statement — and its honest limits + +The enforcement predicate (§10) is a conjunction, and each clause has a +distinct guarantor: + +1. **"This object was created by this account"** — root `Identity` (created + trees), guaranteed by CID + signature + ACL validation on every peer + (§2a-c). Another space member cannot produce an object whose root claims + this account, and cannot alter an existing root. **Unforgeable, + full stop.** +2. **"…via the integration named X"** — `integrationName` on the first + account-signed content change. Another member can stamp `"Linear"` in + *their own* changes (it is just a string), but their changes carry *their* + identity and fail clause 1. Within this account's own objects, only this + account's devices can have written the stamp. **Unforgeable by anyone + outside the account's trust domain.** + +What remains inside the trust domain, stated plainly rather than hidden: + +- **A local process with `Full`/gRPC access** can archive anything directly, + mint keys, or stamp arbitrary names. It always could; the attribution + spec's trust-model paragraph applies verbatim: this record is provenance + within the member's trust domain, and enforcement of what a *key* may do + is exactly what the scoping track + this rule provide against *scoped* + callers. +- **A user can be talked into pairing a malicious app named "Linear"** (the + challenge flow shows the name; approval is the user's). That app then + shares delete rights over linear-created objects. Same-user consent, same + device, recorded as accepted. +- **Legacy unscoped keys can archive anything via `/v1`** (`DeleteObject` → + `ObjectSetIsArchived`, `core/api/service/object.go:218-234`, grandfathered + by the migration stance). For legacy keys the v2 rule is therefore a + property of the v2 surface, not a global guarantee. For **scoped** keys it + is a real boundary: they are refused on `/v1` wholesale + (`v1_not_available_for_scoped_keys`), and on `/v2` the only archive + channels are this route (gated) and `set_properties` — where `isArchived` + is **output-only** and refused + (`core/api/v2/service/stateops.go:811-814`, pinned by + `TestIsOutputOnlyProperty`, `core/api/v2/model/model_test.go:113`). Traced: + no other v2 route reaches `ObjectSetIsArchived` for regular objects. +- **Enforcement is local.** No peer rejects a *delete* — peers reject forged + *changes*. The property shipped is: the record cannot be falsified by + anyone who could profit from falsifying it, and the enforcing heart reads + the record from cryptographically validated storage, never from a detail. + +## 7. Privacy — decided + +The raw app name syncs inside encrypted change payloads to **every space +member**, forever (version history). Space members can, with a modified +client, read which integration created each of this account's objects before +any attribution UI exists. Decision: **intended.** This is precisely the +disclosure the attribution feature exists to make ("attribution must be +visible to all space members, survive sync, persist in history" — its stated +goal), under the trust model already approved there; shipping the field +before the UI changes *when* members can see it, not *whether*. What this +spec deliberately does **not** disclose: nothing is visible to sync nodes +(the field rides encrypted payloads — the root-change option was rejected +partly for leaking to infrastructure, §4), and nothing names the *key* +(no hash, no id — a leaked name says "a thing called Linear", not which +credential). Objects created via API additionally already carry the synced +`origin: api` detail today (`core/api/objectcreateadapter.go:43`), so "this +object came from an API" is not new member-visible information — only +"which integration" is. The §5 revision widens this disclosure slightly: +the raw name can carry anything the user typed at pairing (an emoji, a +person's name, a non-Latin phrase) where the slug would have flattened it — +same class of disclosure, more faithful bytes; the 128-byte issuance bound +caps it. + +**Recorded gaps in this disclosure story (second review round; recorded, +not fixed):** + +- **`anonymize.Change` does not anonymize the field** + (`util/anonymize/anonymize.go` rewrites snapshot details/blocks/relations + and change content, nothing at the `pb.Change` top level) — so the value + appears in ANONYMIZED debug tree exports attached to bug reports. This + matters more now that it is a raw name rather than a slug: a name can + carry a person's name or free text. Recommendation (not implemented + here): anonymization should cover `integrationName` the way it covers + relation names — the field is provenance, not payload, but an anonymized + export's promise is "no user-authored strings". +- **F7 — a read channel through the refusal**: `?dry_run=true` plus the + §9.5 message (which names the recorded creator) lets any write-granted + key enumerate which integration created each object in a granted space, + without the attribution UI existing. Consistent with this section's + stance — the disclosure is to space members and this key is the user's + own — but it is a NEW channel (API-queryable, no modified client + needed), recorded as such. + +## 8. Rotation, re-issue, legacy objects + +- **Re-issue, same name** (revoke key, pair again as "Linear" — + byte-identical): same recorded name → old objects remain deletable by the + new key. This is the rotation story, and it is the decisive argument for + a name-derived value over any per-key value. Works across devices and + across account recovery, since the record is in the synced tree. + **Revised (§5):** the comparison is now EXACT — re-pairing as "linear" or + "Linear " no longer matches "Linear". That tolerance was deliberately + dropped: it was many-to-one (F2), and for an authorization comparison + tolerance is a liability. "Keep the app name byte-stable across re-pairs" + is the one sentence the key-management docs owe. +- **Re-issue, different name**: different recorded name → old objects are no + longer deletable by the new key, permanently (the record is immutable by + design — there is deliberately no re-point). The refusal message names the + recorded app name so the repair ("re-pair under the old name, exactly") is + discoverable. Accepted. +- **Legacy objects — DECIDED (Roman, 2026-08-14): fail-closed, final.** + Everything created before this ships — the vast majority of every account, + including today's v2-created eval fixtures — and **objects created by + other members, by the apps themselves, by import, or by any unstamped + path**: no record → **DELETE refused**, for every key including legacy + unscoped ones. Deletion applies only to objects created *after* this + ships, through the API (v2, and v1 per §8a). **No backfill, no + grandfathering scheme, no migration** — a backfill would be a detail-grade + assertion of exactly the kind the requirement bans, and none is wanted. + This is the design, not a cost awaiting mitigation. Its consequence bites + immediately and is accepted with it: the v2 eval fixtures already + accumulating in the test account remain undeletable through v2 — plan + 3.3's fixture-cleanup motivation is served **going forward only**, and the + two existing eval documents still need one manual archive. If anyone ever + wants an explicit "delete arbitrary objects" capability, that is a + **separate future product decision (§17.2), not a gap in this design**. + +## 8a. API v1 creations: stamped for free — decided + +Roman's rider on the fail-closed decision: *"only for new objects created +via apiv2 (same for apiv1 if it comes for free)"*. That is a question about +**recording provenance on v1-created objects**, not about restricting v1's +delete (which stays the grandfathered full-archive escape, §6/§8a-3). The +answer, traced rather than assumed: **it is free, so v1 creations are +stamped.** + +**8a-1. The trace — both surfaces converge upstream of the stamping point.** +The v1 create path is `CreateObjectHandler` → +`s.CreateObject(c.Request.Context(), …)` +(`core/api/handler/object.go:129`) → `s.mw.ObjectCreate(ctx, …)` +(`core/api/service/object.go:138`) → `Middleware.ObjectCreate(cctx, …)` → +`creator.CreateObjectUsingObjectUniqueTypeKey(cctx, …)` +(`core/create.go:17,36`; the bookmark variant likewise: +`creator.CreateObject(cctx, …)`, `core/create.go:131`). That is the **same +`objectcreator.Service`** the v2 adapter calls directly, entered with the +HTTP request context intact — upstream of §11.4's stamping point +(`CreateSmartBlockFromState` → `CreateTreeObject(ctx)`), which is therefore +version-agnostic. And the ctx carrier the stamp reads is installed by +`ensureAuthenticated`, which **both** groups share (`router.go:49` for v1, +the `Auth` dep for v2; the carriers at `middleware.go:186-195` are +installed unconditionally) — so §11.3's neutral integration-key carrier +rides v1 requests with **zero v1-specific code**. Nothing to build, nothing +to gate: the "free" case is the actual case. + +**8a-2. Legacy keys have a usable identity on that path.** `AppName` is a +persisted field of every app-link file (`core/wallet/applink.go:102,294`), +returned for app-key sessions by `CreateSession` +(`core/application/sessions.go:66`, `AppName: appLink.AppName`), cached in +`ApiSessionEntry.AppName`, and observable today on a legacy key via +`whoami` (a real legacy key shows `"name":"22"`, `key_status: legacy`). The +raw name is stamped verbatim, exactly as for v2 keys — stable per key, +however inelegant (`"22"` stays `"22"`; functional, and the same key +records the same name on both surfaces). The §5 empty-name rule applies +unchanged: no name → no stamp → that key's creations stay unprovenanced. +One recorded caveat, not affecting HTTP: sessions derived from a *token* +(`WalletCreateSession(token:)`) deliberately carry no app attributes +(`sessions.go`, the NOTE at the derive branch) — that is the gRPC-side +path, relevant to attribution's later gRPC labeling, never to v1/v2 HTTP +requests, which always authenticate by app key. + +**8a-3. The resulting asymmetry, stated rather than discovered.** v1's +DELETE stays unrestricted (§6): a legacy key can archive, via v1, objects +it did not create — including objects another key's stamp marks as that +other key's — while the very same key on v2 can delete only its own output. +Deliberate: v1 is the escape hatch and changes "not at all" (the migration +stance); grant presence, not surface, is the durable boundary, and scoped +keys never reach v1. Conversely the stamp composes across surfaces: it is +name-keyed and session-derived, so **the same underlying key used through +v1 and later through v2 records the same name, and v2 DELETE recognizes +its v1-created objects as its own** — v1 creations become deletable through +v2 from day one, which is most of what "free" buys. + +## 9. The DELETE surface + +**9.1 Semantics: archive.** `DELETE /v2/spaces/{space_id}/objects/{object_id}` +archives (Bin, reversible in the app) via `ObjectSetIsArchived` — v1 parity +(`core/api/service/object.go:224`) and v2 uniformity (`DeleteType`/ +`DeleteProperty`, `core/api/v2/service/schema_write.go:580,792`). Real +deletion stays out (`?permanent=true` remains reserved, §9.7). Archive being +reversible does not soften the requirement: mass-archival by an agent hides +data from every view and every other agent, and reversal is manual per +object. + +**9.2 Route + middleware**, exactly the established v2 DELETE stack +(`core/api/v2/router.go:324-329` pattern): + +```go +v2.DELETE("/spaces/:space_id/objects/:object_id", + deps.WriteRateLimit, + idempotencyMW, // C8 — uniform across every v2 DELETE + deps.AnalyticsEvent("V2DeleteObject"), + v2handler.DeleteObjectV2Handler(deps.Service), +) +``` + +plus the registry entry `routeKey(http.MethodDelete, +"/v2/spaces/:space_id/objects/:object_id"): {Verb: RouteVerbWrite}` in +`core/api/v2/authz.go` — the conformance walk fails the build without it, so +the route cannot ship unclassified. + +**9.3 Authorization is a conjunction, not an alternative.** For scoped keys +the existing gates run first and unchanged: space ∈ `grant.spaces` +(`space_not_granted`) and `perms == readwrite` (`write_not_granted`). The +creator check is **in addition to** the write grant — a readwrite grant +means "create and edit broadly, destroy only your own output", which is the +recorded direction of the scoping design and Airtable's loudest lesson. For +unscoped keys the creator check is the only gate beyond auth. + +**9.4 Service flow** (`V2Service.DeleteObject(ctx, space_id, objectId, +dryRun)`): + +1. `ensureSpace` (existing backstop; grant-aware). +2. Resolve the object row; absent or `isDeleted` → 404 `not_found`. +3. Layout steer: type/property/tag-option targets → 400 `validation_failed` + naming `DELETE /v2/spaces/{id}/types/{key}` (resp. `/properties`) — those + routes exist and carry their own semantics. +3a. **sbType allowlist (added by the second review round, F1 — + supersedes this step's earlier "let the restriction error handle system + objects").** User content only — `Page`, `Template`, `FileObject`, the + store-backed chat shapes — refuses everything else with 403 `forbidden` + BEFORE the provenance read. Load-bearing, not cosmetic: derived roots + are account-SIGNED on the personal-space derive path and for every + FileObject (`derivePersonalPayload`), so the root clause alone does not + exclude system objects, and a derived tree whose root exists locally + without a content change could even take its first content change from + an API request. Provenance answers "whose is it"; the allowlist answers + "is this deletable content". +4. **Provenance check** (§10). Mismatch → 403 `not_created_by_this_key`. +5. Already archived → 200 with `warnings: [{message: "already archived"}]` + (idempotent no-op, consistent with C8 retry semantics). +6. `ObjectSetIsArchived{IsArchived: true}`; RPC failure → 500 + `internal_error`. +7. Receipt: the uniform v2 delete shape (`v2model.CreateResult` as + `DeleteType` returns it): `{id, type, dry_run?, warnings?}`, HTTP 200. + +**9.5 The refusal** — C6, and the message must name the actual repair (the +four review rounds' standing rule). New code +`CodeNotCreatedByThisKey = "not_created_by_this_key"`, HTTP 403. Three +variants, each naming what IS recorded: + +- created by no key (apps/import/legacy): `DELETE is limited to objects this + API key created, and no API key is recorded as this object's creator + (created by the Anytype app or before provenance existed). To remove it, + archive it in the Anytype app.` +- created via a different integration: `…this object was created via + 'Linear', not via this key ('Claude Desktop'). App names are compared + exactly (§5). Use a key paired as 'Linear', or archive it in the app.` +- created by another space member: `…this object was created by another + space member. Ask them, or archive it in the app if your role permits.` + +Each carries `issues: [{path: "objectId", hint: "probe deletability without +writing via DELETE …?dry_run=true; the created_date/creator properties on +GET show who created the object"}]`, and the standard `WWW-Authenticate: +Bearer error="insufficient_scope"` channel stays untouched (this is not a +scope failure). + +**9.6 `?dry_run=true` (C9) is the deletability probe.** A dry run executes +steps 1–5 — including the provenance verdict — and skips only the archive: +allowed → 200 `{id, dry_run: true}`; refused → the same 403. This answers +"which of my objects can I clean up?" without a queryable index, which the +minimal build deliberately lacks: the query-side sugar (`createdVia` as a +filterable detail) is attribution's, is advisory-only by construction (a +detail — §2), and is not needed to ship (agents track their own created ids +from create receipts; C8 makes those receipts reliable under retry). + +**Contract boundary, decided (second review round, F4/H3-1):** the dry run +verifies what this route OWNS — existence, steer, allowlist, grant, +provenance. Archive-time restriction checks +(`restriction.CheckRestrictions`, `CanDeleteFile`) run inside the archive +RPC pipeline only and have no read-only surface, so the dry run does NOT +evaluate them: a "deletable" verdict can, rarely, still meet a 403 on the +real call. Accepted rather than built, because after the 3a allowlist +(which excludes every restriction-carrying system type) and the provenance +clause (own-account creations only) those refusals are all but +unreachable; the OpenAPI description states the boundary. + +**9.7 Reserved, unchanged:** `?permanent=true` (hard delete through the bin +pipeline) — when it comes, it consumes the same provenance under the same +rule and inherits the DerivedDeleteConsistency findings; not designed here. + +## 10. The enforcement read — algorithm and cost + +At delete time, read provenance from validated storage, **never from +details**: + +1. Build the tree from local storage the way version history does: + `spc.TreeBuilder().BuildHistoryTree(ctx, objectId, + objecttreebuilder.HistoryTreeOpts{Heads: current, Include: true})` + (`core/history/history.go:549` is the shipped pattern; the in-memory live + tree may be snapshot-reduced and MUST not be used — the creating change + can predate its base snapshot). +2. Root clause: `root := tree.UnmarshalledHeader()`; require + `root.Identity != nil && root.Identity.Account() == ownAccount` — the + §2-grade guarantee that this account created the object. (Derived trees + fail here by construction; they were steered away in §9.4-3.) +3. Key clause: iterate from the root in tree order; take the **first + non-root change**; require its `Identity` to equal the root identity, and + unmarshal its payload (`sourceimpl.UnmarshalChange`, decrypted by the + history tree exactly as `BuildState` does) as `pb.Change`; require + `change.IntegrationName != "" && change.IntegrationName == + ` — an EXACT byte comparison, no + normalization on either side (§5). The calling key's name is already in + the request context on this branch + (`util.CtxWithApiKeyInfo`, `core/api/server/middleware.go:188-194`). +4. Any clause failing → the §9.5 refusal, with the recorded name (or its + absence) folded into the message. + +Edge, recorded: between tree creation and the first content push, another +device of the *same account* could theoretically append the first non-root +change (the tree syncs from the root). Then step 3's identity still matches +but the stamp is absent → fail-closed refusal of a genuinely-owned object. +The window is the milliseconds between `PutTree` and the init `Apply` +(`objectcache/tree.go:56-67`), on an object the other device cannot yet +know exists; accepted as vanishingly rare and safe-direction. + +Cost: one full-history read of one tree from local SQLite — the same class +as opening that object's version history, a shipped user-facing operation; +on a DELETE endpoint (human-scale frequency, write-rate-limited) this is +acceptable without optimization. If it ever matters, the storage layer's +order index allows reading just root + first change; noted, not specced. + +**Retention dependency, flagged:** this read requires the creating change to +exist in storage. Today heart syncs and retains full tree history on every +device (version history is built on it — traced through +`BuildHistoryTree`/`buildState`; there is no pruning path). If history +truncation ever ships, creating-change provenance must be explicitly carried +forward (the way `OriginalCreatedTimestamp` rides snapshots — +`source.go:451` — is the precedent, though a snapshot-carried copy is only +detail-grade: §2 — a truncation feature would need to preserve the original +signed change, which any credible truncation of a CRDT with version history +likely must anyway). This is the design's one long-term structural +assumption. + +## 11. The minimal build for 3.3 (steer question 3) + +Everything DELETE needs, nothing attribution-specific. Each item is +independently reviewable; together they are one shippable PR train: + +1. **Proto** (`pb/protos/changes.proto` + regen): `string integrationName = + 10;` on `Change` **and** `ChangeNoSnapshot` (§5 revision: named for the + raw value it holds; wire number 10 unchanged) — the two messages share + wire numbers by design (content 3, fileKeys 6, timestamp 7, version 8, + changeType 9; 1/2/5 are historical — do not reuse), and the read-side + conversion at `sourceimpl/source.go:114-121` must copy the new field. + `StoreChange` (chat) gets nothing now; its field is additive whenever + attribution lands. +2. ~~**Slug derivation**~~ **Gone with the §5 revision.** There is no + derivation: the stamped value IS the session's `AppName`. + `IntegrationKeyFromAppName` was built, then deleted with the revision — + attribution's unique-key derivation will be a hash of the raw name, + added when attribution lands. What remains in `core/domain` + (`integrationname.go`): the ctx carrier (item 3) and the + `MaxIntegrationNameLen` issuance bound (§5). +3. **Neutral ctx carrier**: a `domain`-level (not `core/api/util`-level) + `CtxWithIntegrationName`/`IntegrationNameFromCtx`, installed by the API + auth middleware next to the existing carriers (`middleware.go:186-195`), + carrying the session `AppName` verbatim. `ensureAuthenticated` serves + **both** route groups, so v1 creations are stamped by the same lines — + the §8a decision costs nothing here. Attribution's future gRPC + interceptor installs the same carrier — that is the shared plumbing + seam, and it is one function each side. +4. **Stamping**: `source.PushChangeParams` gains `IntegrationName string` + (`core/block/source/interface.go:96`); `treeSource.buildChange` + (`sourceimpl/source.go:434`) copies it onto the `pb.Change`. Fill site + for the minimal slice: the **creation path only** — `objectcreator` + has the request ctx in hand end-to-end + (`CreateSmartBlockFromState` → `CreateTreeObject(ctx…)` → + `InitContext.Ctx`, `core/block/editor/smartblock/smartblock.go:212-225`), + so the creating Apply can carry the value into `PushChangeParams`. This + point is downstream of where v1 and v2 creation converge (§8a-1), so the + fill site is version-agnostic by construction. + Implementation caution, pinned by test: the value must be **per-apply**, + never persisted on the state object — a later UI edit on this device + must NOT inherit the stamp (assert: second Apply's change carries no + key). Widening to every labeled change (attribution's `lastModifiedVia` + feed) is the same param filled from the session/state ctx later — more + fill sites, zero wire change. +5. **Provenance read port**: a small `apicore` port (implemented beside the + existing adapters in package `api`, which already owns the heart-internal + composition) exposing `CreatorProvenance(ctx, space_id, objectId) + (accountMatch bool, integrationName string, err error)` per §10. +6. **Surface**: `V2Service.DeleteObject` (§9.4), handler, route + authz + registry entry + `V2DeleteObject` analytics id, the + `not_created_by_this_key` code + constructors in `v2model`, OpenAPI + annotations, `make openapi`. +7. **Guards**: `CreateApp`/challenge reject an empty `AppName` — with the + §5 revision this is sufficient (no non-empty name can lose its record) — + and one over `MaxIntegrationNameLen` (the bound, §5); no other issuance + change. +8. **Docs**: APIV2.md §3 build-item closure note; SKILL.md gets the + delete verb + the dry-run probe idiom; scoping design §3 paragraph gets a + superseded-by pointer. The plan-3.3 row and the APIV2.md note must say + plainly that **DELETE does not clean up existing objects**: it applies + only to objects created after it ships (the §8 decision), so the eval + fixtures already in the test account still need one manual archive, and + any future "delete arbitrary objects" capability is a separate product + decision (§17.2), not part of this route. + +Explicitly **not built** (attribution's, later): `SmartBlockTypeIntegration` ++ derived object + icon pipeline, `createdVia`/`lastModifiedVia` relations, +`StoreChange`/`ChatMessage` fields, history surfacing, any UI, gRPC session +labeling, restrictions registration. + +## 12. The extension test — attribution later, wire diff: zero + +Claim to check: adding integration attribution on top of §11 changes no +shipped wire format. The attribution spec's needs, item by item: + +| Attribution piece | What it consumes/adds | Wire change to §11's format? | +|---|---|---| +| Per-change field on object trees | `pb.Change.integrationName = 10` — **already shipped by §11.1, same field, same number, same value** | none | +| Stamping every labeled change (not just creation) | more fill sites for the same `PushChangeParams.IntegrationName` (session ctx chain per its "Identity plumbing" section) | none | +| Chat | new additive fields (`StoreChange.integrationName = 2`, `ChatMessage.createdVia = 18`) — new surfaces, not changes to shipped ones | additive only | +| Integration object | unique key = a HASH of the raw recorded name (§5 revision — the key need not be human-readable; display comes from the object's `name` detail); ids converge with what DELETE recorded because both start from the same bytes | none | +| `createdVia` detail | set at creation from the same creating-change value §10 reads; display/query sugar, never enforcement | none | +| `lastModifiedVia` | mirrors `SetLastModified` from per-change stamps | none | +| History rows | reads `integrationName` from each `pb.Change` — the field already there | none | +| Icons, wallet, UI, restrictions | orthogonal | none | + +Conversely, if §5's representation were a hash-only value or §4's carrier +the root payload, attribution would require a second field (a resolvable +display value) or a second slot (change-level) respectively — i.e. **only** +a name-carrying change-level field passes this test, which is the concrete +content of "the foundation costs nothing extra now and demonstrably +prevents a format change later." (The §5 raw-name revision passes it the +same way the slug did — the raw name is strictly MORE resolvable.) +DELETE-created objects from the interim are then *retroactively* fully +attributed (their creating changes already carry the name), which is a +small free win the narrow design would forfeit. + +## 13. Cost and compatibility + +- **Bytes**: `integrationName` ≈ name length + 2 (tag+len) — `Claude + Desktop` = 16 bytes, at most `MaxIntegrationNameLen`+3 (§5) — on the + creating change only (minimal slice), and only for API-key-authored + creations. Against a typical creating snapshot change (KB-scale; hard cap + 10 MiB, `sourceimpl/source.go:47`) this is noise; sync-volume delta + likewise. CPU: one string copy at build — the §5 revision removed even + the per-session normalization. +- **Old clients**: the field is an unknown proto field inside the + **encrypted, heart-owned** change payload — any-sync and the nodes never + parse it; old hearts ignore it on unmarshal; and because trees are + append-only, no old client ever re-marshals an existing change, so there + is no lossy-rewrite path. The root-change format is untouched — nothing + any-sync-visible changes at all. No migration; no backfill (§8). +- **New clients reading old objects**: absent field → unprovenanced → + fail-closed refusal (§8). Deterministic, no heuristics. One permanent + wire fact, recorded: proto3 scalar strings have no presence, so "written + by an old client" and "written by a non-API session" are BOTH the empty + string — indistinguishable on the wire, forever. Fail-closed treats them + identically, which is why this is a recorded fact and not a bug; any + future feature wanting to tell them apart would need a new field. +- **Failure surface added to creation**: none — stamping cannot fail + (string copy); an empty name degrades to today's behavior. + +## 14. What argues against, collected + +Stated as prominently as the case for, per the brief: + +1. **The guarantee is asymmetric for legacy keys** (§6): until legacy + unscoped keys sunset, a legacy holder archives anything via v1. The rule + is airtight only for scoped keys — which are exactly the keys agents + should hold, but the spec must not be read as "no API caller can archive + others' objects". +2. **Fail-closed on pre-existing objects is settled, and its consequence + is immediate** (§8, decided): today's v2-created objects, and any + "agent, clean up this space" request over human-created objects, get a + 403 whose only repair is the app — including the eval fixtures plan 3.3 + partly cited as motivation. This design deliberately cannot express + arbitrary-object cleanup; if that is ever wanted it is a separate + future capability (§17.2), not a gap here. Listed so the limitation is + read before shipping, not to reopen the decision. +3. **The retention assumption** (§10): provenance lives in full tree + history; a future history-truncation feature inherits a hard constraint + from this spec. +4. **Name-granularity is same-user-forgeable** (§6): pairing an app under + the byte-identical name transfers delete rights. Within the stated trust + model this is consent, not forgery, but it is the weakest link and the + reason the word "unforgeable" is always qualified with "by other + members" in this spec. (The §5 revision NARROWED this: under the slug, + visibly different names could collapse onto the same principal — F2; + now only the identical string matches, which is the §6 conceded case + and nothing more.) +5. **Member-visible forever** (§7): the disclosure is approved by the + attribution trust model, but it ships *before* the UI that explains it. + Anyone auditing raw changes sees integration app names from day one. + +None of these, in this spec's judgment, outweigh shipping: 1 shrinks by +attrition and is the scoping design's explicit stance; 2 is the requirement +working as intended; 3–5 are recorded costs of the only design that passes +§12. + +## 15. Testing plan + +- **Raw-name carrier** (§5 revision — replaces the slug property tests): + slug-hostile names ride the carrier byte-for-byte ("Claude/Desktop", + "привет", "🙂"); the F2 pair "Claude Desktop" vs "Claude/Desktop" REFUSES; + the F3 shape (recorded and caller both "привет") ARCHIVES. +- **Stamping**: creation via ctx carrying a name → creating change carries + the name (assert at the `pb.Change` level, the E′8 lesson: change-set + assertions, not just green Applies); creation without ctx (UI path, + indexer, import) → no field; **second Apply on the same object without + ctx → no field** (the §11.4 leak guard); `ChangeNoSnapshot` roundtrip + preserves it; old-proto unmarshal ignores it. +- **Provenance read**: fixture trees — created-by-this-account+name (allow); + same account, no name (legacy shape — refuse); same account, different + name (refuse, message names it); derived root (refuse via steer); + snapshot-reduced live tree vs history read (the §10 must-not-use case). +- **Surface**: table-driven handler/service tests per the house fixture + pattern — 404 / steer / 403×3 variants / already-archived idempotence / + dry-run both verdicts / C8 replay / scoped-key conjunction + (space_not_granted and write_not_granted still fire first); conformance + walk picks up the registry entry (fails if omitted). +- **E2E** (rides the Q1 charter): create via scoped key → DELETE → gone from + default queries; DELETE of a pre-existing object → 403; dry-run writes + nothing (C9's real-not-writing assertion). + +## 16. Where this ships, and issue keys + +The spec lands on this branch (GO-7383) as the plan-3.3 design record. The +implementation is two separable PR trains and should carry its own issue +number(s) at implementation time: the provenance foundation (§11.1–11.4, +core change-pipeline territory, reviewable by the sync owners) and the API +surface (§11.5–11.8, `core/api` territory) — the foundation must merge +first or in the same release; the route without the record would be a 403 +for everything, which is shippable but pointless. If the attribution epic +gets its GO number first, the foundation train belongs under it — it is +that spec's §3, built early. + +## 17. Open decisions (recorded, not blocking) + +1. Schema deletes (`DELETE /types`, `/properties`): adopt the same own-output + rule, or stay plain readwrite? (Scoping design's open question; + unchanged. The provenance read works for derived trees via their signed + creating change if the answer is ever yes.) Stated plainly per the + second review round (F8): today those routes archive on the write grant + ALONE — "own output only" is a property of the object route, not of v2 + deletion as a whole, and a steered type target lands on a route with a + weaker rule than the one that steered it away. +2. A "delete any" capability for keys that should manage whole spaces + (grant bit? bot accounts?) — a separate future product decision, not a + gap in this design (§8, decided); nothing here precludes it. +3. Whether `whoami` should surface the key's slug (cheap, helps agents + predict deletability against a future `createdVia` read) — lean yes, + one field, but it is attribution-adjacent disclosure and can wait. +4. Relation key names and the rest of the attribution spec's own open + questions — untouched here, owned there. diff --git a/core/api/APIV2_PHASE0_CODEREVIEW.md b/core/api/APIV2_PHASE0_CODEREVIEW.md new file mode 100644 index 0000000000..24b67896a6 --- /dev/null +++ b/core/api/APIV2_PHASE0_CODEREVIEW.md @@ -0,0 +1,135 @@ +# API v2 Phase 0+1 — code review synthesis (2026-07-23) + +Three opus lenses over the committed branch `go-7383-apiv2-phase0` +(`go-7383-anyblockjson..HEAD`): spec-conformance, Go robustness/security, +tests + agent-contract. Deduped, cross-confirmation noted, severity-tiered. +The top-3 criticals were re-verified in source by the synthesizer before +listing (marked ✓verified). + +Overall: the read/validate/plumbing surface is competently layered and +mostly spec-conformant (C4 split, C6 errors, C10 pagination, /validate +200-with-issues, live-state read under one lock all check out). But there is +**one reachable process-crash, two input-driven DoS holes, and one broken +core agent flow** (outline→drill-down) that the tests structurally cannot +see. None are architectural — all are surgical fixes. + +--- + +## TIER 1 — critical (crash / DoS; fix before Phase 2) + +### C1 — Live `store` pointer escapes the object lock → uncatchable process crash ✓verified [robustness #1] +`objectreadadapter.go:35` sets `Collections: st.Store()`. +`state.go:1664` `State.Store()` returns the **live** `*types.Struct` (walks +to parent, no copy) — unlike `BlocksToSave()` / `CombinedDetails().ToProto()` +on the adjacent lines, which copy. The lock releases at +`objectreadadapter.go:41`; `anyblockjson.Marshal` iterates that map at +`v2_object.go:171`, **outside the lock**. An agent reading a +set/collection/dataview object while a user mutates its store (add/remove a +collection item) → `fatal error: concurrent map read and map write`, which +`gin.Recovery` **cannot** catch → the whole middleware process dies. +**Fix:** deep-copy under the lock — `Collections: pbtypes.CopyStruct(st.Store(), true)` +in the adapter (mirror how blocks/details are already copied). Add a +concurrent read-vs-mutate test on a collection object. + +### C2 — Arbitrary `space_id` path param mints an unbounded store + backing DB [robustness #2] +Every v2 space-scoped handler calls `s.store.SpaceIndex(c.Param("space_id"))` +directly (`v2_discovery.go:26,57,116,163,180,227`; `v2_object.go:171,354,426`). +`getOrInitSpaceIndex` inserts `spaceIndexes[spaceId] = New(...)` for **any** +non-deleted id and `Init()` opens/creates a backing DB. v1 avoids this by +routing through the space service (which rejects unknown spaces). An agent +looping `GET /v2/spaces/{random}/objects` grows an attacker-keyed, +never-evicted map plus DBs/dirs without bound — memory+disk DoS from +untrusted localhost input. **Fix:** validate `space_id` against the account's +known spaces before touching the store (or use a non-creating accessor). +Tree-path reads (`GetObject`/`GetType`) are safe; only the store path. + +### C3 — Idempotency middleware reads the body unbounded, defeating the 10 MiB validate cap ✓verified [robustness #3] +`v2_middleware.go:125` `io.ReadAll(c.Request.Body)` with no limit, before the +handler. `/v2/validate` is wired with the idempotency MW (`router.go`), so +its `io.LimitReader(body, 10MiB+1)` guard is bypassed whenever an +`Idempotency-Key` is present (agents are told to send one on POSTs). No +server-level `MaxBytesReader`. Header + multi-GB body → OOM. +**Fix:** `io.ReadAll(io.LimitReader(body, cap+1))` in the MW; reject over cap +before hashing. + +## TIER 2 — major (broken flow / spec violation an agent hits) + +### M1 — Outline block labels don't round-trip to `?block=` — the outline→fetch loop 404s ✓verified [ALL THREE lenses: conformance #1, robustness #5, tests #1] +`v2_object.go:169` sets `CompactBlockLabels = plan.outline`, so `?outline=true` +emits 5-char suffix labels; a `?block=X` read leaves `CompactBlockLabels=false` +(full ids) and `filterBlockSubtree` (`v2_object.go:324`) matches +`probe.Id == blockId` exactly → 404 for every outline label. This is the +primary large-document agent flow (APIV2.md outline-then-fetch, R12). The +unit tests miss it because **every fixture id is ≤5 chars** (`v2_object_test.go:56-72`), +so label==id and compaction is a silent no-op. +**Fix:** resolve unique block-id suffixes in `filterBlockSubtree` (mirror the +§9a/C4 write-side suffix allowance, applied to reads), OR keep full block ids +in the outline. **Add a fixture with realistic long ids** asserting the +outline label round-trips through `?block=` — without it every future change +here is unguarded. + +### M2 — `format=md` derives etag and content from two separate locked reads (§8 "same state") [conformance #2] +`v2_object.go:154-158` computes etag from the reader's `ReadObject`; then +`markdownEnvelope` (`:215`) acquires a **second** lock via `s.mw.ObjectExport` +whose markdown is a different state read. A concurrent edit between them makes +the returned markdown newer than the etag advertises — the inconsistency §8 +exists to prevent. **Fix:** render md from the reader's snapshot, or derive +md's etag from the export's own read; at minimum document md etag as +best-effort. + +### M3 — Reads hard-fail (500) on unmapped/over-deep block content — violates C11 [conformance #3] +`export.go:600` errors on unknown content type and `:446` on `indent > 32`; +`Marshal` propagates → `GetObject` 500s the whole read. C11: "reads never +fail on unknown content" — degrade to `warnings`. No `OnWarning` sink is +wired, so the reserved `warnings` slot is never populated. **Fix:** wire +`OnWarning`, degrade unmapped/over-deep blocks to a warning, keep the read +succeeding. (Related: N1 below.) + +### M4 — Idempotency store has no in-flight reservation → concurrent retries double-execute [robustness #4, latent Phase 2] +`ensureIdempotency` is get→`c.Next()`→put with no reservation between miss and +put. Two concurrent same-key POSTs both miss, both execute, both put. Correct +today (no mutations wired) but the moment Phase 2 attaches it to mutations, +duplicate concurrent submissions each mutate — exactly what the key exists to +prevent. **Fix now while it's cheap:** reserve the key on first miss (pending +marker; second caller blocks or 409/replays). + +## TIER 3 — medium (correctness / coverage / contract) + +- **T1 — `objectreadadapter` (the etag-consistency crux) has zero tests** [tests #3]. Every service test mocks `ObjectReader`, so the same-lock snapshot+heads capture — the whole optimistic-concurrency invariant — is never run. Needs a focused test (editor/cache infra required); at minimum the snapshot assembly. +- **T2 — 404 mapping untested; real misses may 500** [tests #4]. `mapReadError` (`v2_object.go:130`) maps only `ErrUnknownTreeId` + space-not-exist to 404; the only test hits the 500 branch. A miss with another sentinel → opaque 500 a 3B can't recover from. Test the 404 paths; widen the sentinel set to real miss errors. +- **T3 — Corruption metric is structure/order-blind and misses added details** [tests #5]. `snapshotdiff.Compare:52` scans only `orig` detail keys (added details invisible); no block-order/nesting signal, so the `restructure-section` fixture scores Clean regardless of whether order was restored — it can't measure the thing it tests. Scan `got`-only keys too; add an order signal or drop restructure from backtranslation scoring. +- **T4 — `ListObjects` can emit empty `type`, violating C5** [tests NOTE]. `v2_object.go:398` `Type: typeKeys[...]` is `""` when the type id isn't in `typeKeysById` (hidden/bundled/edge). Fall back to resolving the key directly; test it. +- **T5 — ETag emitted unquoted (RFC 7232)** [conformance #4]. `c.Header("ETag", etag)` bare; `EtagMatches` compares raw. A conformant client quoting `If-Match` (or `W/"…"`) never matches once Phase 3 wires preconditions. Emit `ETag: ""`, strip quotes/`W/` in `EtagMatches`. +- **T6 — C6 `hint` field almost never populated** [tests #7, conformance]. Repair guidance lives in `message`; structured `issues[].hint` set only for the newer-version case. A model keying on `hint` per the C6 contract finds it empty. Move guidance into `hint`. +- **T7 — Outline heading text keeps compacted ref labels after `refs` is dropped → unresolvable** [conformance #5]. `v2_object.go:168` keeps `CompactObjectRefs=true` for outline; `buildOutlineEnvelope:294` then deletes `refs` when properties aren't kept, leaving a 5-char mention label with no legend. Keep `refs` when retained text may carry a compacted ref, or disable ref compaction for outline. + +## TIER 4 — minor / polish + +- **P1 — Every object read echoes the 60-char `$schema` URL + `version`** (~20 tokens/read of constant waste on the token-cheap read path) [tests #6]. Strip from read envelopes (the md envelope already omits them — shapes also diverge). `export.go:167`. +- **P2 — Two divergent pagination strategies; `ListSpaces`/`ListPropertyOptions` load all rows in memory then paginate** [tests #9, robustness]. The "thousands of options" C10 case is bounded only by the prefix filter, not the DB. `v2_discovery.go:48,253`. Untested: offset-past-end, `limit=0`, empty list, no-match prefix. +- **P3 — Idempotency replay drops response headers (ETag/Location)** [robustness #6]. `storedResult` records only status/contentType/body; a replayed create loses `ETag`/`Location`. +- **P4 — 500s leak raw internal error strings to the agent** [robustness #7]. `RespondV2Error` puts `err.Error()` in the client envelope (`mapReadError` fallback, `ObjectExport` desc, idempotency body-read). Low impact on localhost; prefer generic 500 + server-side log. +- **P5 — `dry_run` scaffold runs on every v2 GET** [conformance NOTE]. `GET …?dry_run=xyz` 400s though reads never mutate. Attach to mutation routes in Phase 2 only. +- **P6 — literal `501` not `http.StatusNotImplemented`** [robustness #9], and **`/v2/validate` has no rate limiting** [robustness #10] (v1 attaches a write limiter to every write route). **empty-heads objects share one etag** [conformance NOTE] — treat empty heads as absent etag. + +## Verified sound (not defects — recorded so they aren't re-raised) +objectreadadapter holds the lock across snapshot+heads and copies the heads +slice; `headsHash` sorts before hashing (order-independent, `\n`-delimited, +stable); `suffixLabels`/`setToSlice` deterministic → canonical output stable; +pagination clamps negative/overflow limit+offset and caps at 1000; `Details.Get` +nil-safe; path params are exact-match store keys (no traversal); v2 sits under +engine-level `gin.Recovery`; /validate 200-with-issues + newer-version hint +correct and tested; C4 object-ref/block-label split correct; resolvers wired so +custom date/select props round-trip. + +## Disposition +Tier 1 (C1–C3) are must-fix-now — a crash and two DoS reachable from +untrusted localhost agent input, all surgical (copy-under-lock, validate +space_id, LimitReader). Tier 2 M1 is the one broken *feature* (outline +navigation) and needs the long-id test alongside the fix. M2–M4 and Tier 3 +are correctness/coverage that should land before Phase 2 builds on this +surface. Nothing invalidates the architecture or the spec. Recommended: one +fix pass for Tier 1 + M1 (+ its test) immediately, Tier 2/3 folded into the +same pass, Tier 4 as cleanup. Two spec touch-ups also fall out (write the +`/validate` 200 and `block=`-absolute-indent rules into APIV2.md so Phase 2 +doesn't re-derive them; T5/T7 confirm the outline shape needs a spec note). diff --git a/core/api/APIV2_PHASE3_CODEREVIEW.md b/core/api/APIV2_PHASE3_CODEREVIEW.md new file mode 100644 index 0000000000..187b62aa2b --- /dev/null +++ b/core/api/APIV2_PHASE3_CODEREVIEW.md @@ -0,0 +1,260 @@ +# API v2 Phase 3 (edit surface) — code review synthesis (2026-07-30) + +Four opus lenses over `260635c6d..HEAD` (5 commits): spec/format conformance · +state-mutation/CRDT correctness · robustness/security · tests + agent +contract. Deduped and severity-tiered; cross-confirmation noted. Three lenses +ran live probes (reproduced bugs, not just read code). The synthesizer +re-verified the top mutation-lens claims in source (marked ✓): `Unmarshal` +never sets `RelationLinks`; `ResetToVersion` repairs only *bundled* relation +links (its own GO-7217 comment names the wipe-on-replay class this leaves +open for custom keys); `marshalForEdit` discards PUT warnings. + +> **2026-08-10 — findings retired by the removal of PUT.** APIV2.md §8.27 +> removed the full-document replace surface with its whole pipeline +> (`PutObject`, `putPipeline`, `marshalForEdit`, `ResetObject`, +> `preserveEditorOwnedState`). **B5** (dead `Warnings` on the PUT path) and +> **B7** (create-only guards running before the live-type fallback) are +> retired with their subject, as is every reference below to the +> `marshal → mutate-JSON → Unmarshal → ResetToVersion` apply path — PATCH +> has been a child-state `sb.Apply` since v0.3.4. Findings about the op +> layer, the lock story and PATCH itself stand as written. + +## Overall verdict + +The **op layer** is largely faithful and genuinely well-tested at its own +level: R3 indent arithmetic, move/subtree splicing, the R5 validation net, +error texts, suffix addressing (S3 closed), response economy — all check out, +and the lock story is sound (everything under one `DoContextFullID`, no +TOCTOU, no C1-class pointer escape). What does not hold is the **apply +path**: the marshal → mutate-JSON → Unmarshal → `ResetToVersion` pipeline +resets onto the live object a snapshot that the format *deliberately does not +fully carry* — no RelationLinks, no structural blocks, no resolvedLayout, no +extra object types — and `Apply(NoHistory, DoSnapshot, NoRestrictions)` turns +each absence into real CRDT changes. Unlike the Phase-0 review (surgical +fixes), this one concludes the apply path needs a **correction pass** before +the edit surface can ship. + +--- + +## TIER A — the apply-path cluster (data loss / integrity; fix as one unit) + +### A1 — Custom property values are WIPED on replay: snapshot carries no RelationLinks ✓ [CRDT #1] +`anyblockjson.Unmarshal` never populates `RelationLinks` (✓ verified — +`import.go` has no reference). `ResetToVersion` repairs bundled keys only +(smartblock.go:940-951, the GO-7217 partial fix — ✓ its comment describes +this exact failure). The state diff then emits `RelationRemove` for every +**custom** relation key; on replay/reindex/another device, +`changeRelationRemove → RemoveDetail` deletes the value. Concrete: PATCH one +paragraph on an object with a user-defined "Client" property → locally fine, +on the second device "Client" is gone. **Fix:** seed the reset state with the +live state's relation links (`PickRelationLinks`) in the adapter before +`ResetToVersion` (what the import path does). + +### A2 — An unnamed object loses its first paragraph (layout conversion fires) [CRDT #2] +The reset state has no `resolvedLayout` (stripped as local/derived), so +`Apply → resolveLayout` sees unset→recommendedLayout as a change and runs +`convertLayoutBlocks`; the title block was just dropped by the format, so +`WithNameFromFirstBlock` copies the first text block into `name` and +**unlinks the block**. PATCH anything on an unnamed page → its first +paragraph disappears. **Fix:** inject the live `resolvedLayout` into the +reset state before Apply. + +### A3 — Every edit deletes structural blocks; featuredRelations doesn't come back [CRDT #3] +The format drops header/title/description/featuredRelations (SPEC §7); the +diff emits BlockRemove + root ChildrenIds changes on every edit. Title/header +return via A2's conversion (an accident, not the contract); +`featuredRelations` returns only on a full source rebuild — open clients see +the featured row vanish. §8.2's "the editor regenerates" is only partly true, +and none of this hits the C11 guard or diffStats. **Fix:** preserve the live +structural subtree by id (or re-apply the `template.With*` transforms) in the +adapter before `ResetToVersion`. + +### A4 — No restrictions, no type allowlist: any object in the space is editable [robustness #1 + CRDT #4 + CRDT #9 — three findings converge] +`checkEditPreconditions` excludes only 4 sbTypes; `ResetToVersion` applies +with `NoRestrictions` and no object-level restriction check exists in the v2 +path. PATCH can rewrite the workspace object, Archive/Home, widgets, dates, +chats, sets' dataview blocks, and **type objects** — bypassing the +`/v2/types` endpoint's own guards (featured-list guard, PUT's objectType +rejection, which PATCH never calls) and omitting the type-specific repair +(`InitTemplate` recommendedLayout) that the mirrored import path performs. +**Fix:** allowlist editable sbTypes (Page/Note/Task/Set/Collection/Template…), +check `sb.Restrictions()` in the adapter, drop `NoRestrictions`. + +### A5 — Every PATCH writes a FULL-document snapshot change [CRDT #5] +`ResetToVersion` forces `DoSnapshot`: a one-word edit on a 500-block document +attaches a complete `ChangeSnapshot` to the tree and to sync — defeating the +minimal-diff contract at the change layer even though the Content diff is +minimal — and `checkChangeSize` can reject with `ErrBigChangeSize`, making a +large-but-app-editable object permanently un-PATCHable. **Fix:** don't force +`DoSnapshot` for API edits (flag on `ResetToVersion`, or a dedicated apply +once A1–A3 are handled). + +### A6 — Failures are not clean: events fire before push; side effects survive [CRDT #6 + robustness #5] +`ApplyState` mutates the live doc and dispatches events **before** +`pushChange`; a push failure (size, ACL) returns an error while open clients +already rendered the edit and the cache diverges until eviction. And +create-missing options/properties minted during `Unmarshal` survive every +later failure (validation, guard, apply) — a loop of crafted failing PATCHes +is an unmetered write amplifier. **Fix:** resolve/create refs only after +validation passes (see B6), and either accept+document the event window or +apply through a path that pushes before dispatching. + +## TIER B — the seams (broken flows an agent hits) + +### B1 — Compact-read / full-write seam: PATCH silently writes dangling object refs [conformance #1] +Default GET returns object ids as 5-char legend labels (C4); the edit +pipeline builds a full-id, legend-less doc and **nothing resolves labels on +the write side**: `addItems` appends verbatim (broken members), `removeItems` +silently no-ops, objects/files property values store garbage. SPEC §9a's +resolution rule makes the label "be" a full id. This inverts C4's promise on +the whole write surface. **Fix:** resolve object-id-valued strings by unique +suffix against the space (the §9a wiring allowance) on the mutate path, +and/or accept a `refs` legend in the PATCH body; minimally, existence-check +items/objects values and reject path-addressed. + +### B2 — No op can add the first block to a block-less object [conformance #2] +Every `insertBlocks` target resolves against existing blocks; a fresh +shortcut-created page has none, so PATCH cannot add content at all — the +create-then-write flow falls back to PUT, the path agents are steered away +from. A spec gap faithfully inherited. **Fix:** anchor-less `insertBlocks` +(document-root append / `at: "start"|"end"`), spec'd in §2 Phase 3(a) + the +op schema. + +### B3 — `replaceText` splices the replacement into markup source unescaped [conformance #5 + tests #3, PROBED] +`strings.Replace` on the markup-encoded text; the replacement is then +re-parsed. Probe: `replace:"2*3*4"` stored `234` with an invented italic; +`[a](b)` became a link. Breaks §7's `edit_text` "deterministic server-side +replace" — neither deterministic nor literal for `*_[]`\`` — and R5-net +inline-markup failures surface without the `ops[i]` prefix. **Fix:** escape +the replacement per SPEC §8.2 for text-bearing blocks (literal blocks stay +literal); prefix R5 markup issues with the op index. + +### B4 — `setProperties` validates keys but not values; the output-only denylist is too narrow [tests #2 + robustness #2, PROBED] +Any raw JSON value lands in details (`{"name":{"nested":true}}` → struct in a +string relation, 200) — the server is more permissive than its own published +schema. And the 7-key denylist accepts every other bundled key, including +`revision`/`sourceObject`, letting an agent mark an object bundled-derived +and pin its revision — defeating `guardBundledRevision` itself. **Fix:** +validate values against the relation's format (Phase-2 resolvers already +know it); reject `bundle.LocalAndDerivedRelationKeys` minus the export +exemption list on both PATCH and PUT. + +### B5 — `V2EditResult.Warnings` is dead: PUT's documented safety valve doesn't exist ✓ [conformance #4 + robustness #8 + tests #6 — three lenses] +`marshalForEdit` collects C11 warnings and discards them on the PUT path +(✓ verified); nothing assigns `result.Warnings`. A PUT that drops +unrepresentable content returns a clean 200 with no signal — §8.2's stated +mitigation is untrue. **Fix:** thread the warnings into the PUT response; +test it. + +### B6 — Unbounded work + create-RPCs inside the object lock [robustness #3 + #4 + CRDT #7] +No op-count cap (10 MiB ≈ 330k ops), `resolveRef` is O(blocks) per op with a +fresh slice each time, no ctx checks in the loop, and each unseen option name +fires a synchronous `ObjectCreateRelationOption` — a full object creation — +while holding the edited object's non-reentrant lock (self-deadlock risk on +any nested Do of the same id; UI blocked for the duration; 200k options +mintable in one request). The published schema bounds (`maxItems`, +`maxProperties`, `maxLength`) are never enforced [robustness #7]. **Fix:** +enforce the schema bounds server-side; cap ops (~512) and created options; +hoist an id→index map; resolve/create missing refs in a pre-pass **outside** +the lock (also fixes half of A6). + +### B7 — PUT runs create-only guards before knowing the live type [conformance #3] +`validateDocumentRefs`/`rejectRestrictedType` run on the raw body before the +live-type fallback: a collection PUT omitting `type` is rejected +("items requires type collection, got ''") contradicting §8.2's "absent type +keeps the live type"; restricted-type and template-shape create rules leak +into edits. **Fix:** pin the effective type from the live object before the +R9 layer; gate create-only guards to the create path. + +### B8 — Edit pipeline decodes numbers as float64 — corrupts blocks it never touched [conformance #8] +`parseEditDoc` lacks `UseNumber`; any integer > 2⁵³ in `fields`/dataview +filter values is rewritten (or re-emitted as `1e+20`, which then fails the +schema) on every PATCH. `anyblockjson.Validate` itself uses `json.Number` to +avoid exactly this. **Fix:** `UseNumber()` in `parseEditDoc`; teach +`blockIndent` to accept `json.Number`. + +### B9 — Raw internal errors leak into 500 envelopes (P4 recurrence) [robustness #6 + #9] +`RespondV2Error`'s fallback puts `err.Error()` on the wire; Phase 3 feeds it +wrapped internals (resolver/marshal/apply chains), and one crafted +table+setCell input produces an unwrapped 500 with an internal message +(empty-ref match on a non-object row — also reject empty row/col/id refs). +**Fix:** generic 500 message + server-side log; `V2ValidationFailed` for the +table case; typed 409/422 for `guardBundledRevision` [CRDT #11]. + +## TIER C — contained defects and coverage debt + +- **C-1** `leafBlockTypes` incomplete (code/file/image/video/audio/pdf/widget/ + row/column/group missing) — `insertBlocks inside` an image passes both the + pre-check and the V2 net [conformance #6]. Extend the map; fixture per type. +- **C-2** Per-op schemas not C13-strict below the root (`updateBlock.set`, + `setProperties.set`, `setCell.value` object arms lack + `additionalProperties:false`); the test only checks the root — walk + recursively [conformance #7]. +- **C-3** **Fixture-lossless mask** [tests #1, PROBED]: every edit fixture + builds "live" state via `Unmarshal`, so write-back loss is invisible — + probe showed an unrelated PATCH silently dropping a bookmark's `Title` and + flipping its `State` with no C11 warning (field-level loss has no warning + channel). Add a hand-built rich-snapshot fixture asserting byte-survival + of untouched blocks; extend C11 warnings to field-level drops or document. +- **C-4** The mutate adapter has **zero tests** [tests #4 + CRDT #12] — the + lock/heads contract, `guardBundledRevision` branches, and everything in + Tier A is unexercised. A smartblock-fixture test asserting the emitted + `pb.ChangeContent` list for one `updateBlock` would have caught A1/A2/A3/A5 + at once. Also: no handler-layer PATCH/PUT tests (ETag header, If-Match + passthrough, dry_run binding, status mapping) [tests #7]; the C11 422 + guard untested [tests #5]; targeting arithmetic tested only at depth ≤1 and + 2 of 5 shapes [tests #8]; no multi-op interdependence tests [tests #9]; + dry-run skips the mutator-side checks so dry≠real on guard failures + [tests #10]. +- **C-5** Option resolution round-trip: export falls back to raw option ids + when the name misses → write-back creates junk options literally named + `bafyrei…`; same-named options collapse to first match on every PATCH + [CRDT #8]. Keep resolvable ids as ids; warn instead of create on id-shaped + "names". +- **C-6** Multi-type objects lose `ObjectTypes[1..]` on first PATCH, silently + [CRDT #10]. +- **C-7** Dry-run mints `createdBlocks` ids the real run re-mints differently + (an agent planning a two-step edit 404s) [conformance #9]; dry-run also + inflates repeated would-create side effects unboundedly [robustness #11]. +- **C-8** `addItems`/`removeItems` return all-zero diffStats — success is + indistinguishable from no-op, which compounds B1's silent no-op + [conformance #10 + tests #11]. Also `blocksChanged`/`blocksMoved` are not + disjoint (double-count on edit+move) — document or fix. +- **C-9** diffStats is blind to the Tier-A churn (both diff sides are + canonical docs, so structural/relation-link/type-key losses cancel) — the + "accidental full rewrite signal" cannot see the actual change set + [CRDT #12]. Note: fixing Tier A largely fixes this. +- **C-10** `replace_all` is the only snake_case key in the op vocabulary (C2 + contradiction inherited from the spec body) [conformance NOTE]; op examples + use 2-char ids that teach an unrealistic shape [tests NOTE]; empty + rows/columns error text names an empty list [conformance #11]; PATCH/PUT + retry-safety (idempotency is POST-only by design) deserves an explicit + "send If-Match for retry safety" doc note [robustness NOTE]. + +## Verified sound (recorded so they aren't re-raised) +One-lock consistency (If-Match/marshal/ops/apply/heads — no TOCTOU); no C1 +pointer escape (`readLiveState` copies the store); block-id round-trip +through the pipeline → minimal Content diff; body reads bounded (10 MiB); +`ensureSpace` + write rate limit + dry-run wired on PATCH/PUT; suffix +addressing on op refs implemented + tested; setCell serves the full §6.1 +value set; response economy good (~90-byte plain PATCH response); no +reachable panic found in the op machinery; R3 arithmetic correct (verified by +probe at the tested depths); revision guard faithful to the import mirror. + +## Disposition + +The op layer survives review; the **apply path does not ship as-is**. Fix +order: +1. **Tier A as one correction pass** in the adapter: seed the reset state + with live RelationLinks + structural blocks + resolvedLayout + extra + object types; drop `NoRestrictions` (add restriction checks + sbType + allowlist); drop forced `DoSnapshot`; move create-RPCs outside the lock. + Then add the C-4 adapter test asserting the emitted change set for a + one-block edit — the test that would have caught A1/A2/A3/A5. +2. Tier B seam fixes (B1 suffix-resolution on writes, B2 root insert, B3 + escape, B4 value validation, B5 warnings, B6 bounds, B7 ordering, B8 + UseNumber, B9 hygiene). +3. Tier C coverage + polish. +Nothing invalidates the op vocabulary, the document-level pipeline concept, +or the endpoints — the correction is concentrated in +`objectmutateadapter.go` + `v2_edit.go` seams, not the op set. diff --git a/core/api/APIV2_PLAN.md b/core/api/APIV2_PLAN.md new file mode 100644 index 0000000000..da1d3a1ba0 --- /dev/null +++ b/core/api/APIV2_PLAN.md @@ -0,0 +1,146 @@ +# API v2 — the plan of outstanding work + +Status: plan v1.0 · 2026-08-09 · GO-7383, branch `go-7383-apiv2-phase0`. + +This is the **index**, not a replacement for the specs. Work is specified in +`APIV2.md` (the API spec + §8.x as-built notes), `APIV2_SURFACES.md` (the +remaining-surfaces decisions and phases), `APIV2_ADDRESSING.md` (the identifier +layer), `APIV2_TOKENS.md` (the measured token review) and +`APIV2_SURFACE_REVIEW.md` (the whole-surface audit). Each item below says +where it lives. + +Ordering is by **dependency and evidence**, not by size. Waves 0–1 are the +ones with measured numbers or a live defect behind them. + +--- + +## Built and green (for orientation) + +Phases 0–7 — read, create, edit (batched id-addressed ops; the +full-document PUT shipped and was **removed** — §8.27), query with +the compact filter DSL, the task-tool wrapper (12 tools, CLI, MCP stdio, two +model tiers), chats, the space periphery; the view family +(`update_view`/`insert_view`/`move_view`/`delete_view`); scoped API keys with the +fail-closed route registry; the layout split into `core/api/v2/` with two +OpenAPI documents; the strategy-(a) identity layer (BSON mint + `apiObjectKey` +slug, corpse policy, resolution chain). Each carries an `APIV2.md` §8.x +as-built section and has been through an opus review round. + +--- + +## Wave 0 — cheap, measured, no design risk + +Ship these first. Both are trivial, both pay on **every** read, neither has a +dependency or an open question. + +**Both landed 2026-08-09** — APIV2.md §8.24 and §8.25. + +| # | item | value | where | +|---|---|---|---| +| 0.1 | ~~Compact the embedded envelope values~~ **DONE** — fixed in `encodeEnvelope` (the serving layer); the canonical form is untouched and no golden moved | predicted 16–26 %, **measured −15.5…−26.4 %, corpus −23.2 %** | TOKENS §1.1, action 1; §8.24 | +| 0.2 | ~~Split `?ids=`~~ **DONE + HARDENED** — two shapes: `compact` (edit) and `full` (export); after the three-lens review, **only machine-minted ids relabel** (24-hex bson, view UUIDs — meaningful ids keep their spelling and are reserved), and the legend left the export shape too | legend removal **0.9–11.5 % on the measured corpus** (confirmed); block labels **bimodal**, −19…−22 % on minted-id documents and **0 %** on meaningful-id documents — by rule now, not charset accident; not the flat ~15 % the review assumed | TOKENS §1.2 + §10, action 2; §8.25 + §8.26 | + +`edit` (the default read) is: **short labels for minted block ids, full +inline object refs, no pins**. `full`/export is **full ids everywhere** +(no legend on any shape — §8.26; pins remain unshipped), because block +relabelling is lossy. Combined, against the actual served bytes: +**−33.1 % across the corpus**. + +Closed by the hardening (§8.26): PUT **refused** a body carrying ids the +object does not own (it used to silently adopt served labels as stored +ids); `?block=` subtree reads are marked partial and no write path accepts +them; create strips the read envelope and warns on label-shaped ids; the +wrapper's client-side relabeling retired. **§8.27 then removed PUT +entirely**, which closes the same trap by construction — no channel takes +block ids literally any more — and retires the item once owed to 2.1 +("teach PUT the suffix resolution the ops already use"). `?ids=full` is +now framed as the backup/export shape, not as a write-back read. + +## Wave 1 — finish the identity layer — **DONE 2026-08-13** (APIV2.md §8.37) + +The slug surface shipped; the platform half did not. All four items landed, +in dependency order (the union check has to exist before the backfill can be +safe, and both before the sweep can be anything but silent mis-resolution). + +| # | item | as built | where | +|---|---|---|---| +| 1.1 | ~~Heart-side mint hardening~~ **DONE** — `ensureUniqueApiObjectKey` tests the union: live stored slugs + live stored **keys** (one bounded listing) + the **bundled-derived** vocabulary (a point lookup — arm 3 is the one that catches "Due Date" → `due_date`, which no store-only check can see). Bundled installs skip it (their slug is derived, not minted); a collision **suffixes**, because a UI create has no caller to steer | ADDRESSING §7.5 req 1; §8.37 | +| 1.2 | ~~Backfill `apiObjectKey`~~ **DONE** — a real migration: fills only EMPTY slugs (never re-points one — the slug is v1-visible), skips bundled keys, ascending-id order so devices converge, one filtered query in the steady state. An **already-taken slug is a deliberate no-op** (`takenSlugPolicy`), because a backfill has no caller to steer and no name the user chose; §8-OQ3 owns the repair. **The plan's stated defect was mis-attributed** — a squatter already HAS a slug, so no backfill touches it; what shipped instead is the **loud floor**: a stored slug the bundled table resolves elsewhere is now ambiguous (400 listing both), and `servedKey` stops advertising it | ADDRESSING §7.5 req 5; §8.37 | +| 1.3 | ~~BSON→slug re-spelling sweep~~ **DONE** — one vocabulary, both directions, as a **table built from the bundle** (`mediaArtistURL` and `_score` pinned as the counterexamples no case transform survives). Package default = the bundled table (offline-safe); inside a node `storeresolver` widens it with the space's stored slugs, one bounded query per kind per request. Format key slots, row surfaces, served examples, goldens, both SKILL guides and a new eval task all re-spell; envelope/DTO names, block attributes and enum **values** deliberately do not | ADDRESSING §7.5a-1; TOKENS §6.2, action 6; §8.37 | +| 1.4 | ~~Identity deferrals~~ **DONE** — view-op `set`/`columns` key channels canonicalize their inputs (they were stored-key-only, which 1.3 turns from debt into a defect: a stored-key column address stops matching the column it names). The compact filter string **stays fold-strict on input** by design, but now advertises slugs and canonicalizes its parsed output | APIV2.md §8.23; §8.37 | + +## Wave 2 — make the cheap reads usable + +| # | item | value | where | +|---|---|---|---| +| 2.1 | **Locators on block-addressed ops** — staged into the slices below (2026-08-14), in the order §5.1's verdicts imply; every slice ships and reviews on its own. The shared rule is fixed up front and never re-derived per slice: a locator must resolve to **exactly one block or refuse** (zero → the outline steer; several blocks → `ambiguous_input` with ≤8 candidates + ~30 chars context; several occurrences within the one block → the existing more-context refusal), resolved **per-op against the applier's live document view under the object lock** — op *i* sees op *i−1*'s edits, dry-run and apply resolve identically at apply time (C9-advisory) | patching one word costs **2 417 tokens today, ~45–60 with a locator**; and it is what makes an id-free read writable instead of a trap | TOKENS §5, action 3 | +| 2.1a | ~~`replace_text`: `id` becomes optional, `find` IS the locator~~ **DONE 2026-08-14** — the shipped §8.21 wrapper semantics (7/8 and 6/8 → **8/8** tool selection with the snippet locator) moved down a layer, which is also the correctness win: the wrapper's `locateBlock` was a read-then-patch TOCTOU, and in-API resolution under the lock removes the race. The wrapper dropped its double-read in the same pass (`edit_text` keeps its interface, `locateBlock` retired; the server refusals reach the tool register through restVocab/opsVocab) | the smallest slice that pays: the commonest edit, no new fields at all | TOKENS §5.1/§5.3/§5.5; APIV2.md §8.43 | +| 2.1b | ~~`match` (exact substring of the block's text, same one-match rule) as the `id` alternative on `update_block` and `delete_block`~~ **DONE 2026-08-15** — §5.1's next two verdicts shipped on ONE generalised resolver (2.1a's `resolveByFind` → `resolveByText`, parameterised by the field name the refusals must speak and by the op's candidate scope; `replace_text` keeps its text-bearing gate, the two block-addressing ops scan every block, because narrowing a *destructive* op's candidates is what turns an ambiguity into a silent wrong deletion). Three decisions pinned: `id`+`match` together are **refused, never ranked** (and neither → refused, replacing the useless `block "" not found`); `delete_block`'s descendant guard names the RESOLVED id and its ambiguity candidates are replayable as a destructive retry (pinned by replaying one); `match` selects on the text as it stands when the op runs, so op *i* matches what op *i−1* wrote and never what it overwrote. §5.3's within-block-multiplicity bullet did NOT transfer — it is `replace_text`'s, because only it splices an occurrence | introduces the `match` field once, on the two ops with the clearest intent | TOKENS §5.1; APIV2.md §8.45 | +| 2.1c | `under` (heading text — restrict to that section's indent run) and `nth` (1-based, document order, within scope) on every locator-taking op — scoping and disambiguation; `nth` is also the within-block-occurrence escape the one-block refusals point at | gpt-5-mini composed `under` correctly **unprompted** (§5.4); completes §5.2's whole three-field vocabulary | TOKENS §5.2 | +| 2.1d | `match` on `move_block`'s subject and on the shared `after`/`before`/`inside` anchors, anchored `insert_blocks` ("after the heading 'Risks'" without a read), and `replace_subtree` — §5.1 marks replace_subtree adopt-but-lower-priority, so it rides with the structural tail instead of holding up 2.1b (it is the same locator form as update_block, near-zero marginal design) | the anchor half of §5.1's verdicts, all of it sharing 2.1b's field and 2.1c's scoping | TOKENS §5.1 | +| — | **Deferred, recorded per §5.1**: `set_cell` speaks a *different* vocabulary (column by header text, row by index/first cell — its own design; labels from full reads already work), and view **names** on the view ops (already locator-ish via optional-when-unique + suffix match; names are the remaining gap, not part of this change) | | TOKENS §5.1 | +| 2.2 | **`?mode=outline\|text\|props\|edit\|full`**, default `edit`, retiring `include`/`outline`/`ids`/`format` | `gemma4:e2b` **2/8 → 7/8** optimal reads; the current parameters are not chosen wrong, they are not chosen at all | TOKENS §6.2, action 4 | +| 2.3 | md mention-link short form (drop the redundant same-space `space_id`, or a label + legend appendix) | md is 83–84 % of a default read on mention-heavy docs — the "cheap text mode" is not cheap where it matters | TOKENS §1.3, action 5 | + +**Order matters here:** 2.1 and Wave 0 define what each mode can emit, so 2.2 +is cheaper to specify after them. + +## Wave 3 — surface completion + +| # | item | notes | where | +|---|---|---|---| +| 3.1 | **Phase 8** — file byte-download; the chat SSE stream on `/v2` carrying Phase-6 DTOs; tag/option admin; template reads; **the v1↔v2 conformance test** | auth issuance is deliberately excluded (keys are minted in the app); the conformance test is the exit criterion that makes "complete" checkable. Tag admin is blocked on decision D1 below | SURFACES §10.1 | +| 3.2 | ~~**Phase 9** — space-optional object routes~~ **RETIRED 2026-08-11** — superseded by the short space reference (APIV2.md §8.35), which removes the measured failure without a new route class. Phase 9's *unique* remaining value was a cold-pasted object id with no prior `find`; **no eval has ever produced that case**. Two defects found while scoping it are recorded rather than fixed: `ResolveSpaceIdWithRetry` is `retry.Attempts(0)` — infinite, bounded only by the context, so an unresolvable id spins instead of 404ing; and `set_properties` needs a space regardless, because `propertyFormats`, the option-name guard, `@me` and relative dates are all space-scoped | **D2 is moot**, not decided | SURFACES §10.3 | +| 3.3 | ~~`DELETE /v2/spaces/{space_id}/objects/{object_id}` (archive)~~ **BUILT 2026-08-14** — registered with creator provenance: own-output-only, recorded immutably on the creating change (`pb.Change.integrationName` — raw app name since the §8.44 revision), enforced from validated storage, fail-closed | the cleanup motivation is served **going forward only**: DELETE does not clean up objects created before it shipped (settled — no backfill), so the existing eval fixtures still need one manual archive | APIV2.md §8.42, APIV2_OBJECT_DELETE.md | +| 3.4 | `GenerateSchema` + store-backed option join — un-501 `types/{type}/schema` | the wrapper's `describe` runs degraded until this lands | APIV2.md §3, `discovery.go:235` | +| 3.5 | **A range block-remove op on PATCH** — `deleteBlocks {from, to}` (or `{all: true}` scoped to the document root), one op that removes a contiguous run of top-level blocks with their subtrees | **the capability PUT nominally served**: "clear the document and write new content" now costs one `delete_block` op per top-level block, so a 60-block rewrite is 60 ops against a 512-op batch cap for what is one intent. With this it is one op + one `insert_blocks`, at OP cost rather than DOCUMENT cost — which is the whole reason PUT could be removed rather than replaced (§8.27). Design notes: reuse `matchBlockRef` for both endpoints (no literal-id channel), refuse a range that straddles a container boundary, and make `diff_stats.blocks_removed` the receipt | APIV2.md §8.27 | + +## Wave 4 — format-level + +| # | item | notes | where | +|---|---|---|---| +| 4.1 | **Pins + the D1 kill + the SPEC revision** — the pin table, the label-minting algorithm, the total resolution rule | **pins are export-only** (decided): they protect rename/cross-account round trips that PATCH-first agents never perform, at ~22 tok per custom key. D1 (the silent id-as-name fallback) cannot be fixed without them | ADDRESSING §7.1, §7.6 steps 1–2; TOKENS action 7 | +| 4.2 | §7.4 strict write defaults — the kind × verb rule (only options implicitly created; POST permissive, PATCH strict) | supersedes R9's blanket default; free only pre-ship | ADDRESSING §7.4 | + +## Quality track — runs alongside, not after + +| # | item | notes | +|---|---|---| +| Q1 | **The e2e charter** (E1–E5): a real-account `/v2` fixture, then C9 dry-run really not writing, C8 across a real retry, a PATCH landing in the CRDT with change-set assertions, and a scoped key minted the real way | E1 is the precondition for the rest. E4 also closes **E′8** below. `tests/integration/chat_test.go` is the pattern | +| Q2 | **C′4** — `update_block` merges on exported JSON, so `Restrictions`, exotic `Fields` kinds and int64 precision vanish on the touched block even when never named in `set` | contradicts the published "only the named fields change"; `replace_text` does it correctly | +| Q3 | **E′8** — no change-set assertion, so reverting `sb.Apply(st)` to `Apply(st, NoRestrictions, DoSnapshot)` keeps the suite green | the M7 work pinned marshal *counts*; the change-set assertion is still open | +| Q4 | **The B4 model benchmark rerun** — the small-tier ID-vs-locator arms | blocked on the remote Ollama host (Metal-compiler wedge); the exact rerun recipe is in TOKENS §5.4 | +| Q5 | `make openapi` regeneration | pending across the view family, the sorts `id` addition, the insert_view schema and every §8.2x change | +| Q6 | The 15 SHOULD-FIX items from the whole-surface review | headlined by pinning the C9/C8/E′8 safety contracts | + +## Decisions needed from a human + +| id | decision | lean | blocks | +|---|---|---|---| +| D1 | Tag/option rename semantics under names-as-identity | rename = create + migrate + delete server-side; no id-addressed escape hatch (C2) | 3.1 | +| D2 | ~~Object in a space the key does not hold: 403 naming the space, or 404 hiding it~~ **MOOT** — the question only existed for Phase 9's space-less routes, and Phase 9 is retired (3.2). No route reaches an object without a space today, so nothing is waiting on this | — | nothing | +| D3 | `apiObjectKey` mutability — freeze, or keep v1's re-pointing | keep mutable, address-only | 4.1 | +| D4 | The five surface-review decisions: If-Match on types/properties, set-read base scope, the `GlobalAuthExempt` allowlist, per-credential rate limiting, the space-kind schema split | — | the allowlist one should land **before** 3.1 adds SSE and file routes | +| D5 | ADDRESSING's remaining open questions (twin-slug repair depth, uniform strictness for integration scopes, MUST-vs-SHOULD identifier-shaped labels) | — | 4.1 | + +## Tickets outside API v2 + +- **`POST /sets` still runs a whole-document creating-resolver import** — the same dangling-name minting the view ops fixed, on a path that predates them. Needs its own change: the caller authored the whole document, so "did they mean this?" cannot be answered the way it was for view ops. *(PUT was the other half of this item and left with §8.27.)* +- **Date objects** — a view op on one dies inside `sb.Apply` with `state.ErrRestricted` (a different sentinel from `restriction.ErrRestricted`), likely surfacing as a 500. Pre-existing and shared with every block op. +- **GO-5969** — the type default-view visibility regression, cherry-picked to `develop` as PR #3235. Independent of this branch; every type created since Nov 2025 has an all-columns-hidden "All" view until it merges. +- Two eval documents remain in the throwaway test account. 3.3 shipped but + deliberately cannot remove them — provenance is fail-closed for + everything created before it shipped (APIV2.md §8.42) — so they still + need one manual archive; they double as the Q4 rerun fixtures. + +## The shortest useful path + +Waves 0 and 1 have shipped. Of what remains, if only two things ship: +**2.1 locators** (the 50× edit flow, and it is what makes an id-free read +writable instead of a trap) and **2.2 modes** (the parameters are not chosen +wrong, they are not chosen at all). + +One thing Wave 1 leaves for a human: an existing **twin/shadow slug** now +fails loud but is not repaired, because repairing it re-points an address v1 +serves. That is ADDRESSING §8-OQ3 (also D5 below), and the backfill's skip +counter is the telemetry it asked for. diff --git a/core/api/APIV2_REDESIGN_CODEREVIEW.md b/core/api/APIV2_REDESIGN_CODEREVIEW.md new file mode 100644 index 0000000000..f3cbe69101 --- /dev/null +++ b/core/api/APIV2_REDESIGN_CODEREVIEW.md @@ -0,0 +1,165 @@ +# API v2 PATCH state-ops redesign — 4-lens review synthesis (2026-07-30) + +Four opus lenses over `ef8e4385b..HEAD` (4 commits: `dafb99322` fragment API, +`f987b9244` port, `a53984f7c` state-ops applier, `efe2ebc80` §8.2 v0.3.4): +state-ops correctness · fragment API · validation & adversarial safety · +contract & tests. Three lenses ran live probes in-package. Deduped and +severity-tiered; cross-confirmations noted. + +Baseline verified by the synthesizer before review: tree clean, `go build` +exit 0, all 12 packages green, `ResetToVersion` confined to `ResetObject` +(PUT), PATCH ending in a plain `sb.Apply(st)`. *(2026-08-10: `ResetObject` +and PUT are gone — APIV2.md §8.27 — so `ResetToVersion` has no API caller +at all; findings below that name PUT as the fallback or the comparison +are retired with it.)* + +## Verdict + +**The redesign's thesis is correct and its foundation is verified sound.** The +state-ops lens actively tried to break the op→state mutations and could not: +insert positions match `position.go`, `replaceSubtree`'s splice index is right +*because* `insertAt` re-picks the parent after the unlink, the `moveBlock` +cycle check reads the pre-unlink view, orphans are collected, the view is +invalidated after every mutating op. **A1/A2/A3/A5/C6 genuinely hold by +construction** — independently verified, including that `resolveLayout` now +sees `cur == new` so the paragraph-eating conversion cannot fire, and that +plain `Apply` leaves `doSnapshot=false` / `addHistory=true`. + +**But the damage moved rather than vanished.** Removing the whole-document +`Validate` was load-bearing for more than §8.2 admits (V3 and the +document-wide id domain are now unenforced), and three things are strictly +*worse* than the pipeline they replaced: side effects now escape the +preconditions entirely, the per-op view rebuild is a performance regression +inside the object lock, and table ops re-mint wrapper ids on every edit. + +--- + +## TIER A — regressions vs the old pipeline (fix before anything else) + +### A′1 — Create-missing side effects escaped the preconditions [3 lenses: state-ops #1, validation #4, contract #10] — MAJOR +`prewarmCreateMissing` (v2_edit.go:56-57) runs **before** `MutateObject`, i.e. +before `checkObjectEditable`, before `checkEditPreconditions` (If-Match, +sbType), and before the object is known to exist. Pre-redesign these ran +*inside* `finishEdit`, after the precondition checks. Reproduced: `PATCH` an +object that **does not exist** → 404, **and a tag option is permanently +created in the space**. Same for stale If-Match (412), restricted object +(403), and ops aborted by a later op's validation error — and because prewarm +resolves *all* ops up front, one rejected request can mint every option named +anywhere in the batch. +**Fix:** read the object + run `checkEditPreconditions`/`checkObjectEditable` +*before* prewarm (a read costs nothing here); cap created options per request; +attach side effects to the error payload so a rejected agent learns what it +caused. + +### A′2 — The per-op view rebuild is a DoS regression inside the object lock [state-ops #3, validation #3] — MAJOR +Every mutating op nils the view; every op then rebuilds it via a full +`snapshotFromState` + `anyblockjson.Marshal` **and a brand-new +`storeresolver`** whose caches start empty (re-running `ListAllRelations` / +`ListRelationOptions` per op). `begin()`'s already-marshaled document is +thrown away instead of seeding the view, so even a 1-op PATCH marshals three +times. With no op cap and a 10 MiB body (~3×10⁵ trivial ops), a batch holds +`DoContextFullID` for O(ops × document) work. **This is precisely the B6 +finding the redesign was meant to relieve, made worse** — the old pipeline +parsed once. +**Fix:** seed `a.view` from `begin()`'s `beforeDoc`; reuse the final view as +`afterDoc`; keep ONE resolver on the applier across marshals; cap ops (~512); +check `ctx.Err()` per op. Longer term: address blocks off the state tree +instead of re-marshaling. + +### A′3 — Every table op re-mints both layout wrappers [state-ops #2, contract #6] — MAJOR +`tableFromJSON` (table.go:265-273) always assigns `imp.genId()` to the +columns/rows wrappers, so a `setCell` replaces the table's `ChildrenIds`, adds +2 blocks, deletes 2, and re-parents every row and column — while `diffStats` +reports `{BlocksChanged:1}`. **Two devices editing different cells produce +disjoint wrapper structures that merge into a table with duplicated rows and +columns.** The in-code comment "only the edited cell diffs" and §8.2's "the +diff is no longer blind to anything real" are both false here. +**Fix:** feed the live table's existing wrapper ids into the fragment import +(or derive them deterministically from the table id); correct the comment and +the §8.2 claim. + +## TIER B — guarantees silently lost with the whole-document validate + +### B′1 — V3 (row→column containment) is enforced by nobody [validation #1] — MAJOR +`resolveTarget` pre-checks only `LeafBlockType`, and `row` correctly isn't a +leaf; the only V3 check lives in `checkFlatRun`, which now sees an isolated +fragment, never the spliced result. Three **single-op** repros return 200 and +produce a document that fails `anyblockjson.Validate` — i.e. **the object's own +GET body is no longer PUT-able**, and R5 is violated by construction: +`insertBlocks inside `, `moveBlock inside `, `replaceSubtree` a +column with a paragraph. +**Fix:** check containment against the **live parent's type** on every splice +(`insertBlocks`/`moveBlock`/`replaceSubtree`/`updateBlock`/`replaceBlock`) with +an `ops[i]` path — the applier already knows the parent. + +### B′2 — The dropped depth bound corrupts the applier's own view mid-batch [fragment #4, validation #2] — MAJOR +Fragment validation is run-**relative**, so a 33-deep run passes; inserted at +depth ≥1 it pushes the document past `maxBlockIndent=32`. Because `marshalDoc` +always installs a non-nil `OnWarning`, the exporter takes the **clamp** branch +and `doc()` discards the warnings — so **within the same PATCH**, op N+1 sees +clamped indents: `deleteBlock` computes `descendants == 0`, **skips the +recursive guard and drops an entire subtree**; `moveBlock`'s cycle check and +`updateBlock`'s leaf check are mis-scoped the same way. Afterwards `begin()` +*does* see the warning → **every subsequent PATCH on that object 422s** — +a self-inflicted permanent lockout, repairable only via PUT/app. +**Fix:** carry a base depth into fragment validation +(`UnmarshalBlocks(run, base, opts)` / `Options.BaseIndent`) and pass the +anchor's depth; enforce the bound as a cheap post-op tree walk rejecting with +`ops[i]`; make `doc()` **fail rather than degrade** when the view would clamp. + +### B′3 — Make the post-op document validate hard, not env-gated [contract #2] — MAJOR +`moveBlock`, `deleteBlock`, `setProperties`, `addItems`/`removeItems` get **no** +document-level check at all; the replacement is gated behind +`ANYTYPE_API_V2_VALIDATE_EDITS=1` and only logs. Fragment validation also can't +see the document-wide id domain (derived `rowId-colId` cells, other tables' +row/col ids). Decisive: **`afterDoc` is already marshaled for diffStats and +already handed to the debug validator** — the check is one env-var away from +free. +**Fix:** make it a hard failure **by default** (keep the env var to *disable*), +reusing `invalidDocError`. This also backstops B′1/B′2 cheaply. + +## TIER C — contract breaks (the agent repair loop depends on these) + +- **C′1 — R5 issue paths lost their `ops[i]` prefix** [validation #5, fragment #9, state-ops #10, contract #3 — all four lenses]. Fragment issues surface as `/blocks/0/type`, so in a 4-op batch an agent cannot tell which op failed; `replaceText`'s markup failure carries **no path at all**; and `runPathFor` maps every non-top id (table rows/columns/cells) to index 0, pointing the repair loop at an innocent block. **Fix:** re-prefix and re-base fragment issue paths onto `ops[i].blocks[j]` in `invalidDocError`; give `replaceText` issues `ops[i].replace`; attribute non-top ids to their owning top block, falling back to `ops[i].blocks`. +- **C′2 — dry_run double-reports every created option** [contract #1, state-ops #4]. `OptionId`'s dry branch appends to `sideEffects` without memoizing, and prewarm + the in-lock pass both call it → `created.options` lists each option twice; the real run lists one. **Breaks C9's dry≡real on the one field dry-run exists to preview.** **Fix:** memoize the dry-run miss / dedupe by ref. +- **C′3 — dry_run skips the adapter guards** [validation #8, state-ops #9, contract #11]. `checkObjectEditable` and `guardBundledRevision` live in `MutateObject`, so a dry run returns 200 where the real PATCH 403s; and the dry state is built from the read snapshot, so local details/relation links/structural blocks the live doc owns are absent — `applySetProperties`'s `inDoc` escape and `begin()`'s C11 guard can therefore reach different verdicts. **Fix:** hoist both guards to a service-level check both paths call. +- **C′4 — `updateBlock` silently drops non-format fields** [contract #7]. The merge happens on the *exported* JSON and re-imports, so `Restrictions`, exotic `Fields` value kinds and int64 precision vanish on the touched block even when never named in `set` — contradicting the published "only the named fields change". `replaceText` does it correctly (`b.Copy().Model()`). **Fix:** overlay onto a copy of the live model, or amend the schema/§8.2. + +## TIER D — known-open, unfixed by the port (re-confirmed, now measured) + +- **D′1 — `replaceText` markup injection (B3)** [fragment #3, state-ops #8]. Now quantified: on `"the cat sat"`+Bold, `replace:"a*b"` **destroys the mark and leaves raw asterisks in stored text**; `replace:"*star*"` invents an italic; `replace:""` **mints a mention at an arbitrary object id from plain text**. The fragment API *hinders* the fix — it exports the codec pair but no escape helper. **Fix:** export an escape helper and escape `op.Replace` for text-bearing blocks (leave code/embed literal), or run find/replace on plain text with offset-shifted marks. +- **D′2 — `revision`/`sourceObject` still settable (B4)** [validation NOTE, state-ops #6]. `setProperties` marks an ordinary object bundled-derived and pins it above future revisions — defeating `guardBundledRevision`, which reads the same child state the op just wrote. The mirror case (`unset`) silently no-ops. **Fix:** add both to the output-only denylist; reject `LocalAndDerivedRelationKeys` minus export exemptions; validate values by format. +- **D′3 — `leafBlockTypes` incomplete** [validation NOTE, state-ops #7, fragment]. `code`/`file`/`image`/`video`/`audio`/`pdf`/`widget`/`row`/`column`/`group` missing; `insertBlocks inside ` now has **nothing** behind it since the net was removed. Needs a deliberate decision — SPEC §5 permits legacy nesting under file blocks. +- **D′4 — B1 (dangling refs from compact reads), B9 (raw internal errors in 500s), B8 (float64)** all unchanged. B8 is *wider* than §8.2 claims: it also affects **client-supplied payload numbers** in insertBlocks/replaceBlock/replaceSubtree, which never touch the view. + +## TIER E — new-substrate defects and coverage debt + +- **E′1 — Structural guard misses table cells** [fragment #2]. Reproduced: a `featuredProperties` or `title`-styled block inside `rows[].cells[]` passes, via `setCell`, `insertBlocks`, `replaceSubtree`. **Fix:** recurse the guard over cells in both object and array forms. +- **E′2 — `MarshalBlockSubtree` honours compaction flags with nowhere to put the legend** [fragment #1]. Ids and object refs silently truncate (`aaaaaaaa…`→`aaaaa`, link target→`ectid`) and feed back truncated. **Fix:** reject/force-clear the compaction+OmitIds flags in the fragment marshal. +- **E′3 — Primary dataview id unprotected** [fragment #5]. `replaceSubtree`/`deleteBlock` on a set's `"dataview"` block mints a fresh id or removes it; the editor then adds a second default view. **Fix:** refuse to re-id/delete it on set/collection/objectType documents. +- **E′4 — `setProperties.unset` has no key validation** [state-ops #5]. `unset:["id"]`/`["type"]`/`["spaceId"]` pass into `RemoveDetail` (self-repairing via `injectDerivedDetails`, but the hole is real and typos silently no-op). **Fix:** run `unset` through the same lifted-key + unknown-key checks as `set`. +- **E′5 — Relation link can contradict the value** [contract #8]. `propertyFormat` falls back to `longtext` while the value was decoded under the same failed resolution → a `longtext` link on a value the format layer read differently: the A1 class the link exists to prevent. **Fix:** thread the resolved format into the link, or refuse unresolvable keys. +- **E′6 — Delete-then-recreate the same id in one batch is wrongly rejected** [validation #6]. `deleteBlock` only unlinks, so `st.Exists` stays true → 400 "it already exists" for a block the view no longer shows. **Fix:** track unlinked-this-PATCH ids. +- **E′7 — `UnmarshalPropertyValue` has no error channel** [fragment #6]. `dueDate:"tomorrow"` silently stores a string on a date property with a 200. +- **E′8 — Coverage: the port was mechanical** [contract #5, #4, #9]. Old and new suites have **byte-identical `t.Run` name lists** — 45 in, 45 out, **zero new cases for anything the redesign introduced**: view invalidation, `checkFreshIds`, `replaceLive`, id reuse, prewarm ordering, the debug validator. Concrete regression that ships green: **deleting any `a.mutated()` call** (every block test is single-op). Worse, `TestMutateObject` stops at final state, so **switching `sb.Apply(st)` back to `Apply(st, NoRestrictions, DoSnapshot)` — the exact thing the redesign escaped — keeps every test green.** And the atomicity test now only restates the mock's wiring. **Fix:** assert `st.GetChanges()` kinds/count for a one-block edit (A5 proof); add insert-then-address-created-id, move-then-address, delete-then-sibling-suffix, duplicate-payload-id path, prewarm ordering; drive one PATCH end-to-end through the real adapter over `smarttest`. +- **E′9 — `fields._detailsKey` not stripped on write** [fragment #7]. A payload can mint a second name-bound "title" block — what the §7 guard exists to prevent. Pre-dates the redesign (PUT has it too). +- **E′10 — SPEC §13 stale** [fragment #8]: no `fragment.go`, none of the six new exports listed, and it still asserts "the inline codec is internal — not part of the public API", which `fragment.go` falsified. + +## Corrections to §8.2's own claims (verified false) +1. "Per-block restriction checks ride the Apply path" — **inert**: `Apply` calls `s.ParentState().CheckRestrictions()`, and for `sb.NewState()` the parent's own parent is nil, so it returns immediately. A pre-existing upstream quirk shared with every `Block*` handler, but the redesign did not gain it. +2. "The diff is no longer blind to anything real" — false for table wrapper churn (A′3). +3. "B8 narrowed to the one re-imported block" — omits client-supplied payload numbers. +4. "Only the edited cell diffs" (in-code comment) — false (A′3). + +## Disposition + +Nothing here invalidates the redesign — keep it. Fix order: +1. **Tier A** (regressions): prewarm behind preconditions; seed the view + one + resolver + op cap; pin table wrapper ids. +2. **Tier B** (lost guarantees): live-parent containment check; depth base + + fail-don't-clamp; **turn the post-op validate on by default** (cheapest, + backstops the other two). +3. **Tier C** (contract): `ops[i]` paths, dry-run dedupe + guard parity, + `updateBlock` merge fidelity. +4. **Tier D/E** as a seams-and-coverage pass — with E′8's change-set assertion + first, since it is what keeps all of this from silently regressing. diff --git a/core/api/APIV2_SURFACES.md b/core/api/APIV2_SURFACES.md new file mode 100644 index 0000000000..7825a98b71 --- /dev/null +++ b/core/api/APIV2_SURFACES.md @@ -0,0 +1,690 @@ +# API v2 — the remaining v1 surfaces (spaces, members, files, chats, auth, tails) + +Status: decision v0.2 · 2026-08-06 · GO-7383 follow-on to `core/api/APIV2.md` (v0.4). +Scope: everything `/v1` serves that `/v2` does not yet. Evidence is the shipped +route table (`core/api/server/router.go`) and the v1/v2 handler+service source; +every claim below carries a file:line ref. + +> **v0.2 — the completeness decision (human, 2026-08-06).** The mixed-client +> rule proposed in v0.1 (§7, Q8) is **rejected**. v2 is to be a *complete, +> self-contained API*: auth, file download, chats, streaming and the admin +> tails all get a `/v2` home, and a v2 client never types `/v1`. The stated +> reason is documentation coherence — one API that can be documented cleanly, +> end to end, for both agents and human users. §§1-8 below record the +> per-surface evidence, which is unchanged and still load-bearing; the +> *recommendations* they reach are superseded by §10, which converts them into +> a completeness plan. Where v0.1 argued "reuse, the seam is harmless", the +> counter-argument that won is that a seam is cheap to cross and expensive to +> *explain* — every exception costs a paragraph in the docs, an example that +> works differently, and a reader who now has to know which half they are in. + +## Verdict + +No remaining surface needs a redesign-for-agents (d) — that finding stands. +The object surface was redesigned because its *representation* was wrong for +models; the remaining surfaces are small CRUD tails whose v1 shapes are +serviceable, so the work is **translation, not redesign**. What the v0.2 +decision changes is the *extent*: every surface gets a v2 home, including the +three v0.1 wanted to leave behind (auth bootstrap, byte download, SSE stream). + +That makes the remaining work three phases rather than two — Phase 6 (chats), +Phase 7 (periphery), Phase 8 (completeness: auth, download, stream, admin +tails) — and it makes §6's deprecation clock meaningful for the first time: on +Phase 8 exit, `/v1` has no unique capability left, so it can be deprecated +whole rather than in pieces. + +One caveat the decision inherits, stated here so it is not rediscovered later: +**completeness is not parity.** Two v1 behaviors should NOT be reproduced — +v1's `total = len(fetched)` (already banned by Phase-4 rule 4) and its +snake_case auth bodies (§10.1). "A v2 home for every capability" is the goal; +"the same shape at a new URL" is not. + +| Surface | v0.1 Rec. | v0.2 (decided) | One line | +|---|---|---|---| +| Auth | (a) reuse | **issuance dropped (2026-08-07); v2 owns consumption** | No v2 minting endpoint by design — keys are issued in the app over gRPC. v2 owns the scope gate, the per-space grant and `GET /v2/auth/whoami`. | +| Spaces | (c) | **(c)** unchanged | List shipped; add GET-one/POST/PATCH — v1's list does N+1 RPCs and misses every v2 convention. | +| Members | (c) | **(c)** unchanged | List shipped; the real gap is `GET /members/me`; member admin is disabled even in v1 — nothing to port. | +| Files | (c), download stays v1 | **(c) incl. download** | Upload shipped; download gets `/v2` bytes (HTTP conventions still apply *around* the stream); the search file-layout blindness is the live bug. | +| Chats | (c) | **(c)** unchanged, incl. SSE | v1 drops chatState/message_count the RPC already returns; rows/marks are token-hostile — passthrough + compact shapes, and the stream comes too. | +| Lists (v1 `/lists`) | — | — | Superseded by Phase-4 sets/collections; nothing to do. | +| Tags admin | (a) | **(c)**, Phase 8 | Rename semantics under names-as-identity must be resolved (Q5), not dodged. | +| Templates read | (a) | **(b/c)**, Phase 8 | Trivial to port; ports for completeness rather than demonstrated demand. | + +--- + +## 1. Auth — (a) reuse v1 as-is + +**What exists.** Two unauthenticated routes registered under `/v1` +(`router.go:329-335`): `POST /v1/auth/challenges` (app name → challenge id + +4-digit code shown in Desktop) and `POST /v1/auth/api_keys` (challenge id + +code → bearer key), backed by `AccountLocalLinkNewChallenge` / +`AccountLocalLinkSolveChallenge` with scope `AccountAuth_JsonAPI` +(`service/auth.go:19-52`). Every authenticated request — v1 and v2 alike — +passes the same `ensureAuthenticated` middleware, which exchanges the key for +a session token via `WalletCreateSession` and caches it +(`server/middleware.go:60-101`). The `/v2` group already mounts that +middleware (`router.go:85`); APIV2.md §8 declared "auth is shared with v1 — +no new auth surface" and the code matches. + +**Agent workflow.** The challenge flow is inherently human-in-the-loop (the +4-digit code appears in the Desktop app) and runs once, at harness setup. No +model ever authors these two calls; token economy and C2-C13 are irrelevant +to them. Nothing v1-specific is baked into the *mechanism* — only the URL +carries `/v1`. + +**Recommendation.** Reuse. Document `/v1/auth/*` in the v2 docs as the +version-neutral bootstrap. Do not mount an alias under `/v2` today: it would +put snake_case bodies (`challenge_id`, `app_key` — `model/auth.go`) inside +the C2 camelCase namespace, or fork the shape for two field names' sake. The +one commitment to record: **`/v1/auth` outlives v1 deprecation** (or moves in +its own migration) — it is the account-link protocol, not part of the v1 +resource surface. See open question Q1. + +## 2. Spaces — (c) thin adaptation, mostly shipped + +> **STATUS: BUILT** (Phase 7, 2026-08-06 — decisions as built in APIV2.md +> §8.8). The N+1 claim below verified. One deviation from v1's mechanics: +> the description rides the ONE WorkspaceCreate call (CreateWorkspace +> applies every detail), dropping v1's second WorkspaceSetInfo RPC. The +> get-one read is the tech-space space view (it mirrors name AND +> description), so it costs zero RPCs. + +**What v1 has.** `GET /v1/spaces` (list), `GET /v1/spaces/{id}`, `POST +/v1/spaces`, `PATCH /v1/spaces/{id}` (`router.go:536-556`). The v1 list is +expensive by construction: per space-view row it calls `WorkspaceOpen` + +`ObjectShow` (`service/space.go:88-94` → `getSpaceInfo`, `space.go:212-250`) +— N+1 RPC pairs to render name/icon/description. Create routes through +`WorkspaceCreate` (hardcoded `CHAT_SPACE` use case, random icon option — +`space.go:136-174`); update through `WorkspaceSetInfo`. The v1 `Space` model +carries `gateway_url` and `network_id` (`model/space.go:17-25`). + +**What v2 has.** `GET /v2/spaces` shipped (`router.go:94`), one store query +over tech-space space views, rows `{id, name}` (`v2/service/discovery.go:26-53`, +`v2/model/model.go:124-128`). No get-one, no create, no update. + +**Agent workflow.** (1) Orientation: "which space am I in / which spaces +exist" — the shipped list answers it. (2) A description is genuinely useful +orientation signal for multi-space accounts; today v2 has no way to read it. +(3) Creating a scratch/project space is a real, occasional agent task; two +calls into v1's shape work but miss idempotency (an auto-retried space create +duplicates a whole space — the worst possible duplicate) and the C6 error +shape. `gatewayUrl`/`networkId` are client-infrastructure fields, not agent +fields — leave them out of v2 rows (they remain reachable via v1). + +**v2 additions** (C6/C8/C9/C10 semantics throughout): + +``` +GET /v2/spaces/{space_id} → {"id","name","description"} +POST /v2/spaces body {"name", "description"?} → the same shape +PATCH /v2/spaces/{space_id} body {"name"?, "description"?} → the same shape +``` + +Create/patch are thin over `WorkspaceCreate`/`WorkspaceSetInfo` exactly as +v1; `Idempotency-Key` honored (C8), `dry_run` validates the body only (C9 — +a space create cannot be simulated). No v2 space delete: v1 has none either, +and space deletion is an account-level operation the local API should not +casually offer. The "space orientation one-shot" is deliberately **not** +specced here — it is a real fork, see Q2. + +## 3. Members — (c) thin adaptation, one endpoint of substance + +**What v1 has.** `GET /v1/spaces/{id}/members` (active + joining, two +sequential ObjectSearches — `service/member.go:19-103`), `GET +…/members/{member_id}` resolving by participant id *or* raw identity +(`member.go:106-143`). The write path (approve/decline/remove/role) exists in +service code (`member.go:146-208`) but its route is **commented out** pending +granular permissions (`router.go:459-464`) — there is no live member-write +surface in v1 today. + +**What v2 has.** `GET /v2/spaces/{space_id}/members` shipped +(`router.go:106`): active participants, minimal rows `{id, name, role, +identity}` (`v2_discovery.go:57-99`). + +**Agent workflow.** Members exist for one reason in the agent loop: object +property values (`assignee`, `creator` hold participant ids). The shipped +list serves that. The missing piece is self-identity — `@me` in filters and +"assign to me" — which only the server knows; APIV2.md §3 already names +**`GET /v2/spaces/{space_id}/members/me`** as a Phase-5 build item (the same +identity Phase 4's placeholder substitution uses, `v2_list_read.go:453-467` +via `V2Deps.AccountId`). Shape: + +``` +GET /v2/spaces/{space_id}/members/me → {"id","name","role","identity"} +``` + +Not building: get-one-member (the paginated list covers realistic space +sizes; a compact row is ~90 tokens), and member administration — v1 itself +keeps it disabled, and approving join requests is a human trust decision, not +an agent task. If the v1 route is ever re-enabled, agents can be pointed at +it; do not pre-build a v2 twin of a surface v1 doesn't trust yet. + +## 4. Files — (c) thin, and mostly already decided + +**What v1 has** (`router.go:403-423`): `POST /files` (multipart, staged to a +temp path — `handler/file.go:157-229`), `GET`/`HEAD /files/{id}` streaming +bytes with range/conditional support and image width variants + SVG +sanitization (`handler/file.go:38-76`, `service/file.go:68-162`), `DELETE +/files/{id}` = archive-or-purge of the file *object* +(`ObjectSetIsArchived`/`ObjectListDelete`, `service/file.go:189-208`). + +**What v2 has.** `POST /v2/spaces/{space_id}/files` shipped — multipart *or* +`{"url": …}`, returns `{id, name, mimeType, size}`, stamps `origin: api` +(`v2/service/file.go:21-50`, route `router.go:264`). APIV2.md already calls +it load-bearing (R11): file/image blocks and `iconImage` need the id. + +**Agent workflow, honestly assessed.** Agents deal in text; what they do with +files is (1) mint an id to place in a block or icon — shipped; (2) attach an +*existing* file — requires finding it; (3) hand bytes to the harness (save an +attachment, show an image to the user) — a tooling concern, not a model +concern; (4) "read this PDF" — **no server capability exists**: the FT +indexer treats file layouts specially but indexes relation values, not +content (`core/indexer/fulltext.go:52-57`), and the `FileObjectService` port +serves bytes only (`core/api/core/core.go:35-39`). Do not invent (d) scope +for a capability the middleware does not have (Q7). + +**Decisions.** +- **Download: reuse v1 (a).** It is a byte stream with HTTP-native semantics + (ranges, conditional GET); none of C2-C13 applies to it. Document + `GET /v1/spaces/{id}/files/{fileId}?width=` as the transport endpoint. +- **Delete: no v2 file route.** A file object is an object; the pending + `DELETE /v2/spaces/{space_id}/objects/{object_id}` archive (§3 build item, + due before Phase 5) covers it — v1's own DeleteFile is exactly that RPC. +- **The real gap — discovery (BUILT, Phase 7 — APIV2.md §8.8).** The gap + was: the v2 query surface scoped rows to `util.ObjectLayouts`, which + excludes file layouts (`util/constant.go:9-18`), while v1 search has an + explicit opt-in (`prepareBaseFilters(includeFileLayouts)`, + `service/search.go:178-186`) — so a pure-v2 agent could upload an image + and never find it again. What was widened is **search only** + (`v2/service/search.go appendBaseRowScope`): naming a file type in the + type channel (`type = "image"`, top-level `type: "file"`, …) widens the + layout scope to `ObjectAndFileLayouts` for that query — the v1 opt-in + reproduced without a new parameter. **ListObjects keeps the narrow + `ObjectLayouts` scope by design** (`v2/service/object.go` — it has no + type channel and deliberately gains none; file discovery is search's + job), and the sets/collections reads never had the layout scope at all, + so a set over a file type already returned its rows. Rows come back + C5-minimal with `mimeType`/`size` available via `fields=` (and, post + review, as filter/sort keys — APIV2.md §8.8). + +## 5. Chats — (c) thin adaptation with three real reshapes + +> **STATUS: BUILT** (Phase 6, 2026-08-06 — decisions as built in APIV2.md +> §8.7). The evidence below held under re-verification, with two backend +> facts the plan could not see: `ChatReadReactions` ignores its order id +> (`core/chats.go:325`) so the reactions read scope is all-or-nothing, and +> the edit RPC replaces the whole message content so PATCH is a read-merge. +> Q3 resolved (i) counter-free list; Q4 resolved counts-by-default. The SSE +> stream and per-chat FT search remain on v1 until Phase 8, as planned. + +The one surface where "thin" still means design work. Read the machinery +first; the conclusion is that **v1's chat *model* is right and its chat +*shapes* leak or drop exactly the fields an agent needs**. + +**What v1 has** (`router.go:338-400`, 13 routes): list/create chats; +get-messages with order-id cursor (`before_order_id`/`after_order_id`, +limit ≤1000 — `handler/chat.go:126-153`); get-one; add/edit/delete message; +toggle reaction; read-all / read-range / read-reactions; per-chat FT search +(`ChatSearch`); an SSE stream (last N + live `message_added/updated/deleted`, +`reactions_updated`, heartbeats — `handler/chat_stream.go:63-135`). + +**What the middleware can do that v1 hides.** `ChatGetMessages` returns +`chatState` — unread messages *and* mentions `{oldestOrderId, counter}`, +`unreadReactionOrderId`, `last_state_id` — plus `message_count` +(`pb/protos/commands.proto:9179-9205`; +`pkg/lib/pb/model/protos/models.proto:1638-1648`). The v1 service **drops +both** and returns bare messages (`service/chat.go:88-103`). Three concrete +consequences for a polling agent: + +1. **"Anything new?" costs a page, not a peek.** With no counters on any read + shape, the agent must fetch messages and diff to learn there is nothing. +2. **Mark-read has a race v1 makes unavoidable.** `ReadChatMessagesRequest` + accepts `last_state_id` precisely to prevent marking messages the client + has not seen (`model/chat.go:64-69`, proto comment `commands.proto:9344`) + — but **no v1 response ever carries a state id** (the message DTO omits + `stateId`; the SSE converter drops `ChatStateUpdate` events — + `model/chat.go:279-314`). The correctness affordance exists server-side + and is unreachable through the API. +3. **Token-hostile shapes.** `ListChats` rows are the full v1 Object — the + embedded `*Type` object and complete properties array + (`service/chat.go:72-74` → `object.go:415-431`), the C5-banned + multiplier. Message reads carry a ~120-char participant id per message + plus identity-list reaction maps; marks are offset-based + (`model/chat.go:28-33`) — the exact trap (offset arithmetic in model + space) the block surface eliminated via §8 inline markup. + +Sending is fine: one round trip, returns `message_id` +(`service/chat.go:128-144`). The SSE stream is fine for harnesses that hold +a connection; a stateless polling agent needs the counters instead. + +**Not (d), because:** the primitives (order-id cursor pagination, id-addressed +message CRUD, toggle reactions, read watermarks) are already agent-shaped — +they map 1:1 onto what a redesign would specify. Everything wrong is at the +DTO layer. That is the definition of (c). + +**v2 chat surface** (all C6 errors; C8 `Idempotency-Key` on every mutation — +a double-sent chat message is user-visible damage; C9 `dry_run` = +validate-only; C7 etag/If-Match **does not apply** — order ids and +`last_state_id` are the stream's native concurrency vocabulary, documented as +a deliberate exemption like search's C8/C9 one): + +``` +GET /v2/spaces/{space_id}/chats # C5 rows {id,name} — store query, no chat opens +POST /v2/spaces/{space_id}/chats # {name} → row (thin over ObjectCreate, v1 parity) +GET /v2/spaces/{space_id}/chats/{chat_id}/messages # ?after=&before=&limit=25 +POST /v2/spaces/{space_id}/chats/{chat_id}/messages # {text, reply_to?, attachments?:[fileId…]} → {id} +PATCH /v2/spaces/{space_id}/chats/{chat_id}/messages/{message_id} # {text} +DELETE /v2/spaces/{space_id}/chats/{chat_id}/messages/{message_id} +POST /v2/spaces/{space_id}/chats/{chat_id}/messages/{message_id}/reactions # {emoji} → {added:bool} +POST /v2/spaces/{space_id}/chats/{chat_id}/read # {up_to, last_state_id?, scope?:"messages"|"mentions"} +``` + +GET messages response (compact JSON, C3): + +```json +{"messages":[ + {"id":"…","order":"…","author":"Alice","author_id":"_participant_…", + "at":1717405200,"text":"can you **check** the doc?","reply_to":"…", + "reactions":{"👍":2},"attachments":[{"id":"bafy…","type":"image"}]}], + "state":{"unread_messages":3,"unread_mentions":1,"oldest_unread_order":"…","last_state_id":"…"}, + "message_count":812} +``` + +The three reshapes that carry the phase: + +- **State passthrough.** `state` + `message_count` on every messages read — + zero extra RPC cost (the fields are already in the response the service + throws away). This closes both the peek problem (poll = `limit=1` read) + and the mark-read race (`last_state_id` finally reaches the client; + `POST …/read` forwards it). +- **Text is §8 inline markup, both directions.** Read: marks render into the + text via the anyblockjson inline codec (the same serialization block text + uses — one vocabulary, C2); write: `text` parses as markdown source + exactly like `replace_text`/`insert_blocks` payloads (the D′1 caveat applies + verbatim and is documented on the endpoint). Offset mark arrays never + cross the API. `style` is dropped from the default read (it is + `"paragraph"` in practice) and not accepted on write for now. +- **C5 rows and compact reactions.** Chat rows are `{id,name}` (the chat + *object* remains visible in object search — `chatDerived` is in + `util.ObjectLayouts` — but its document body is empty: messages live in + the chat store, not blocks). Reactions default to counts + (`{"👍":2}`); `?reactions=full` restores identity lists. + +**Reused from v1 during the window (a):** the SSE stream (an event channel +has no v2-convention delta worth re-mounting; harness-level consumers only) +and per-chat FT search (`…/messages/search`). Both are candidates to move +under /v2 unchanged (b) before the deprecation clock if the mixed-client rule +(Q8) demands it. Deliberately absent: per-chat unread counters on the *list* +— computing them means opening every chat, the exact cost GO-7302 removed +from startup; see Q3. + +## 6. Residual v1 surfaces — nothing to build + +- **Lists** (`/v1/spaces/{id}/lists/*`, `router.go:426-446`): superseded by + the shipped Phase-4 sets/collections reads and the Phase-3 + `add_items`/`remove_items` ops. v1 keeps serving legacy clients until + deprecation. Done. +- **Tags** (`/v1/…/properties/{id}/tags/*`, `router.go:559-584`): v2 reads + options (`GET /properties/{key}/options`) and creates them by name + (create-missing, R9). What v1 alone offers is id-addressed rename / + recolor / delete (`service/tag.go:103-208`). That is curation, not an + agent loop; and v2's names-as-identity makes "rename" a genuinely + different operation (the identity changes under every object that carries + it). Reuse v1 (a); revisit only on demand (Q5). +- **Templates** (`/v1/…/types/{id}/templates`, `router.go:587-597`): v2 can + create templates (`POST /v2/…/templates`) and read one by id (GET object), + but cannot list them — v2 search excludes template rows by design (§8.4 + base scope). v1's list/get stand (a). If the wrapper ever grows a + create-from-template flow, a `GET /v2/…/types/{type}/templates` list is a + half-day build over the same store query v1 uses (Q6). + +## 7. Cross-cutting: the mixed /v1+/v2 client, stated plainly + +A v2 client that calls /v1 for auth, byte downloads, and admin tails is +**acceptable — with a rule**. The coherent-API story agents actually need is +"one OpenAPI document whose examples all work"; that is satisfied by the v2 +spec *documenting* its three /v1 dependencies (auth bootstrap, file +download, SSE stream) as named, versioned exceptions. What would break the +story is the agent *loop* straddling versions — different error shapes, +pagination, and vocabulary inside one task. Hence the rule this document is +built around: **everything a task loop touches (objects, query, chats core, +file upload+discovery, space/member orientation) lives under /v2 before the +CLI ships and §6's deprecation clock starts; what stays on /v1 is only +bootstrap, byte transport, and administration** — surfaces where either HTTP +itself is the convention or a human is in the loop. When v1 is eventually +removed, the survivors (auth, download, stream) are re-homed as their own +small migration, not blockers today. + +**Phase-5 relevance.** The wrapper's ~10 tools (§7.2) touch none of the +surfaces above except `GET /v2/spaces` (shipped) and the `@me`/`members/me` +identity (already a named Phase-5 item — this document just confirms its +shape). Chats and files do not appear in the tool set, so nothing in these +recommendations changes the wrapper's design. If chat tools are wanted later +(`chat_read`/`chat_post` are the obvious pair, and the Phase-6 shapes above +are deliberately flat enough to back them), they belong in a separate +profile/manifest — the >15-tool cliff (§7) rules out widening the core set. + +## 8. Phase plan + +### Phase 6 — chats for agents (BUILT 2026-08-06 — APIV2.md §8.7) + +1. **[built]** v2 chat DTOs + inline-markup bridge (`v2/model/chat.go`): + marks ↔ §8 markup text via the anyblockjson inline codec, both + directions; reactions compaction (counts default, `?reactions=full` as + participant ids); author name enrichment — store-backed via the + deterministic participant id, NOT the v1 cross-space subscription cache + (that cache is the v1 service's; the deviation is recorded in §8.7). +2. **[built]** `GET /chats` C5 rows (store query over `ChatLayouts`, no chat + opens — the test fails on any RPC) + `POST /chats` (thin `ObjectCreate` + with the `chatDerived` type; non-empty name required). +3. **[built]** `GET /messages` with `state`+`message_count` passthrough + (`v2/service/chat.go`). +4. **[built]** `POST`/`PATCH`/`DELETE` message + reactions toggle, C8 on all + (the middleware's method set widened to DELETE for the chat delete + route), C9 dry runs (PATCH is a read-merge — the edit RPC replaces the + whole content, a naive text forward would wipe attachments). +5. **[built]** `POST /read` forwarding `{up_to, last_state_id, scope}`; up_to + required+inclusive for messages/mentions (an empty bound silently marks + nothing); the reactions scope is all-or-nothing (the backend ignores its + order id) and rejects bounds. +6. **[done]** Docs: C7 exemption on every chat endpoint; D′1 caveat on + POST/PATCH message; v1 SSE stream + chat search named as exceptions; + `chat`/`chatMessage`/`chatRead` discovery kinds (§5 rule: an authoring + surface needs a schema kind). + +Exit criterion (harness): a polling agent completes "summarize what's new in +this chat and mark it read" in ≤2 calls with zero message re-reads, at lower +token cost than the v1 flow, and a double-send retry is absorbed by C8. + +### Phase 7 — periphery (BUILT 2026-08-06 — APIV2.md §8.8) + +1. **[built]** Spaces: `GET /v2/spaces/{space_id}`, `POST /v2/spaces`, + `PATCH /v2/spaces/{space_id}` (§2 shapes; C8 on both mutations — the + router test pins both; PATCH additionally requires at least one field + and a non-empty name). +2. **[already shipped]** `GET /v2/spaces/{space_id}/members/me` — verified + Phase 5 landed it (route + `GetMemberMe` + tests); nothing rebuilt. +3. **[built]** the search file-layout opt-in keyed off the type channel + (§4): top-level file `type` or a positive (`=`/`IN`) `type` filter + leaf widens the row scope to `ObjectAndFileLayouts`, both request + forms; negated leaves do not. `mimeType`/`size` joined the `fields=` + vocabulary as display-only aliases of fileMimeType/sizeInBytes + (search AND the sets/collections `?fields=`). Scoped to search: + ListObjects has no type channel (§4's "without a new parameter"), + and the sets/collections reads never had the layout scope, so sets + over file types already worked. +4. Restated, not re-budgeted (already §3 build items): `DELETE /v2/objects` + archive (covers file delete), `GenerateSchema`. + +## 9. Open questions — decisions needed from a human + +- **Q1 · Auth path — DECIDED, twice.** v0.2 (2026-08-06) moved the + challenge/key pair to `/v2/auth/*`. **2026-08-07 supersedes it: v2 mints + nothing.** Keys are issued in the app over gRPC; the HTTP API only consumes + a bearer token, and v2 owns everything downstream of it — the scope gate, + the grant, `whoami` (§10.1 item 1). The v0.1 instinct that auth was + "version-neutral plumbing" turns out to have been half right: not because + the URL prefix is harmless, but because *issuance does not belong to the + HTTP API at all*. +- **Q2 · Space orientation one-shot.** A `GET /v2/spaces/{space_id}/context` + returning `{space, me, types[], propertyKeys[]}` would collapse the + cold-start 3-4 calls into one (~1 s of round trips, a few hundred tokens). + Against it: it duplicates three shipped discovery lists behind a second + cache-staleness surface, and the wrapper's `describe` flow already covers + the per-type half. Recommendation: defer until the Phase-0 harness measures + the wrapper's cold-start; build only if orientation calls dominate turns. + This is the one candidate where "agent-shaped addition" is plausibly real. +- **Q3 · Unread counters on the chat list — DECIDED (Phase 6, as + recommended): (i)**, the list stays counter-free; per-chat state is free + on the messages read (a `limit=1` poll), and computing list-wide counters + means opening every chat — the GO-7302 startup cost — on every poll. + (iii), the store-side counter aggregate, remains the only good-UX option + if list counters are ever demanded; it is middleware work, not API work. +- **Q4 · Reactions default — DECIDED (Phase 6, as recommended): + counts-by-default** (`{"👍":2}`); `?reactions=full` restores identity + lists, carrying participant ids (one vocabulary with `author_id`, C2), + never raw identities. +- **Q5 · Tag/option administration.** Leave rename/recolor/delete on v1 + until deprecation (recommended), or spec + `PATCH/DELETE /v2/…/properties/{key}/options/{name}` now and resolve the + rename-changes-identity semantics (rename = create+migrate+delete under + names-as-identity, or an id-addressed escape hatch that reintroduces the + id/name duality C2 banned)? +- **Q6 · Template listing under v2.** Build `GET /v2/…/types/{type}/templates` + (trivial) — or wait for a demonstrated create-from-template agent flow? + Recommended: wait; v2 create takes full bodies, so templates currently buy + an agent nothing it cannot inline. +- **Q7 · File content extraction.** "Read this PDF" has no backing + capability anywhere in the middleware (FT indexes relation values only). + Building extraction is a middleware project with its own owner — decide + whether it enters the v2 roadmap at all; the API-shaped part + (`GET /files/{id}/text` with C11 warnings) is trivial once a service + exists. Until then the honest answer is "download the bytes via v1 and + extract harness-side". +- **Q8 · The mixed-client rule — DECIDED (v0.2): rejected.** v2 is complete + and self-contained; a v2 client never types `/v1`. See the header note and + §10. Q5 (tag rename semantics) and Q6 (templates) are consequently no longer + "wait for demand" — they are Phase-8 build items, and Q5's rename semantics + must actually be resolved. Q7 (file text extraction) is unaffected: it is a + missing *capability*, not a missing endpoint, so completeness does not + conjure it — `/v2` exposes the bytes and says so plainly. + +## 10. The completeness plan (v0.2) + +The three phases below replace §8's two. Phases 6 and 7 are unchanged in +content; Phase 8 is new and exists only because of the v0.2 decision. + +### 10.1 Phase 8 — completeness: the surfaces v0.1 wanted to leave on v1 + +1. **~~Auth endpoints~~ — DROPPED (human decision, 2026-08-07). v2 does not + mint keys at all.** Neither `POST /v2/auth/challenges` nor + `POST /v2/auth/api_keys` is built. + + **Key issuance is not an API surface.** A user creates a key in the app, + over gRPC; the HTTP API only ever *consumes* an already-issued bearer + token. That is a boundary, not a gap: issuance is an in-app, human, + consent-bearing action, and the reworked challenge flow landing separately + across heart and the clients is where it belongs. Building either endpoint + now would mean shipping a v2 surface we already know is changing. + + What v2 *does* own is everything downstream of the token: the scope gate, + the per-space grant, and `GET /v2/auth/whoami` (shipped with scoped keys — + it derives from the same grant record the gate reads, so a client can ask + what its key is allowed to do without guessing). + + **Consequence for the completeness rule.** `/v1/auth/*` is now an + *architectural* exception rather than a temporary one — the whole issuance + flow lives outside the HTTP API, so a v2 client that already holds a token + never types `/v1`, and one that does not cannot get a token from any HTTP + call, v1 or v2. The docs must say this plainly: **v2 has no minting + endpoint by design; obtain a key in the app.** Revisit only if headless + issuance ever becomes a real requirement. +2. **[build] `GET /v2/spaces/{space_id}/files/{fileId}/content`** — the byte + stream. HTTP is the convention *inside* the response (Content-Type, + Content-Length, Range, ETag as a real validator), but everything around it + is v2: path shape, C6 errors on the failure paths, and the 404/403 + vocabulary. Pairs with the shipped upload and with `GET .../files/{id}` + metadata. +3. **[build] the chat SSE stream under `/v2`** — carried by Phase 6's DTOs + rather than v1's, so the stream and the polling read agree field for field. + This is the one item where a straddling client would have been genuinely + incoherent: the same message in two shapes depending on how it arrived. +4. **[build] tag/option administration** (`PATCH`/`DELETE` on + `.../properties/{key}/options/{name}`) — requires resolving Q5's rename + semantics under names-as-identity. Recommended resolution: + rename = create + migrate + delete, performed server-side as one operation, + with the id-addressed escape hatch explicitly NOT reintroduced (C2). +5. **[build] template reads** (`GET /v2/…/types/{type}/templates`, `GET` one) + — trivial passthrough; ships for completeness. +6. **Exit criterion.** A conformance test asserts that every capability + reachable under `/v1` has a `/v2` route, with an explicit, reviewed + allowlist of the exceptions. This is the test that makes "complete" + checkable instead of asserted — without it, completeness decays silently + the next time a v1 route is added. The allowlist today is exactly + `/v1/auth/*` (both routes, item 1): key issuance is deliberately not an + HTTP-API surface. Each entry carries a reason and an owner, so the list + reads as a set of decisions rather than a carve-out, and adding an entry + for any other reason has to be argued for in review. + + Note the harness already exists: scoped API keys shipped a fail-closed + route-walking registry over the `/v2` group (verb × global class). It + answers a *different* question — "is every v2 route classified for + grants?" — but the walk is reusable, and this test is the second + assertion over it. + +### 10.2 What "complete" does not mean + +- **Not shape parity.** See the Verdict caveat: `total = len(fetched)` and + snake_case bodies are not ported. +- **Not capability invention.** File text extraction (Q7) stays absent because + the middleware cannot do it; the docs say so rather than implying a gap. +- **Not v1 removal.** Phase 8 makes `/v1` *deprecable*, and §6's clock can + then start. Removal is its own migration with its own notice period. + +### 10.3 Phase 9 — space-optional object routes (decided 2026-08-09 · **RETIRED 2026-08-11**) + +> **Retired — superseded by the short space reference (APIV2.md §8.35).** +> `/v2` now serves spaces by a six-character reference off the tail of the +> CID half and accepts either spelling on every route that takes a space. +> That removes the measured failure — a model cannot truncate a value with +> no dot in it, and the truncated form resolves anyway — without a new route +> class, a new grant class, or a resolver dependency. The section below is +> kept for the reasoning it records; three of its claims are corrected here. +> +> **Correction 1 — the tools it was said to unburden already take `object` +> alone.** "For the wrapper's small tier that removes a required argument +> from half the tools" is wrong as written: `read`, `set_properties`, +> `add_blocks`, `edit_text`, `check_item`, `move_block`, `delete_block` and +> `set_cell` take `object` (a handle number or an id) and resolve the space +> from `session.Space`, set by the last `find` (`runner.go` `resolveObject`). +> Phase 9 would have removed a `space` argument from the ROUTES, which the +> wrapper fills itself. The three tools that ask a model for a space — +> `find`, `describe`, `create` — are exactly the three Phase 9 says keep it. +> +> **Correction 2 — what Phase 9 uniquely solved, and how often it happens.** +> The one case the wrapper's own space memory cannot cover is a **cold-pasted +> object id with no prior `find`**. That case has **never appeared in an +> eval**: every measured trace reaches an object through `find`, which is +> also the only thing that mints the handles the tools take. It is a real +> gap and an unobserved one. +> +> **Correction 3 — `set_properties` needs a space regardless.** Even with a +> space-less route, the wrapper cannot drop the space for it: `propertyFormats` +> (the key → format index), the option-name guard, `@me` resolution and +> relative-date resolution are all space-scoped calls (`values.go`). A +> space-optional route would have moved the lookup, not removed it. +> +> **A defect found while scoping it, filed here rather than fixed:** +> `ResolveSpaceIdWithRetry` (`core/block/object/idresolver/resolver.go:98`) +> is `retry.Attempts(0)` — **infinite**, bounded only by the caller's +> context. Build item 2 below mandates using it, so an unresolvable object +> id would have spun until the request deadline instead of answering 404. +> Anything that revives this must bound the retry first. +> +> **Decision D2 is moot**, not decided: it asked what to answer for an object +> in a space the key does not hold, which only arises on a space-less route. + +Not completeness and not a token knob — a surface simplification that +happens to save tokens. **Object ids are content-addressed (the CID of the +object header), so they are unique across spaces**; the `space_id` in +`/v2/spaces/{space_id}/objects/{object_id}` is redundant whenever the object +id is known. + +The binding already exists and is a keyed point lookup, not a scan: +`spaceresolverstore.GetSpaceId(objectId)` (`FindId` on the primary key, +`pkg/lib/localstore/objectstore/spaceresolverstore/store.go:44`), exposed +as the `idresolver.Resolver` component (`ResolveSpaceID` / +`ResolveSpaceIdWithRetry`, `core/block/object/idresolver/resolver.go:32`) +and already consumed by `core/block/service.go:215` and `fileobject`. This +is exposing existing machinery, not building it. + +**What it buys.** `space` disappears from every object-addressed route and +tool — `read`, `set_properties`, `add_blocks`, `edit_text`, and +`check_item`/`move_block`/`delete_block`/`set_cell` in the large tier. It +stays where it is genuinely part of the intent: `find`, `describe`, +`create` (you search *in* a space, create *in* a space). For the wrapper's +small tier that removes a required argument from half the tools — and it +is the argument a model is most likely to omit or invent, because it never +appears in the user's request. + +**Measured, 2026-08-11 (APIV2.md §8.34).** The prediction above is right +about *which* argument and wrong about the failure mode: `space_id` is the +argument a small model most often **mangles**, not the one it omits. A space +id is two dot-joined parts (`bafyrei….28y6mgnwgodt7` — CID plus base36 +replication key, and the suffix is load-bearing: it is what +`nodeconf.ReplKey` hashes to pick the responsible nodes). `gemma4:e4b` +truncates it at the dot, plausibly reading the suffix as a file extension: +**83 of 93 `find` calls** across its wrapper attempts in run +`20260810-235748`, **zero** mangles in any other argument of any other tool, +and 2/12 passed on `wrapper/large` against 8/10 on an ops arm whose tools +take **no** space id at all. §8.34 repairs the refusal so the mistake is +recoverable; Phase 9 removes the argument from the routes that do not need +it, which is the only version of this that also removes the mistake. Note +the scope limit: the routes that keep `space` (`find` above all — the very +call that produced these numbers) still take the composite id, so Phase 9 +narrows the exposure rather than closing it. + +**That scope limit is what retired it (2026-08-11).** `find` is where the +numbers came from and Phase 9 does not touch `find`. §8.35 does: it changes +what a space id *is* on the wire, so the value the model copies has no dot +to cut, on `find`, `describe`, `create` and every path param at once — and +it needs no resolver, no new route class and no D2 decision. + +**Build items:** + +1. **[build]** an `apicore` port for the resolver, carried on `V2Deps` (the + same shape as the existing object adapters). +2. **[build]** space-optional routes (`GET /v2/objects/{object_id}` and the + object-addressed mutations), resolving the space before anything else. + **Use `ResolveSpaceIdWithRetry`** — the binding is eventual, so a plain + resolve immediately after a create will intermittently 404, which is the + worst class of bug to ship on the commonest agent flow. +3. **[build]** grant enforcement **after** resolution. `ensureSpaceGrant` + reads the space from the path param today; a space-less route needs a + new class in the fail-closed route registry (§8.10), or the conformance + walk refuses it outright — which is the M1 trap in a new form: an + unregistered route class is exactly what that registry exists to catch. +4. **[decide]** the error for an object in a space the key does not hold: + 403 naming the space confirms the object exists somewhere; 404 hides it. + Enumeration is not a real threat against 59-char CIDs, so the lean is + 403 with the grant message — but it should be a deliberate call. + +**Sequencing note.** A free partial exists first: the wrapper's `find` +already returns handles, so it can remember the space each handle came from +and fill the path itself — no API change, covers the dominant +find → read → edit flow, fails only on a cold-pasted id. Do it in the API +anyway, for the same reason locators belong there: one implementation +serves the CLI, both MCP tiers, raw HTTP and third-party SDKs, and the +wrapper-side version helps nobody who is not using the wrapper. + +## 11. Documentation architecture — the actual deliverable + +The completeness decision was made *for* the documentation, so the doc plan is +part of the spec, not an afterthought. Three audiences, three artifacts, one +source of truth each — and every artifact must be generated or test-pinned, +because this project has already been bitten twice by hand-maintained +artifacts drifting from the code they describe (the Phase-5 GBNF accepted +strings its own parser rejected; nine of eleven served examples were +ungeneratable under their own served grammar). + +| Audience | Artifact | Source of truth | Anti-drift mechanism | +|---|---|---|---| +| Humans (developers, integrators) | OpenAPI document + a narrative guide | Swagger annotations on the v2 handlers (already present on all seven v2 handler files) | `make openapi` in CI; the conformance test of §10.1(6) | +| Agents at runtime | `GET /v2/schemas/{kind}` — the nine discovery kinds, the filter grammar, per-kind examples | The Go types and the shipped validators | The Phase-5 pattern: every served example is asserted to be accepted by its own served schema/grammar | +| Agents via the CLI | `anytype tools` manifest + `SKILL.md` | The one Go tool table (`wrapper.Tools()`) | `TestToolCount`, `TestOneDefinition`, the GBNF acceptance suite | + +Two rules follow, and they are the ones worth enforcing in review: + +1. **No artifact describes the API from memory.** If a document states a + behavior, either it is generated from the code that implements it, or a + test fails when the two disagree. Prose that cannot be pinned should + describe *intent* (why a surface is shaped this way), never *contract*. +2. **One vocabulary across all three.** The same concept keeps the same name + in the OpenAPI document, the discovery kinds, and the tool manifest — + `property`, not `relation`; `type`, not `objectType`; option *names*, not + ids. A reader moving between artifacts should never have to translate. + +Open build item: the narrative guide has no home yet. The candidates are a +generated docs site (consistent, unloved) or a hand-written `README`-style +guide under `core/api/` that the conformance test keeps honest about routes +but not about tone. Recommendation: the latter, kept deliberately short — the +OpenAPI document is the reference, and the guide's job is orientation. diff --git a/core/api/APIV2_SURFACE_REVIEW.md b/core/api/APIV2_SURFACE_REVIEW.md new file mode 100644 index 0000000000..cf7194d355 --- /dev/null +++ b/core/api/APIV2_SURFACE_REVIEW.md @@ -0,0 +1,584 @@ +# API v2 — Assembled-Surface Review Triage + +2026-08-07 · branch `go-7383-apiv2-phase0` · synthesis of seven independent reviews +(sweep:dialect, lens:edit, lens:grant, lens:query, lens:surfaces, lens:agent, lens:tests) +of the assembled Phase 0–7 surface + scoped keys + the layout move. +Every MUST-FIX item below was re-verified in source by the synthesizer (file:line re-read, +mechanism confirmed) before promotion; items two lenses found independently are marked +**[independent ×2]**. Tags: **[R]** = reproduced by at least one lens, **[read]** = read-only +conclusion. Verdicts on the two previously-open findings: **C4 still open** (and wider — +`setCell` strips the whole table), **E8 still open** (and wider — the entire PUT commit path +is untested, confirmed by mutation testing in two lenses independently). +*(2026-08-10: the PUT half of E8 is retired rather than fixed — APIV2.md +§8.27 removed the surface, so `ResetObject`, `preserveEditorOwnedState` +and their untested guards no longer exist. E8's PATCH half — no change-set +assertion on `sb.Apply` — stands.)* + +--- + +## Verdict + +The assembled surface is strong precisely where the phase reviews concentrated: one row +builder feeds objects/search/sets/collections; one filter codec backs both filter forms; the +scoped-key enforcement could not be defeated by a dedicated adversarial lens (route/registry +bijection exact, fail-closed branches all held under probing, JsonAPI scope denied on all +gRPC methods, no token echo, grant-narrowing eviction wired); the marks bridge round-tripped +thirteen adversarial cases exactly; batch atomicity, If-Match re-check under lock, and C8/C9 +wiring are genuinely there on every mutation route. + +The defects worth fixing cluster in four seams no phase review could see: + +1. **The edit surface meets the restriction system**: sets and collections are entirely + un-editable through v2 — `addItems`/`removeItems`, the only membership write, is dead — + and every refusal in that family surfaces as a retryable 500 instead of the documented 403. +2. **Refusal classification is string-matching on unpinned middleware English**: the chat + edit path's foreign-message refusal and every file-upload failure fall through to + retry-looping 500s; one covering test is green against a string the code path cannot produce. +3. **The oldest surface never got the newest discipline**: Phase-2 bodies bind non-strict and + unbounded while the served discovery schemas promise `additionalProperties:false`; the + structured `filters` array silently compiles two malformed shapes to match-everything; the + generated OpenAPI documents the conventions only on the newest half of the routes. +4. **The safety contracts are real but unpinned**: mutation testing showed C9 dry-run key + drift, removal of the C8/rate-limit middleware from a write route, and the E8 Apply-flags + revert all pass the full `./core/api/...` suite; /v2 has zero end-to-end coverage (the one + real-account fixture constructs the server with empty `V2Deps{}`). + +Nothing found crosses the grant boundary, leaks a non-granted space, or loses committed data +on the happy path. Fix section 1, pin section 2's top item, write the top five charter tests, +and Phase 8 can proceed on a surface that is honest about the rest. + +--- + +## 1. MUST FIX before Phase 8 + +### M1. Sets and collections cannot be edited through v2 at all — `addItems`/`removeItems` are dead in production **[R]** + +- **Where**: `core/block/restriction/object.go:43-44` (`objRestrictEdit` — which contains + `Restrictions_Blocks` — assigned to layout `set`, and to `collection` minus TypeChange only), + `core/api/objectmutateadapter.go:135-144` (`checkObjectEditable` refuses on Blocks OR + Details), `core/api/objectreadadapter.go:63` (`EditRefused: checkObjectEditable(sb)`), + `core/api/v2/service/edit.go:77-79` (returned before any op runs). +- **Failing input**: `PATCH /v2/spaces/{s}/objects/{collectionId}` with + `{"ops":[{"op":"addItems","items":["objA"]}]}` → refused. Reproduced at the restriction + layer by lens:edit (`GetRestrictions(...TypeKeyCollection).Object.Check(Restrictions_Blocks)` + → `restricted: Blocks`; same for TypeKeySet; plain page nil). Verified in source by synthesis. +- **Why it matters**: `addItems`/`removeItems` is the ONLY v2 route that puts an object into an + existing collection (router has GET reads + POST create, nothing else), and + `APIV2_SURFACES.md` §6 explicitly retires v1's `AddObjectsToList` in its favour. A collection + is write-once: seedable at POST (creator adapter, different path), immutable after. The same + gate also blocks `setProperties` (renaming a set/collection) even though + `Restrictions_Details` is NOT set on those layouts. No phase review saw it because every + v2/service test mocks the mutator. +- **Fix**: make the gate per-op instead of per-request — check `Restrictions_Details` for + `setProperties`/`addItems`/`removeItems` and `Restrictions_Blocks` only for block ops + (`addItems` mutates the collection store, not blocks); or route the item ops through + `mw.ObjectCollectionAdd/Remove` as v1 does. Add an adapter-level test driving a REAL + collection smartblock through `addItems` (charter E4). + +### M2. Permanently-refused writes surface as retryable 500s on three surfaces — agents retry-loop forever + +The spec promises 403 (`APIV2.md:1278` for restrictions; the chat handler's own swagger text +for foreign edits). `RespondV2Error` (`core/api/v2/handler/error.go:19-23`) turns any +non-`*v2model.Error` into 500 `internal_error`. Three independent producers hit that fallback: + +- **(a) Restriction refusals on PATCH/PUT** **[R]**: `edit.go:78` returns `cur.EditRefused` + bare (a `fmt.Errorf` wrapping `restriction.ErrRestricted`); the mutator error path wraps in + `mapReadError` whose fallback is `fmt.Errorf("read object %s: %w", …)` + (`core/api/v2/service/object.go:142`) — a read-shaped 500 for a refused write. Combined with + M1, every collection PATCH is an infinite retry loop. (lens:edit + lens:tests, independent.) +- **(b) Editing another member's chat message** **[R]**: the edit path emits + `errors.Join(storestate.ErrValidation, "can't modify someone else's message")` + (`core/block/editor/chatobject/chathandler.go:246`; `ErrValidation` = `"validation"`, + `storestate/error.go:7`), surfacing as `"push change: validation\ncan't modify someone + else's message"` — matches NONE of `v2ChatRpcError`'s arms (`"validate:"` needs the colon, + `"not own message"` is the DELETE wording from `chathandler.go:193`) → 500 + (`core/api/v2/service/chat.go:631-645`, verified in source). **The covering test is green + against behavior that does not exist**: `chat_test.go:482-504` feeds the DELETE string + `"can't delete not own message"` into the EDIT path. +- **(c) Every file-upload failure** **[read, pinned by the existing test]**: `mw.FileUpload` + has exactly one error branch, always `UNKNOWN_ERROR` (`core/file.go:152-154`); + `UploadFile` wraps it bare (`core/api/v2/service/file.go:40-42`) → 500 for a bad URL, an + unreachable host, an oversized file alike. `file_test.go:68` pins the raw string, not a code. +- **Fix**: produce the `*v2model.Error` where the verdict is made — map + `restriction.ErrRestricted` → 403 in a `mapWriteError` used by PATCH/PUT; widen the chat + forbidden arm to the edit wording (better: export the two sentinels from + `core/block/editor/chatobject` so a rewording is a compile error) and fix the test to feed + the real edit string; add `v2FileRpcError` with description arms like chat/space have. + The space classifier (`space.go:246-252`) matches the same unpinned way — pin its strings too. + +### M3. The structured `filters` array silently matches EVERYTHING on two malformed shapes **[R]** + +- **Where**: `pkg/lib/anyblockjson/dataview.go:485-497` (`Operator != ""` → group; Property + ignored; empty `filters` → empty AND = true), `core/api/v2/service/search.go:570-579` + (validator takes the same branch, walks the empty child list, `continue`), + `pkg/lib/anyblockjson/filters.go:145-147` (condition validated only when non-empty), + `pkg/lib/database/filter.go:71-73` (`Condition_None` → filter dropped). All verified in source. +- **Failing inputs** (space with 3 objects, one matching): + `{"filters":[{"operator":"and","property":"severity","condition":"equal","value":"High"}]}` + → total 3, no warning. `{"filters":[{"property":"severity","value":"High"}]}` (no + condition) → total 3. `{"filters":[{"property":"severity","conditon":"equal","value":"High"}]}` + (typoed key) → total 3. The correct form returns 1. +- **Why it matters**: this is the exact promise the surface makes ("unresolved → did-you-mean, + never a silent no-match", `search.go:16-17`) inverted into match-everything — and it is the + ONE input channel with no GBNF grammar (documented C13 exception), i.e. where a 3-4B model is + most likely to emit these shapes. The served `filters` kind schema makes shape (b) more + likely: its leaf arm marks `condition` optional (`schemas.go:154`). +- **Fix**: harden `validateStructuredFilters` (the v2 gate, not the shared codec — stored + dataviews legitimately carry `None`): reject a node carrying both operator/filters and + property as ambiguous; reject a leaf with `value` but no `condition`. Share the gate with + POST /sets (same field). Separately, `UnmarshalFilters` should refuse a group whose + `filters` array is empty rather than emitting a match-everything AND. + +### M4. Sending the Idempotency-Key that C8 mandates caps file uploads at 10 MiB with a misleading 413 **[R, independent ×2]** + +- **Where**: `core/api/v2/middleware.go:193-201` (`io.ReadAll(io.LimitReader(body, + MaxRequestBody+1))` on the RAW multipart body when a key is present; 413 above 10 MiB), + `core/api/v2/router.go` `registerCreateRoutes` (POST `/files` carries `idempotencyMW`). + Verified in source; reproduced independently by lens:surfaces and lens:tests. +- **Failing input**: 11 MiB multipart POST `/v2/spaces/{s}/files` — without the header → 201 + (`size:11534336`); with `Idempotency-Key: k1` → 413 `request_too_large` naming the body, not + the header. C8 says every mutation honors the key with no exceptions, so the disciplined + agent is exactly the caller that cannot upload >10 MiB — and the error steers it to shrink + the file, never to drop the header. Secondary: every keyed upload ≤10 MiB is buffered whole + in RAM and re-parsed by multipart. +- **Fix**: skip body buffering for `multipart/*` — hash method+path+query (+ Content-Length or + a caller digest) and leave the body streaming; or move idempotency inside the upload handler + keyed on the staged file's digest; or exempt the route and record the C8 exception in + APIV2.md §1 (which currently claims none). + +### M5. Create-missing is unbounded: one FAILING PATCH permanently created 5,000 tag options **[R]** + +- **Where**: `core/api/v2/service/resolver.go:171-219` (`prewarmCreateMissing` — no cap on + array length, no `ctx.Err()`, one `ObjectCreateRelationOption` RPC per unresolved name, no + rollback; verified in source). The skip covers add∩set but not set∩unset — the probe never + reads `unset` — so `{"set":{"tag":["Q3"]},"unset":["tag"]}` creates the option and then 400s. +- **Failing input**: PATCH with + `[setProperties{set:{tag:[5000 unknown names]}}, updateBlock{id:"doesNotExist"}]` (~60 KB) + → op 2 is a 404, the batch fails, and 5,000 real option objects exist in the space. Scaled + to the 10 MiB cap: 10^5–10^6 permanent objects from one erroring request. v2 has no + option-delete surface, so a hallucinated tag array (or a retry) pollutes the space + irreversibly. APIV2.md documents the leak-on-validation-failure trade-off, but not that it + is unbounded. +- **Fix**: cap create-missing side effects per request (a few dozen) and reject above it with + a path-addressed error BEFORE any create RPC; honour ctx cancellation inside prewarm; extend + the skip to any key claimed by more than one of set/unset/add/remove (mirroring `claim()`). + +### M6. The five Phase-2 JSON bodies bind non-strict and unbounded, contradicting the served `additionalProperties:false` schemas **[R]** + +- **Where**: `core/api/v2/handler/create.go:218,248,302,332,380` (`c.ShouldBindJSON`; gin's + `DisallowUnknownFields` is nowhere set) vs `core/api/v2/service/schemas.go:59-90` (the served + `property`/`set`/`collection`/`file` kinds all declare `additionalProperties:false`, plus + name/key/url bounds nothing enforces). Both halves verified in source. The idempotency cap + only engages when the header is present (`middleware.go:188`), so a keyless request to these + five routes is read unbounded — the very hazard `search.go:25-30` documents as the reason + for its own cap. +- **Failing input** (reproduced by sweep:dialect against the real binder): + `POST /v2/spaces/{s}/properties` with + `{"name":"Priority","format":"select","option":[{"name":"High"}],"bogus":123}` → 200-path, + `Options` empty — the typo silently drops agent intent while GET /v2/schemas promises a + rejection. A 1 MiB name and key `"my key!!"` are likewise accepted though the schema declares + them invalid. Bind errors also break the C6 dialect (gin text, no `issues`). +- **Fix**: route the five handlers through `decodeStrictJSONBody` (per-surface caps for free — + closes the unbounded-body hole in the same edit), and enforce the advertised bounds from the + same constants the schemas serve (the space service at `space.go:90-98` is the in-repo + precedent, comment and all). + +### M7. One PATCH can hold the object lock for tens of minutes — the 512-op cap bounds the ops, not the document they inflate **[R]** + +- **Where**: `core/api/v2/service/edit.go:37-40` (the cap's own comment names O(ops×document)), + `stateops.go:189-201` (every mutating op invalidates the view; the next op re-marshals the + WHOLE document under the smartblock lock). +- **Failing input** (reproduced by lens:edit): a 1,004,532-byte body — one `insertBlocks` of + 24,000 paragraphs + 400 trivial `replaceText` ops — spent **36.8 s** inside PatchObject; + the same shape at 175 KB took 3.0 s (confirming the product). Extrapolated to the 10 MiB + cap × 512 ops: ~15–20 minutes of held lock from one HTTP request, during which ObjectOpen, + sync and every RPC on that object block. `ctx.Err()` only helps if the client disconnects. +- **Fix**: cap total blocks per PATCH (the markdown channel already has + `v2MaxMarkdownBlocksPerOp=256`; the blocks channel has none) and/or the post-op document + size; cheaper long-term: maintain the block index incrementally instead of re-marshalling + per op. + +--- + +## 2. SHOULD FIX — real but survivable + +### S1. Pin the three unpinned safety contracts (C9, C8-binding, E8) — mutation testing passes today **[R, E8 independent ×2]** + +Not live bugs (lens:tests probed the real applier over a real smarttest child state: change +sets are minimal and correct today), but the entire safety story is convention, and Phase 8 +adds routes. Three mutations leave `go test ./core/api/...` fully green: + +- **(a) C9 key drift**: `apiv2.dryRunKey = "dry_run"` (`middleware.go:260`) and + `v2handler.v2DryRunContextKey = "dry_run"` (`handler/create.go:23`) are two independent + literals across a package boundary (verified in source; v2handler cannot import apiv2). + Drifting one makes **every** `?dry_run=true` a real committing write — deletes blocks, + deletes chat messages with their irreversible attachment GC — and nothing fails. + Fix: one shared constant (v2model or a tiny ctx package) + a router-level test: mutation + route with `?dry_run=true` → `dry_run:true` AND the creator/mutator mock never called. +- **(b) C8/rate-limit binding**: deleting both `deps.WriteRateLimit` and `idempotencyMW` from + `PATCH …/objects/:object_id` passes the suite. The grant conformance walk pins authz per + route but nothing pins the middleware chain. Fix: extend the walk — for every + `RouteVerbWrite` registry entry, send twice with one key and assert + `Idempotency-Replayed: true`, and 409 on a mutated body. +- **(c) E8 wider than recorded**: `sb.Apply(st)` → `Apply(st, NoRestrictions, DoSnapshot)` + green (`objectmutateadapter.go:72`); deleting `preserveEditorOwnedState`, + `checkObjectEditable` or `guardBundledRevision` from `ResetObject` (`:86-130`) each green — + there is no `TestResetObject` anywhere. *(2026-08-10: `ResetObject` and + `preserveEditorOwnedState` are gone — §8.27 — so three of the four + untested guards no longer exist; `checkObjectEditable` and + `guardBundledRevision` survive on the PATCH path, where `TestMutateObject` + covers both. The `sb.Apply` mutation is still green.)* Root cause verified in source: the mutator mock + builds a fresh ROOT state via `NewDocFromSnapshot` (`edit_test.go:64-72`) where production + hands a CHILD state, so ApplyState diffs nothing and restrictions never run in tests. + Fix: an `expectMutateLive` fixture over smarttest + assertions on `st.GetChanges()` (no + RelationRemove/BlockDelete/snapshot beyond what the ops named). Charter E4 is the e2e twin. + +### S2. `moveBlock`/`insertBlocks` can orphan a column at the document root; normalization then destroys the 2-column layout **[R]** + +`pkg/lib/anyblockjson/validate.go:565-566` checks containment one-directionally only ("a row +block can only contain column blocks" — verified; nothing requires a column's parent to be a +row). Reproduced at both layers by lens:edit: root-append +`{"op":"moveBlock","id":"colOne1"}` passes the R5 net; at the state layer the row collapses +(`normalizeLayoutRow`), the sibling column is unlinked, and a bare column persists at root — +a shape the editor never produces. No content lost; the user's layout is destroyed by an op +that asked to move one block. Fix: add the missing direction to the anyblockjson containment +check (fragment + post-op net both inherit it) and/or refuse moves that re-parent a column +outside a row. E2E: reopen such a page in the app (charter E10). + +### S3. C4 is wider than recorded: `setCell` strips editor-owned block state from EVERY cell of the table **[R]** + +C4 (updateBlock merges on exported JSON — `stateops.go:832-862`, "merge on the JSON shape" +comment verified in source) is recorded open; the NEW fact: `setCell` (`stateops.go:1219-1253`) +rebuilds the whole table through the format and `replaceLive` overwrites every produced block, +so an untouched cell's `Restrictions{Edit:true}` vanished in lens:edit's 2×2 repro. The C11 +marshal guard cannot see it (exporter warns only on indent clamps and unmapped types). Fix: +merge non-format fields (at minimum Restrictions) onto the live block by id in +`replaceLive`/`setBlocks`, and extend the guard to fail a PATCH that would drop a field the +caller never named. + +### S4. `?fields=` is validated everywhere except GET /v2/spaces/{s}/objects — the C5 canonical list **[R, independent ×2]** + +`object.go:416-427` passes `fields` straight to the row builder (verified in source — no +`validateListFields` call), while POST /search 400s with did-you-mean and the set/collection +reads validate; `APIV2.md:1369-1371` states the rule as a contract. `?fields=statuss` → 200, +every row lacks the key, indistinguishable from "no value set". Fix: one call to +`s.validateListFields(spaceId, fields)` after `ensureSpace`; add the case to the guard test +pinning the other three entry points. + +### S5. The shared pagination middleware answers in gin's envelope, pre-auth, and is lax and strict at once **[R]** + +`core/api/pagination/pagination.go:24-46` (verified): `limit=0`/`limit=1001` → +`{"error":"limit must be between 1 and 1000"}` — no status/code/issues, unparseable by a C6 +client; `limit=abc`/`offset=-1` silently coerce to defaults; and it is the FIRST middleware in +the /v2 group (`router.go:69-76`), ahead of Auth — so an unauthenticated caller gets 400 on a +real route vs 404 on a non-route, a route-existence oracle and a hole in the "every /v2 route +401s a credential-less request" property. Fix: a v2 pagination middleware (or an OnError hook) +emitting `v2model.ValidationFailed` with `Issue{Path:"limit"}`, rejecting non-numeric/negative +values the same way, installed AFTER `deps.Auth`. + +### S6. `ensureSpace` lacks the liveness predicate — a deleted/left space stays fully addressable on ~20 routes **[R]** + +`service.go:99-113` checks only `GetSpaceViewDetails` existence (verified in source); +`isLiveSpaceView` guards exactly three call sites (`discovery.go:58`, `space.go:79`, +`search.go:768` — verified by grep). Reproduced by lens:grant through the real engine: a +granted space with `spaceAccountStatus=SpaceDeleted` → `GET /v2/spaces` empty, +`GET /v2/spaces/{id}` 404, but `…/types`, `…/objects`, `…/chats`, `POST …/search` all 200 — +and `SpaceIndex(spaceDead)` is minted as a side effect, the very thing the guard's doc comment +exists to prevent. Writes fail deep with 5xx instead of the clean 404. Fix: move the liveness +check into `ensureSpace` (hence `ensureSpaceWrite`) so the predicate is one choke point. + +### S7. Every /v2 request — including unauthenticated ones — warms v1's account-wide caches that v2 never reads **[R]** + +`router.go:75-76`: `deps.CacheInit` before `deps.Auth` (verified in source). Reproduced: +`GET /v2/spaces` with NO auth header → 401, but `crossSpaceSubService.Subscribe` already ran — +four cross-space subscriptions over EVERY space, pre-credential. `V2Service` is constructed +without the v1 service (`service.go:50`), so the caches are pure cost. Fix: drop `CacheInit` +from the /v2 group. + +### S8. Discovery artifacts on the primary generation target are self-inconsistent (ops schemas) **[R]** + +Two verified-in-source defects on `/v2/schemas/ops/{op}`, the surface built for constrained +decoding: **(a)** all 10 ops serve a single-op schema (`opSchema` — `additionalProperties:false`, +required op fields) with an ENVELOPE example (`{"ops":[…]}`, `schemas_ops.go:67-148`) — the +example fails its own schema (lens:agent validated all 22 kinds: the 10 ops are the only +failures); an agent constraining on the schema emits a bare op and gets a 400 whose hint talks +about If-Match. **(b)** C13 break: `columns`/`rows` in the shared `$defs/block` +(`schemas_ops.go:65-66`) and `set.views` (`schemas.go:75`) are arrays with NO `items` — the +repo's own search-kind test states why that poisons strict decoders, but covers one kind. +Also: `updateBlock.set` has no `additionalProperties` (unrecorded C13 exception), and the +collection/addItems/removeItems examples carry truncated `bafyreieqh63jv…` ids with a literal +Unicode ellipsis. Fix: wrap the op schemas in the `{"ops":[…]}` envelope; give the three +arrays `items`; hoist the strictness assertion into a loop over ALL kinds and ops; add +example-validates-against-own-schema to `schemas_ops_test.go`; full-length synthetic ids. +(Related decision on the `space` kind: D5.) + +### S9. The generated OpenAPI is a two-dialect document — the conventions its preamble promises are declared only on the newest half **[read, independent ×2]** + +Verified against handlers + the generated document by two lenses with matching counts: +Idempotency-Key declared on 8 of 21 mutations (missing from all 13 Phase-2/3 writes incl. +PATCH/PUT objects); `dry_run` missing on PATCH/DELETE types and properties though all four +honor it; offset/limit missing on 5 paginated lists; the five typed DTOs +(CreateProperty/UpdateProperty/CreateSet/CreateCollection/UploadFile) exist but bodies render +as bare `{"type":"object"}`; POST /files documents neither the `file` form field nor the JSON +`{url}` mode; `SchemaKindV2Handler`'s description lists 9 kinds of 16 served; the brand-new +scope/grant 403s (`space_not_granted`/`write_not_granted`, WWW-Authenticate, two different +envelopes) appear nowhere but whoami. A generated client loses retry-safety, dry-run and +paging affordances on exactly the oldest routes — C12 broken at the parameter level. Fix: one +annotation pass + `make openapi`; then close the drift channel — add +`make openapi && git diff --exit-code core/api/docs` to the CI codegen step +(`.github/workflows/test.yml` runs `make generate` only — verified claim by lens:tests) and a +test asserting embedded-document paths == engine route table. + +### S10. Global search: the materialization bound guards `offset` but `need = offset+limit` is what fans out **[read]** + +`search.go:821,845`: `runSearchQuery(space.id, plan, 0, need)` per space with `need` up to +3000 while `maxGlobalSearchOffset=2000` checks offset alone — 50% past the bound on every +granted space simultaneously (~234k detail structs on a 78-space account). Fix: bound +`offset+limit`, or a per-space fetch cap reported through C11 warnings. + +### S11. Full-text `total` is a moving lower bound asserted as fact in the C10 steering message **[R]** + +The lower-bound mechanics are documented (`APIV2.md:1421-1424`); NEW: `search.go:125-139` +renders it as "26 matches — showing 25…" and the number grows per page (26 → 51 → 76 over 120 +real matches), so "total/limit" planning under-fetches ~5×, and nothing on the wire +distinguishes exact from clipped. Fix: "at least %d matches" + a `total_is_lower_bound` flag +or C11 warning when `len(all) == offset+limit+1`. + +### S12. The idempotency store is keyed by (space, key) — never by credential **[R, independent ×2]** + +`middleware.go:219-221` (verified: `store.begin(spaceId, key)`, hash covers method/path/query/ +body, never the caller). Two credentials sharing a client-chosen key collide: the second +caller's write silently never executes and it receives the first caller's stored body with +`Idempotency-Replayed: true`. Contained today (the grant gate runs before replay, so no +cross-space leak), and the shipped wrapper mints random keys — but deterministic third-party +keys ("create-doc-1", a date) are a natural pattern. Fix: fold the key id (already on the ctx +for whoami) into `idempotencyStoreKey`. One line. + +### S13. Chat edit-path merge holes: blocks-composed messages and replyTo **[R]** + +Three small verified defects in `EditChatMessage` (`chat.go:267-281`): **(a)** text-clearing +is refused when `len(Attachments)==0` even though chatmodel accepts blocks-only messages — +v2's own read shows the message non-empty (`blocksText`) while v2 refuses to edit it to that +state; fix: include `len(existing.Blocks)` in the emptiness test. **(b)** the edited proto +never carries `ReplyToMessageId` — the reply survives ONLY because `storeObject.EditMessage` +modifies the content key alone (`chatobject.go:583`, verified in source), a middleware detail +v2 does not control and no test asserts; fix: one line + assert in the merge test. **(c)** +`replyTo` is the one reference AddChatMessage never resolves — a dangling id is accepted +silently; fix: resolve via `getChatMessageProto` like edit/delete/reaction do. + +### S14. Edit-surface polish: empty-list residue and delete-then-recreate **[R]** + +**(a)** `setProperties` remove of the last entry leaves `key: []` instead of unsetting +(`stateops.go:769` — unconditional `SetDetail`, verified): present-but-empty ≠ absent in the +§3 presence contract. Fix: `RemoveDetail` when `kept` is empty. **(b)** reusing a block id +deleted earlier in the same batch is rejected as "duplicate … already exists" — +`checkFreshIds` tests `a.st.Exists` (`stateops.go:453`, verified) and `deleteBlock` only +unlinks; the natural delete-and-recreate pattern fails with an actively wrong message. Fix: +track ids unlinked by this PATCH as free. + +### S15. Small dialect nits (four one-liners) **[read]** + +- Oversized POST /v2/validate → 400 `validation_failed` instead of 413 `request_too_large` + (`handler/validate.go:35-37`) — the one surface off the shared code. +- `?outline=1`/`True` silently coerces to false (`handler/object.go:37` string-compare) while + `dry_run` 400s on the same input — use the tri-state parse. +- C8 replay drops the ETag header (`middleware.go:249-254` stores status/CT/body only); body + etag survives. Store and restore the header. +- Member role `no_permissions` is the lone unrecorded snake_case enum in a v2 body + (`service/discovery.go:164`) — see D4. + +--- + +## 3. DECISIONS, not fixes + +### D1. If-Match on type/property mutations: honor it or stop advertising it + +GET types/{t} emits ETag + envelope etag; PATCH returns a fresh etag — the full C7 vocabulary — +yet none of the four type/property mutation handlers reads If-Match (verified: zero `If-Match` +occurrences in `handler/create.go`) and no exemption is recorded (chats: APIV2.md:1713, +spaces: APIV2.md:2043 — types/properties absent). Stale-etag PATCH → 200 last-write-wins. +**Recommendation**: honor it (compare against `ComputeEtag` of the object's heads, 409 on +mismatch) — these surfaces already ship every other half of C7; recording an exemption on a +surface that actively emits etags would be the confusing choice. + +### D2. Set/collection reads: apply the base row scope or record the exemption + +`list_read.go:206-260` never appends the layouts/no-template/no-hidden triple that both +SearchObjects and ListObjects apply — same space, same type: set read returns 4 rows (incl. a +hidden object and a relation row), search returns 2 **[R]**. v1 behaves identically, so it is +a v2-internal divergence, not a regression; APIV2.md:1340 records the base scope as a property +of "the query surface" without noting the opt-out. **Recommendation**: append +`appendBaseRowScope` in `listObjects` (with a documented exception when the stored view itself +filters on isHidden/layout); if instead the raw behavior is wanted, say so in §8.4 and both +route descriptions — today code and spec disagree silently. + +### D3. `GlobalAuthExempt` is the single door out of the fail-closed registry — make it self-limiting before Phase 8 + +No current defect (the walk verifies the class behaviorally; verified fail-closed everywhere +else). But the procedure for shipping an unauthenticated /v2 route is: register outside the +group + classify auth-exempt, and CI stays green — and Phase 8's file byte-download and chat +SSE stream are exactly the streaming routes someone registers outside the gin group. +**Recommendation**: pin the auth-exempt set to a literal allowlist (the two docs paths) and +make the conformance walk FAIL on any new route carrying the class, so a third is a reviewed +edit, not a passing test. + +### D4. One write-rate budget for all credentials, and one snake_case role + +The write limiter keys on RemoteAddr — always 127.0.0.1 — so all keys share 1 write/s burst 60 +(`server/router.go:24-25`, `server/middleware.go:329-338`); the multi-key scoped model is +precisely several agents at once, and one bulk edit starves the rest ("the API randomly +429s"). **Recommendation**: key on the session/key id (resolved before the per-route limiter +runs). Separately `no_permissions` (S15): rename to `none` now while v2 is unreleased, or +record the carve-out next to dry_run/has_more. + +### D5. The `space` kind describes two contracts with one schema + +`schemas.go:103-108`: `required:["name"]` but the endpoint string says PATCH takes the same +fields "both optional — at least one" — a model generating a PATCH under the schema can never +change only the description, and minProperties:1 is expressible in neither. +**Recommendation**: split `space` / `spaceUpdate` (or drop `required`, add `minProperties:1`). + +--- + +## 4. ACCEPT / RECORD + +- **C4 remains open as recorded** (updateBlock merges on exported JSON, `stateops.go:832-862` + verified unchanged): Restrictions, exotic Fields kinds and int64 precision vanish on the + touched block. S3 records the new blast radius. Record both in APIV2.md until fixed. +- **Global search is an N+1 over spaces for any non-bare query** (~300 store queries on a + 78-space account: knownPropertyKeys + aliases + type resolution per space, + `search.go:179-232`), and the merge comparator is built from the FIRST space's plan + (`:843,862`) — under a shadowed alias (`size`→`sizeInBytes` active in one space only) the + comparator reads a key other spaces' rows don't carry and their rows fall to the id + tiebreak; `_final_score` is likewise compared across independent BM25 indexes. Record; cache + per-fan-out and per-origin translation are the eventual fixes. +- **Collection reads with no stored sort materialize the whole membership per page** + (`list_read.go:233-241`, deliberate for the honest total): O(n²/limit) to page a 50k-member + collection to the end. Record the cost in APIV2.md §8.4; bound or approximate past a size + threshold when it bites. +- **Multipart staging has no size cap and writes the body to disk twice** + (`handler/create.go:405-443`; shared with v1; authenticated, localhost). Also `?dry_run=true` + stages the whole file before "uploaded nothing". Record; `http.MaxBytesReader` + advertised + cap + dry-run short-circuit when touched next. +- **POST …/read answers an unqualified 200 even when it marked zero messages** (stale + lastStateId → strict subset marked; `markedCount` is discarded at + `core/block/chats/service.go:811-829` and the RPC has no field). Not fixable in v2 alone — + record on the endpoint description; plumb `marked` through the RPC when the middleware is + next touched. +- **The C8 store is a 1024-entry in-process LRU** — replay protection has an eviction horizon + and does not survive restart. By design; record next to C8. +- **/v2 has zero end-to-end coverage**: `tests/integration/chat_test.go:107` constructs the + server with empty `V2Deps{}` so `RegisterRoutes` returns at line one. This is the + precondition for the whole charter below — recorded here so it is never mistaken for tested. +- **Verified-good, for the record**: route/registry bijection exact both directions; empty + `:space_id`, case/whitespace/percent-encoding variants all refused; JsonAPI scope denied on + all gRPC methods (no gRPC escape from a scoped key); no token echo anywhere; ListSpaces/ + spaceRefs/whoami intersect the INPUT set (totals cannot leak); marks bridge exact on 13 + adversarial cases; batch atomicity, in-batch id addressing, If-Match re-check under lock, + duplicate-id detection, table pinning, unlink sweeps all correct; SKILL.md's filter examples + all parse and are in the served GBNF; the wrapper's 60s dedup claim matches the code. + +--- + +## 5. E2E CHARTER (ranked) + +Ranked by risk × invisibility-to-mocks. E1 is the precondition for E2–E10. + +1. **E1 — The real-account /v2 fixture.** Extract the chat_test bootstrap + (`tests/integration/chat_test.go`) into a shared helper and construct the server with real + `V2Deps` (needs exported test constructors for the three package-private adapters in + `core/api`). Why no mock can substitute: every /v2 test today is service-vs-mock or + engine-vs-mocked-services; no HTTP request has ever hit /v2 backed by a real account, store, + or CRDT tree. +2. **E2 — C9 dry-run really does not write.** For PATCH objects (deleteBlock+insertBlocks), + POST objects, POST types, DELETE properties, DELETE chat message: send `?dry_run=true`, + assert 200 + `dry_run:true`, re-read over HTTP, assert the store byte-identical. Why: + the dry-run key-drift mutation (S1a) passes the entire suite — nothing today proves the + flag reaches the handlers; the blast radius of a silent flip is irreversible deletion. +3. **E3 — C8 idempotency across a real retry.** POST objects with key K → 201 + id; resend + byte-identical → same id, `Idempotency-Replayed: true`, and a search proves ONE object + exists (the count is the real assertion); mutated body under K → 409; PATCH retry appears + once; two identical keyed POSTs concurrently from two goroutines → one object. Why: the + middleware-deletion mutation (S1b) is invisible to the suite; the begin/pending reservation + has only stub-handler tests. +4. **E4 — A PATCH that lands in the CRDT, with change-set assertions, surviving evict+reopen — + plus `addItems` on a REAL collection.** Create from markdown, PATCH a batch covering every + op kind, assert diffStats; capture the emitted change set (no RelationRemove/BlockDelete/ + snapshot beyond the ops — closes E8; the ResetObject guards it also named went away with + PUT, §8.27); restart + against the same repoDir and re-GET (title/featured row/custom relation intact, etag moved). + Drive `addItems`/`removeItems` on a real collection smartblock — the ONE test that would + have caught M1. Why: the mutator mock builds a root state; ApplyState diffs nothing in + tests, ever. +5. **E5 — A scoped key minted the REAL way.** Second space via `WorkspaceCreate`; key via + `AccountLocalLinkCreateApp{Scope: JsonAPI, Grant:{Spaces:[A], Perm: Read}}`; assert: + GET spaces/B → 403 `space_not_granted` + WWW-Authenticate; POST to A → 403 + `write_not_granted`; GET /v2/spaces lists only A; global search over [A,B] returns only A; + tech space → 403; whoami echoes [A]/read; the key on /v1 refused; then narrow the grant + mid-session (`LinkLocalUpdateApp`) and assert the very next request is refused. Why: every + current enforcement test hand-builds `ApiSessionEntry` — the sealed-app-link → + `WalletCreateSession` → `util.ApiGrant` conversion has zero end-to-end coverage. +6. **E6 — Two accounts in one shared space: foreign chat edit/delete.** Account B PATCHes and + DELETEs A's message; assert 403 with the C6 envelope on BOTH. Why: only a real second + identity produces the middleware's actual "can't modify someone else's message" string — + the mocked test fed the wrong string and stayed green (M2b). +7. **E7 — File upload, both modes, both sizes.** >10 MiB multipart with and without + Idempotency-Key (pins the M4 fix); bad URL via JSON mode (pins M2c's 4xx); attach the + uploaded file to a chat message immediately and read back — the attachment kind is inferred + from the file object's asynchronously-indexed layout, so a fast attach may downgrade + image→link (mock-invisible race). +8. **E8 — If-Match against a genuinely concurrent writer.** GET etag; mutate the object + out-of-band via gRPC (`BlockTextSetText`); stale If-Match → 412 carrying the current etag; + no header → succeeds; refreshed → succeeds. Why: `EtagMatches` is tested against string + literals — nothing proves the etag derives from heads that move when the tree does; the + spec predicts 409/412 noise under sync, and only live contention shows the rate. +9. **E9 — Chat round-trip byte-identity.** Send bold/mention/link markup, GET, PATCH the + returned text verbatim, GET: text/marks/replyTo/style/attachments/blocks byte-identical. + Why: the marks bridge is unit-exact, but nothing exercises the real store's + marshal/unmarshal in the loop — where replyTo and Style survive only by accident (S13b). + Include a cold-process first GET (chatState/lastStateId populated with no prior open) and + cursor paging while another device writes. +10. **E10 — Query surface at real scale + renderer round-trip.** (a) FT search past the + engine's 2000-doc candidate limit: does has_more terminate at the true end? (b) global + search on a many-space account at offset=2000&limit=1000: wall time, peak heap, sane + ranking (S10 + the comparator hazard). (c) page a several-thousand-row set/collection to + the end on lastModifiedDate ties — duplicates/gaps only show against real data. (d) reopen + a page after the S2 orphan-column move in the desktop client to see what the renderer does. +11. **E11 — The document and schemas against real consumers.** Generate a client from + `docs/v2/openapi.json` (openapi-generator) and run the four SKILL.md walks — the missing + parameters (S9) are only felt there; feed the served op schemas to OpenAI strict mode and + llama.cpp's json-schema-to-grammar (S8's vendor half); fetch /v2/schemas/* over HTTP and + diff against service-layer output. Plus the §10.1 v1↔v2 conformance test: nothing e2e + asserts either served surface matches its generated spec. + +--- + +## 6. What no reviewer covered + +- **Anything requiring a live account** — the single biggest gap, and the charter's reason: + real CRDT change-set contents under sync, concurrent PATCH-vs-editor merges, cache + eviction/reopen, wallet-minted grants, two-device space deletion mid-session, real file + bytes. +- **Whether opening an object on a read-classified route can commit a migration change to the + tree** — lens:grant explicitly declined to evaluate the smartblock Init path. If it can, a + read-only grant performs writes as a side effect. Worth a targeted look before Phase 8. +- **The wrapper/CLI and eval harness against the shipped surface** — SKILL.md was statically + checked (all examples parse), but no lens ran the 12 tools end-to-end, with or without a + scoped key. +- **Vendor acceptance of the served schemas** (OpenAI strict, llama.cpp GBNF conversion) and + **generated-SDK usability** — reviewed at the JSON-Schema-rule level only (E11). +- **HTTP-level serialization of the discovery payloads** — validated in-process, not the + `json.RawMessage` the handler emits. +- **Scale**: global-search memory at fan-out, FT beyond the candidate cap, 50k-member + collections — all read-only extrapolations. +- **SSE and file byte-download** — Phase 8, does not exist; D3 is the guardrail to land first. +- **Origin/host check and the analytics middleware** — no lens probed them. + +## Process note + +During lens:grant's run a concurrent agent briefly mutated this worktree +(`core/api/v2/router.go` had `ensureSpaceGrant()` removed and restored; two stray +`zzprobe_test.go` files appeared under core/api/v2/service and core/block/restriction). All +lens findings were observed with the gate intact, and the tree was verified clean +(`git status` empty) at synthesis start and end. If any finding above seems to contradict +HEAD, re-check against `aa52f3b9f` before acting on it. diff --git a/core/api/APIV2_TOKENS.md b/core/api/APIV2_TOKENS.md new file mode 100644 index 0000000000..a1a0152608 --- /dev/null +++ b/core/api/APIV2_TOKENS.md @@ -0,0 +1,610 @@ +# API v2 token economics — a measured review (GO-7383) + +Status: review v1.0 · 2026-08-08 · companion to `APIV2.md` (C3/C4/C5, §7, +§8.20–8.22) and `core/api/APIV2_ADDRESSING.md` (§7.5a, §7.6). + +Method: everything here is **measured against the running app** (a real +~16-space account) unless marked *estimate* or *sim:* (simulated offline from +the served JSON — exact for the field edits applied, e.g. deleting `id` +keys). Tokenizers: **o200k_base** via tiktoken (the GPT-4o/o-/5-family +count), and the **served `gemma4:e2b` tokenizer** measured as +`usage.prompt_tokens` deltas on the live Ollama endpoint — real counts, not +estimates. Gemma counts run 5–25 % higher than o200k on this JSON; every +*ratio* below holds under both, so tables quote o200k and call out gemma +where it matters. bonsai-27b's tokenizer was not measured (see §7's +availability note). Model evals ran real tool-calling against +`gemma4:e2b`, `gemma4:e4b` and `gpt-5-mini`/`gpt-5` (OpenAI). Measurement +scripts lived in the session scratchpad and are not committed; every number +is reproducible from the live account + this description. + +Corpus — real documents from the live account (plus two documents created +through the API for the evals; the account's seeded docs carry +human-readable block ids, API-minted ones carry 24-hex — flagged where it +matters): + +| tag | doc | blocks | refs | default read (o200k) | (gemma) | +|---|---|---|---|---|---| +| XS-props | Sales crmActivity (properties only) | 0 | 5 | 607 | 701 | +| S-12blk | Company Wiki policy page | 12 | 7 | 1 238 | 1 410 | +| M-24blk | Project Brief — Q3 Website Relaunch | 24 | 14 | 2 417 | 2 722 | +| L-66blk | "Properties" (Get Started) | 66 | 9 | 4 441 | 5 542 | +| R-20refs | Q3 2026 planning (mention-heavy) | 22 | 20 | 3 814 | 4 363 | +| K-recipe | Personal recipe, 4 UI-created BSON-key props | 31 | 1 | 1 989 | 2 391 | + +--- + +## 1. Three costs nobody had priced + +These dominate everything the knobs can do, and none of them is a knob. + +### 1.1 Default reads are pretty-printed inside — a 16–26 % tax on every GET (C3 violation) + +`anyblockjson.Marshal` emits the format's canonical byte form — **two-space +indented** (`marshalCanonical`, `pkg/lib/anyblockjson/json.go:156`). The v2 +read path (`core/api/v2/service/object.go`) splits that document into +`map[string]json.RawMessage` and re-emits a compact **envelope** whose +`properties`/`blocks`/`refs` values keep their indented bytes verbatim. So +every default object read is compact at the top level and pretty-printed +underneath, while C3 promises "compact JSON always (free 38–46 %)". + +| doc | served (o200k) | compact re-encode | tax | +|---|---|---|---| +| XS-props | 607 | 509 | 16.1 % | +| S-12blk | 1 238 | 981 | 20.8 % | +| M-24blk | 2 417 | 1 903 | 21.3 % | +| L-66blk | 4 441 | 3 271 | 26.3 % | +| R-20refs | 3 814 | 2 902 | 23.9 % | + +(gemma agrees: M-24blk 2 722 → 1 984, −27 %.) The outline and markdown +shapes are nearly unaffected (they re-encode). **Fix: compact the embedded +values at the envelope (or add a compact option to `Marshal`)** — zero +semantic change, the single largest saving in this review, and it makes +every percentage elsewhere in this document better than quoted (they are +measured against the *served* form). + +### 1.2 The refs legend loses tokens on reads — C4's default is inverted by real documents + +C4 compacts object refs by default via the `refs` legend (5-char label +inline + `"label": "<59-char id>"` legend line). That trade only wins when +a ref is used ≥ 2×. Measured usage multiplicity on the corpus: **85–90 % +of refs are used exactly once** (M-24blk: 12 of 14; R-20refs: 18 of 20; +S-12blk: 5 of 7). Result — `?ids=full` is *cheaper* than the compact +default on **every** document measured: + +| doc | compact default | `ids=full` | legend overhead | +|---|---|---|---| +| XS-props | 509 | 456 | +10.4 % | +| S-12blk | 981 | 947 | +3.5 % | +| M-24blk | 1 903 | 1 811 | +4.8 % | +| L-66blk | 3 271 | 3 194 | +2.4 % | +| R-20refs | 2 902 | 2 763 | +4.8 % | + +(compact-encoded pairs, so this isolates the legend from §1.1.) The legend +also adds an indirection: a model that wants to write an object id back +(e.g. `setProperties.remove` of one linked object) saw `"ai52e"` inline and +must dig the legend for the full id — a correctness hazard on top of a +token loss. C4's research rationale (−89 % id *transcription* errors) is +about models *generating* references — which happens in agent-authored +create documents and in ops, where **block** ids are the vocabulary +(and those are full on default reads anyway). On reads the legend earns +nothing. **Recommendation: serve full object ids inline by default; keep +legend resolution on *input* documents unchanged (SPEC §9a is total), and +keep the legend in the export/backup shape only (§6).** + +### 1.3 `format=md` mention links cost ~60 tokens each + +The markdown exporter renders every mention as +`[Name](anytype://object?objectId=<59 chars>&spaceId=<66 chars>)`. +Measured: md is 27–33 % of the default read on link-light docs but **83–84 % +on mention-heavy ones** (M-24blk: 2 036 of 2 417; R-20refs: 3 165 of +3 814) — the "cheap text mode" is nearly as expensive as the full document +exactly where documents are link-rich. Fix candidates, cheapest first: +drop the `spaceId` query param (same-space links; halves the cost), or +render `[Name](anytype:<5-char label>)` with a one-line legend appendix +(lossless, ~50 tok saved per mention). md is read-only (C11), so this is +purely a rendering decision. + +--- + +## 2. Knob inventory — what exists today, its default, and whether the default is right + +Verified against handlers/service (`core/api/v2/handler/*.go`, +`v2/service/object.go`, `v2/router.go`) and exercised live. **`pins` do +not exist anywhere in v2** — `?pins=` and the pin tables are ADDRESSING +§7.1/§7.6 design, explicitly unshipped (§7.6 steps 1–2); nothing below is +a pin. `OmitIds` exists only as an unexposed export option in the format +package — no API surface reaches it. + +| knob | surface | default | measured cost/saving | default right? | +|---|---|---|---|---| +| `?include=properties,blocks` | object GET | both | props-only 7–53 % of default; blocks-only 57–98 % (barely saves) | both-by-default is right for the edit read; `include=blocks` never earns its existence | +| `?outline=true` | object GET | off | 13–29 % of default; `+include=properties` 20–60 % on block-bearing docs (89 % on the 0-block XS doc, where properties are the document) | right as opt-in; the shape itself is the API's best token lever | +| `?block={id\|suffix}` | object GET | — | one subtree | right; keep as the orthogonal target param | +| `?ids=compact\|full` | object GET | compact | **compact is 0.5–10.4 % more expensive** (§1.2) | **wrong — flip to full** | +| `?format=anyblock\|md` | object GET | anyblock | md 11–84 % of default (§1.3) | right as opt-in; fix the mention links | +| `?fields=` | lists, sets/collections, search body | rows = `{id,name,type}` (~50 tok/row, ~30 of them the id) | +2 fields ≈ +28 tok/row | right (C5); nothing to change | +| `?offset=/&limit=` | every list | 25 / max 1000 (`v2/router.go:23`) | 15 pages ≈ 758 tok | right | +| `?view=` | set/collection reads | object's default view | — | right | +| `?reactions=counts\|full` | chat messages | counts | — | right | +| `?prefix=` | options list | — | — | right | +| `Options.CompactObjectRefs` | format pkg; ON for default reads | — | §1.2 | flip with `?ids` | +| `Options.CompactBlockLabels` | format pkg; ON only in outline | — | part of outline's 75 % saving | right (C4/T7) | +| `Options.OmitIds` | format pkg; **never exposed** | — | sim: −16…−36 % of compact read | right to keep unexposed as a raw knob; see §4 | +| `?pins=` | **does not exist** | (planned "all") | sim: +22 tok per custom key per read | planned default is wrong — see §6 | + +Defaults matter more than options — §7's eval shows the smallest model +never touches a parameter. The three defaults that are wrong (§1.1 +encoding, §1.2 ids, §6 planned pins) are exactly the ones every +untouched-parameters caller pays. + +--- + +## 3. Do we always need ids? Measured id budgets + +Per-id prices (o200k, measured): a 24-hex **block id** in a default read +costs **18.2 tok** including its `"id":` scaffolding (436 tok / 24 blocks +on the API-minted eval doc); a 59-char **object id** ≈ 30 tok; a 66-char +space-suffixed id ≈ 33. Block ids in aggregate are **8–36 % of a compact +default read** (sim:omitIds deltas: S −16 %, M −17 %, L −31 %, 48-block +K-page −36 %). The account's seeded docs carry short readable block ids +(13.3 tok/block) — real API-minted documents pay the full 18.2. + +**For a model that wants to patch ONE object** (the user quoted the +sentence to fix), the measured paths on M-24blk: + +| path | model-visible tokens | works? | +|---|---|---| +| A. GET default → PATCH `replaceText` by id | 2 417 + 33 (op) | yes — today's canonical flow | +| B. GET `?include=blocks` → PATCH | 2 252 + 33 | yes, marginal saving | +| C. GET `?outline=true` → PATCH | 582 + 33 | **no** — outline has no body text; you cannot find the word | +| D. GET `format=md` → PATCH | 2 036 + … | **no** — no block ids to address (today) | +| E. wrapper `edit_text`, `block` omitted (shipped) | **≈ 45** (call + result; server does one internal GET) | yes — verified live: unique find applies; ambiguous find refuses listing candidates (54-tok refusal) | +| F. raw-API locator op (proposed, §5) | ≈ 50–60, zero reads | — | + +So: **the raw API needs the whole id-bearing document (~2.4 k tokens) to +patch one word; the wrapper's locator does it for ~45.** The raw API has +no equivalent of E — `replaceText.id` is required +(`v2/service/stateops.go` `applyReplaceText` → `resolveRef`); that gap is +§5. Structural edits (move/delete) genuinely want ids — and outline serves +them at 13–29 % of the default read; that is the id budget an edit +actually needs. + +--- + +## 4. Id-free mode: trap or mode? + +What works with **no block ids at all** today: `setProperties`, +`addItems`/`removeItems` (object ids from search rows, not reads), +`insertBlocks` in append form (no anchors), the whole view family on +single-dataview objects (`updateView` block/view optional, columns keyed +by property key), create, search. What cannot: `replaceText`, +`updateBlock`, `deleteBlock`, `moveBlock`, `replaceSubtree`, `setCell`, +anchored `insertBlocks` — every block-addressed op. + +Measured saving of a hypothetical id-free full read (sim:omitIds, +compact): 50–66 % of the served default (M 1 583, L 2 247, R 2 438) — +real money; the 48-block K-page drops 36 %. But **without a write-back +story it is a trap**: the model +reads a document it cannot then edit at block level, and the failure +arrives one turn later as a missing-id dead end (§8.21 measured exactly +this pattern: a required-but-unknowable id made small models route around +the edit tool entirely). + +**With locators (§5) the verdict flips**: read without ids, patch by +content. The pairing is coherent for *text* edits (the majority editing +class); structural edits keep outline (which is also id-bearing and +cheap). Even then, a separate `omitIds` JSON read mode is not worth a +knob: `format=md` (mention links fixed, §1.3) is the natural id-free +reading surface at 27–33 %, and outline covers structure at 13–29 %. +**Recommendation: do not expose raw `OmitIds`; make md the id-free mode +and make it writable via locators.** + +--- + +## 5. Locators instead of ids in PATCH ops + +Proposal under review: ops accept a *locator* that must resolve to exactly +one block, instead of requiring an id. + +**Prior evidence (§8.21, live benchmark of the shipped MCP small tier):** +with `edit_text.block` required, gemma4:e4b and e2b both picked `read` +instead of `edit_text` for an explicit "change the word" task — the +rational move when the required id is unknowable on turn one. Making +`block` optional (snippet locates the block, one-match-or-refuse) took +tool selection from 7/8 and 6/8 to 8/8. The primitive already exists and +already paid off; the question is generalising it. + +### 5.1 Which ops can take one + +| op | locator form | verdict | +|---|---|---| +| `replaceText` | `find` doubles as the locator — `id` becomes optional; `under`/`nth` narrow | **adopt** — the shipped wrapper semantics, moved down | +| `updateBlock` | optional `match` (exact substring of the block's text) + `under`/`nth`, alternative to `id` | **adopt** — the checkbox-toggle case ("check 'Draft timeline' under Planning") | +| `deleteBlock` | same | adopt — destructive, so the one-match rule is load-bearing | +| `moveBlock` | `match` for the subject; `after`/`before`/`inside` anchors accept the same forms | adopt (anchors too — "after the heading 'Risks'") | +| `replaceSubtree` | same as updateBlock | adopt, lower priority | +| anchored `insertBlocks` | anchor locators only (payload has no ids) | adopt with moveBlock | +| `setCell` | col by header text, row by index/first cell — a *different* vocabulary | **defer** — rare shape, its own design; labels from full reads already work | +| view ops | already locator-ish (optional-when-unique, suffix match); accepting view *names* is the remaining gap | note, not part of this change | +| `setProperties`, items ops | address no blocks | n/a | + +### 5.2 Syntax: a small closed vocabulary, not a query language + +Ship exactly three fields: **`match`** (exact substring that must identify +one block; for `replaceText`, `find` *is* `match`), **`under`** (heading +text — restrict to that section's indent run; trivial on the flat format), +**`nth`** (1-based, document order, within scope). All flat strings/int: +C13-strict, GBNF-trivial, nothing to parse. + +Against a CSS/XPath/jq-style selector, argued from this repo's own +history: the one general parser v2 shipped (the compact filter string) +required a recursion bound (`filterstring.go:36 maxGroupDepth`), fuzzing +and grammar-pinning to close a reachable process-fatal DoS. A locator +language re-inherits that whole class for expressiveness no measured +consumer used: in §5.4 the models never needed more than `under` + the +snippet, and gpt-5-mini composed `under` correctly *unprompted*. The +failure mode of an expressive locator is a silent wrong match — the exact +thing the one-match rule exists to kill. Small closed vocabulary wins on +the evidence; revisit only if a benchmark shows tasks failing for lack of +expressiveness. + +### 5.3 Resolution semantics (the load-bearing part) + +Reuse the shipped rule, once, server-side — never a second rule: + +- **Exactly one or refuse.** Zero matches → 404-class C6 steering to the + outline read (the candidate list *is* the outline), and — from + `applyReplaceText` — "copy the find text exactly, including inline + markup". Multiple blocks → `ambiguous_input` listing ≤ 8 candidates as + block ids + ~30 chars of context (the wrapper's `locateBlock` refusal, + verified live: 54 tokens, repaired first-try in §8.21's measurements). + Multiple occurrences within the one block → the existing more-context + refusal (`nth` is the escape). Never a guess. +- **Mid-batch:** resolution runs per-op against the applier's live + document view — the same view id-suffix resolution uses + (`stateops.go` maintains it across ops; `replaceText` updates it in + place, M7) — so op *i* sees op *i−1*'s edits. Deterministic, and the + natural reading of a batch. +- **Dry-run vs apply:** both resolve at apply time under the object lock; + a C9 dry run's resolution is advisory exactly as every C9 verdict is. A + document change between dry-run and apply either creates a second match + (→ refusal, the safe direction) or removes the match (→ refusal). The + residual case — the unique match *moved* — is strictly safer than the + id equivalent: **a locator is inherently a content precondition** (the + op fires only where the text it names exists), which an id never is. + If-Match remains the strict guard (C7), unchanged. + +### 5.4 Measured: can the models actually write locators? + +Six edit tasks against two live documents (the API-minted 24-block brief +with 24-hex ids; a purpose-built two-section doc where "Budget: TBD" and +"Draft timeline" each appear twice). Arm **ID**: compact read *with* ids +in context, tool requires `block_id` (resolution = shipped +`matchBlockRef`: exact, else unique suffix; one retry on the server +error). Arm **LOC**: id-free read in context, tool takes +`find`/`under`/`occurrence` (resolution = §5.3; ambiguity refusal lists +candidates; one retry). Success = the edit lands on the intended block. + +| model | ID arm | LOC arm | notes | +|---|---|---|---| +| gpt-5-mini | 6/6 (6 first-try) | 6/6 (5 first-try) | copied 24-hex ids perfectly; used `under:"Execution"`/`"Planning"` unprompted, first try; on the one genuinely two-match task the ambiguity refusal listed candidates and the retry repaired — §5.3 working as specified | +| gpt-5 | 6/6 (6 first-try) | 6/6 (6 first-try) | | +| gemma4:e2b / e4b | *blocked* | *blocked* | see below | +| bonsai-27b-q1_0 | *blocked* | *blocked* | see below | + +**Environment fault, stated rather than papered over:** midway through +this session the remote Ollama box's Metal compiler wedged while loading +bonsai-27b (`llama-server … MTLCompilerService … Reentrancy avoided`); +from that point every local model answered 500 and did not recover within +the session (a server restart is required), so the direct gemma ID-vs-LOC +arms could not be completed. The §7 knob eval ran *before* the fault and +is unaffected. Read the frontier rows accordingly: **a 6/6 ID arm at +gpt-5 scale is precisely NOT the case locators exist for** — frontier +models copy 24-hex ids fine; the claim under test is about the small +tier, and **the direct ID-vs-LOC comparison at the small tier is +untested**. The standing small-model evidence is §8.21's earlier live +benchmark — the same comparison in tool-selection form, against the real +MCP server: with a required block id, gemma4:e4b/e2b scored 7/8 and 6/8 +and routed around the edit tool; with the snippet locator both reached +8/8. That supports "a required id makes small models avoid the edit +path"; it does not yet measure small-model *locator authoring* accuracy +head-to-head against id copying. + +*To close the gap once the box is restarted* (whoever reruns needs no +re-derivation): two arms × 6 tasks × {`gemma4:e2b`, `gemma4:e4b`, +`bonsai-27b-q1_0`}, temperature 0, one tool call + one error-fed retry. +Arm ID: context = compact default read *with* ids, tool +`replace_text(object_id, block_id, find, replace)`, resolution = shipped +`matchBlockRef` (exact-else-unique-suffix). Arm LOC: context = the same +read with block ids stripped, tool +`replace_text(object_id, find, replace, under?, occurrence?)`, +resolution = §5.3 (one-match-or-refuse; ambiguity refusal lists ≤ 8 +candidate blocks with ~30-char context). Documents: the two eval objects +left in the test account's Project Tracker space — "Relaunch brief +(locator eval copy)" (24 blocks, API-minted 24-hex ids; three +unique-match tasks: 61 %→58 %, ~140 posts→~150, 4.2s→3.9s — the last +matches two blocks, score either) and "Locator eval doc" (two sections +where "Budget: TBD" and "Draft timeline" repeat; tasks: Execution budget +→ $40k, Planning budget → $12k, "scope creep"→"schedule slip"). Success += the edit lands on the intended block; report first-try and +after-retry separately. + +Two observations beyond the scores. Context-cost asymmetry, same +documents: the ID arm's read is 1 551 tok, the LOC arm's 1 115 (−28 %); +the id op is 33 tok vs the locator op's 24; and the LOC arm composes with +§3's flow F — when the user's request already contains the anchor text, +the read can be skipped entirely. Second: in the LOC arm, where +`object_id` was incidental, even the frontier models filled it with +garbage (`"current"`, `"1"`, `"page"`, the document's *name*) — id-echo +discipline is weak the moment an id stops being the model's focus, which +is the §8.21 finding at frontier scale and an argument for locators plus +enumerated handles everywhere ids are secondary. + +**Verdict: adopt, in the reduced form of §5.2** — `find`-as-locator with +optional `id` on `replaceText`, plus `match`/`under`/`nth` on +`updateBlock`/`deleteBlock`/`moveBlock`/`replaceSubtree` and the +`insertBlocks`/`moveBlock` anchors; one-match-or-refuse with the shipped +candidate-listing refusal; setCell deferred; no selector language. The +frontier arms show locators cost nothing in correctness at the ceiling; +§8.21 shows they are the difference between routing around the edit path +and using it at the small end; and they are what turns §4's id-free reads +from a trap into the cheapest correct loop. + +### 5.5 Where it lives: the API op set, not the wrapper + +- **One implementation, every client.** CLI, MCP (both tiers), raw HTTP, + third-party SDKs — the wrapper-only version serves one of four surfaces. +- **The wrapper's version is a read-then-patch TOCTOU.** `locateBlock` + GETs the document, resolves, then PATCHes by id — the document can move + between the two. In-API resolution runs under the object lock; the race + disappears. §7.3 item 1's own principle (bounded server-side primitives, + never GET+compute+write-the-whole-document at a layer above) argues + this side — the same argument APIV2.md §8.27 applied to PUT itself. +- **The §8.21-fix-3 precedent does not apply.** Case folding went + wrapper-side because folding is *forgiveness* — it changes what a + spelling resolves to, and REST clients depend on exact-match strictness. + A locator is exact-match addressing with a hard ambiguity refusal — + deterministic, C2-clean, additive (an optional alternative to `id`, not + a change to `id`). +- The wrapper keeps `edit_text` unchanged and drops its double-read + (`locateBlock` becomes "omit the op's id"). + +Cost *(estimate)*: `resolveLocator` on the applier's existing doc view +(linear scan + indent-run scope) is tens of lines plus tests; ~15 tok per +op schema in discovery; +3 SKILL lines (§8). No new parser, no new +grammar artifact. + +--- + +## 6. Can the tuning collapse into ONE knob? + +### 6.1 The proposed ordinal (`fullIds > allPins > minPins > noPins > noIds`) — rejected as the one knob + +It linearizes only the *id-spelling* axis, and that axis is third-order: + +- The measured money is in the **shape** axis — outline (13–29 %), props + (7–53 %), md (11–84 %) — which the ordinal does not touch at all. +- Its levels mix **independent** choices the shipped surface already + treats independently: block-id spelling vs object-ref spelling (outline + compacts the former and *fulls* the latter — the C4/T7 exception exists + precisely because they must move separately), and pins (a + rename-protection legend, ~6 % — measured +87 tok on a 1.5 k read for a + 4-custom-key doc) vs ids (an addressing vocabulary, −5…−35 %). +- `allPins` vs `minPins` is a distinction below the noise floor of one + paragraph of content. And `noIds` is not a *cheaper level of the same + thing* — it changes what the document can do (write-back), i.e. it is a + different shape, not a smaller spelling. + +### 6.2 What the evidence supports: five named profiles on one parameter + +`?mode=outline | text | props | edit | full` on the object GET, default +`edit`. (`view` as a name is taken — set/collection reads use `?view=` +for stored dataview views; the wrapper's read already calls this `mode`.) + +| mode | contents | encoding | M-24blk cost (today 2 417) | +|---|---|---|---| +| `outline` | skeleton `{indent,id,type}` + heading text **+ properties** + etag | block labels, object refs full (T7 kept), compact | 773 | +| `text` | markdown envelope | short mention links (§1.3 fix); no ids | ~1 150 *(estimate after fix — 14 mentions × ~55 saved; 2 036 today)* | +| `props` | properties + etag, no blocks | full object ids | 774 | +| `edit` (default) | properties + blocks | **full block ids, full object ids inline, no legend, compact (§1.1+§1.2)** | 1 811 | +| `full` | canonical export: refs legend + full block ids + **pins when they ship** | the backup shape = `Marshal` default | ~1 900 + pins | + +- `include=` and `ids=` retire (props/edit/full cover every measured use; + `include=blocks` saved 2–7 % on content-bearing docs and §7 shows + nobody selects it); + `outline`/`format` fold in; `?block=` stays orthogonal (a target, not a + shape). v2 is pre-GA — cut clean rather than alias (ADDRESSING §7.4's + own argument: possible precisely because nothing has shipped). +- `outline` gains properties relative to today (+4–34 pp of the default + read, median ~10 — the top end is the property-rich wiki page): the + dominant outline use is orient-before-acting, and properties are where + status/assignee live — one call instead of two. The bare skeleton + disappears as a distinct shape; its saving over outline+props was + 240–460 tok on the corpus, less than the second round trip it invites. +- **Pins land in `full` only.** ADDRESSING §7.1 plans "API v2 default + read: all pins"; measured, that is +22 tok per custom key on every + read to protect a whole-document round trip that no longer has a write + leg at all (§8.27 removed PUT) and that PATCH-first agents never ran — + and PATCH inputs never consume pins (key resolution walks + the §7.5a-5 chain regardless). `full` is the document-shaped read; it + carries the protection. This review recommends amending §7.1's emission + table accordingly; `?pins=min` dies with the ordinal (§6.1). +- The BSON→slug respelling sweep (§8.22 deferred work) is worth shipping + for reads: measured on the live "Personal" space recipe, + `include=properties` drops 313 → 253 (o200k, −19 %; gemma 353 → 279, + −21 %), the full doc 1 563 → 1 503, and — the larger effect — the + *comprehension* call disappears: today the model must fetch + `GET /properties` (722 tok on this space) to learn that + `6a764b3f61fab21cd4b9e0a7` means `prep_time`; a slug-keyed read needs + no discovery call at all. Key spelling is a migration, not a knob — it + belongs in no mode. + +### 6.3 The deciding evidence + +The profiles are not just tidier — §7 measures that models *use* them: +the same tasks that leave the current five parameters untouched at 2B +scale (2/8 optimal) are solved 7–8/8 through the one enum, at every model +size tested. A knob nobody turns saves nobody tokens; §7 is why this +section recommends profiles rather than better documentation for the +existing parameters. + +--- + +## 7. Model comprehension — the empirical core + +**Setup.** Eight realistic read tasks (overview, property check, edit +prep, render, export ids, subtree follow-up, text grep, restructure +prep), one tool call each, temperature 0 (gpt-5-family calls run at API +default — the endpoint rejects the parameter). Arm **A**: `read_object` with +today's five parameters, descriptions verbatim from the served OpenAPI +document. Arm **B**: `read_object` with the §6.2 `view`/`mode` enum + `block`. +Scored per task: *optimal* (cheapest sufficient shape), *ok* (correct but +overpays — in practice: sent a bare default read), *miss/illegal* +(another round trip or a 400). Scoring is symmetric-lenient: a spelled-out +default (`include="properties,blocks"`) counts as the default. + +| model | arm A optimal | arm A ok (paid default) | arm A miss | arm B optimal | arm B miss | +|---|---|---|---|---|---| +| gemma4:e2b | **2/8** | 6 | 0 | **7/8** | 1 | +| gemma4:e4b | 5/8 | 3 | 0 | **8/8** | 0 | +| gpt-5-mini | 7/8 | 1 | 0 | **8/8** | 0 | +| gpt-5 | 8/8 | 0 | 0 | 7/8 (+1 ok) | 0 | +| bonsai-27b-q1_0 | — | — | — | — | — | + +(bonsai-27b could not be measured: the remote Ollama box fails to load it +— an `MTLCompilerService` fault from llama-server that then took the box +down for the session (§5.4) — an environment fault, not a model verdict. +A second gpt-5-mini arm-A sample — gpt-5-family calls run without +`temperature`, so samples vary — produced `format=md` **with** +`include=properties,blocks`: one of the served 400 `ambiguous_input` +combinations. Even a frontier-mini can trip the param-legality matrix; +the enum has no illegal combinations to trip.) + +**The null result, plainly: the smallest model ignores the current +parameters entirely.** gemma4:e2b sent the bare default read for 6 of 8 +tasks — never `outline`, never `format=md`, never `include`, never `ids` — +exactly §8.21's route-around pattern, now reproduced on the read surface. +It is not that e2b *cannot* choose: given the enum it chose the right +profile 7/8 (its one miss: `view=text` before an edit — which locators, +§5, turn from a dead end into a working flow). e4b never discovered +`ids=full` in arm A (sent a default read for the export task) but was +perfect with the enum. Even gpt-5-mini improved. Nobody produced an +illegal combination in arm A — the failure mode is under-use, not misuse: +**parameters whose value requires understanding the format's internals +(`ids`, `include`) are dead weight; one enum whose values name tasks is +usable at every size.** + +Token consequence (priced with the M-24blk measurements, subtree read +885): e2b's arm-A choices fetch **17 804** tokens across the eight tasks +vs **11 299** for its own arm-B choices — the multi-knob surface costs +the smallest model **~58 % more input**, and on the pure-read tasks +(overview, restructure) 4.2× per call. (Arm B's one miss — `text` before +an edit — would cost a second read today; with §5 locators it wouldn't.) + +Caveats, stated: selection quality was judged against schema+description +only (no SKILL.md in context) — that is the real condition for MCP and +function-calling hosts, but CLI/HTTP agents that load SKILL.md get prose +steering this eval does not credit. Judges were authored with the tasks +(risk of construct bias); the arm-B enum is this review's own proposal, +so arm B had descriptions written to be chosen well — which is, in fact, +the point being demonstrated. + +--- + +## 8. SKILL.md delta + +`core/api/v2/SKILL.md` is at 262 of 300 lines; every recommendation above +that ships must displace text. Exact edits, keyed to today's lines — net +**−2 lines** if all of §1/§5/§6 land: + +- **L71–77 ("Read cheaply" bullets 1–2)**: replace the + outline/include/format bullets with the `mode=` table — 5 shapes, one + line each ("`?mode=outline` structure+props at ~⅓ price · `text` + readable md · `props` values only · `edit` (default) full ids · + `full` export with legend/pins"). −1 line. +- **L78–79 ("Object ids in read bodies are compacted via a refs legend by + default (`?ids=full` opts out); block ids are always full")**: becomes + "Ids are full on `edit` reads; `full` mode adds the refs legend for + export and backups." −1 line. +- **L74–75 ("editing needs no prior full read once you know the ids")**: + append the locator sentence: "text edits need no read at all — + `replaceText` with no `id` locates by `find` (must match exactly once; + narrow with `under`/`nth`)." +1 line. +- **L95 (replaceText op line)** gains `— id optional`: ±0. +- **L113–116 (replaceText bullet)**: fold the locator narrowing rule into + the existing "found 2 matches" sentence: ±0. +- **Mistakes section (L234+)**: add "a `find` that matches several blocks + refuses and lists them — narrow with `under`, don't guess." +1 line; + drop the now-moot "Option ids as values" duplicate hint (L240–241 + overlaps L33–35) −1 line. + +If only the encoding fixes (§1.1/§1.2) ship: the sole edit is L78–79 (the +compaction sentence flips), ±0 lines. + +--- + +## 9. Ranked actions + +| # | change | saving (measured basis) | effort | +|---|---|---|---| +| 1 | ~~Compact the embedded envelope values (§1.1)~~ **DONE** (Wave 0.1, APIV2.md §8.24) | 16–26 % of every object read — **re-measured on the live corpus: −15.5…−26.4 %, total −23.2 %** | trivial | +| 2 | ~~Split the `?ids=` knob: short block labels + full object refs on `edit` (§9a below)~~ **DONE** (Wave 0.2, APIV2.md §8.25) **+ HARDENED** (§8.26: only machine-minted ids relabel — 24-hex bson, view UUIDs — and the legend left the export shape too, so refs are full inline on every shape) | re-measured live: **0.9–11.5 % on the measured corpus (refs, confirmed — §1.2's model has the legend winning only at ≥2× reuse, which the corpus rarely shows)**; block labels are **bimodal, not ~15 %** — −19…−22 % on minted-id documents, **0 %** on meaningful-id documents (opaque ids compact, meaningful ids never do — the §8.26 rule; the shipped dash-charset mechanism was the accident that produced the same rows). With action 1: **−33.1 % across the corpus** | trivial | +| 3 | Locators on block-addressed ops (§5) | read-free text edits (~2.4 k → ~50 tok on the quoted-sentence flow); id-free reads become writable | small | +| 4 | `?mode=` profiles replacing include/outline/ids/format (§6.2) | 4× on pure-read calls for small models (§7); −37 % across the task mix for e2b | small-medium | +| 5 | md mention-link short form (§1.3) | up to ~55 % of md reads on mention-heavy docs | small | +| 6 | BSON→slug respelling sweep (already planned, §8.22-deferred) | −19 % on property reads + kills a 722-tok discovery call per space | planned | +| 7 | Amend ADDRESSING §7.1: pins default `none` on API reads, `all` in `full`/export (§6.2) | +22 tok/key/read avoided when pins ship | doc-only | + +The knobs were never the problem; the defaults and the encodings were. +One profile knob a 2B model can drive, full ids under it, locators so the +cheapest read is also writable — that is the whole recommendation. + +--- + +## 10. Decisions taken on this review (human, 2026-08-09) + +**1. `?ids=` splits; it currently bundles a winner with a loser.** +`CompactIds` is shorthand for two mechanisms with opposite economics +(`export.go:35-37`): `CompactBlockLabels` relabels doc-local block ids to +short suffixes and is **legend-less** (the server resolves them — +`matchBlockRef`: exact, else unique suffix), while `CompactObjectRefs` +shortens object refs **via the refs legend**, which §1.2 measures as a net +loss because 85–90 % of refs are used exactly once. Outline already moves +them independently; the read knob should too. + +**`edit` (the default) therefore emits: short block labels, full inline +object refs, no pins.** Block labels are ~15 % of a default read (18.2 tok +per 24-hex id → 2–3 per suffix) and are *simultaneously* cheaper and +easier for a small model to echo back — the only place in this review +where cost and usability point the same way. Note `CompactBlockLabels` is +marked **lossy** (the original ids are not recoverable from the document +alone), so `full`/export keeps full block ids, the refs legend and pins. + +**2. The "legend only for refs used ≥ 2×" hybrid is REJECTED.** It was the +cheap way to keep some compaction on object refs without new machinery, +but repeated mentions of one object inside a single document are rare +enough that the hybrid would almost never fire, leaving per-document +counting logic that earns nothing. Object refs go full inline, and the +future direction is a **space-wide short object-id map** — a resolver that +makes a short form addressable across documents rather than within one. +That is a real build (it needs a cheap space-scoped short→full lookup, and +a decision about what happens when a short form stops being unique), and +it is deliberately deferred, not designed here. + +Worth recording as its motivation: object ids are the **dominant cost of +search results**, not of documents — a search row measures ≈ 50 tok of +which ≈ 30 is the object id (§3), and search returns rows by the dozen. A +space-wide short id would pay there far more than it ever could inside a +single document, which is the opposite of where the refs legend was aimed. + +**3. Space-optional object routes (decided 2026-08-09 — specified in +`APIV2_SURFACES.md` §10.3).** Object ids are content-addressed and unique +across spaces, and `spaceresolverstore.GetSpaceId` already binds +objectId → spaceId as a keyed point lookup — so `spaceId` is redundant on +any route that already names the object. It drops a required argument from +every object-addressed tool, which matters here for a reason this review +keeps running into: the tokens are a rounding error, but `space` is the +argument a small model is most likely to omit or invent, because it never +appears in the user's request. Recorded here for the cross-reference; it is +a surface simplification, not a token knob, and the build items and the +403-vs-404 decision live in the surfaces doc. diff --git a/core/api/CLAUDE.md b/core/api/CLAUDE.md index 8301bc1724..054da045f7 100644 --- a/core/api/CLAUDE.md +++ b/core/api/CLAUDE.md @@ -24,9 +24,10 @@ make test-deps # Generate OpenAPI documentation make openapi -# Documentation generated in core/api/docs/ -# - openapi.yaml -# - openapi.json +# One document per API version: +# - core/api/docs/v1/openapi.{yaml,json} (from core/api, v2 excluded) +# - core/api/docs/v2/openapi.{yaml,json} (from core/api/v2) +# Served at /v1/docs/openapi.*, /v2/docs/openapi.*, and /docs/openapi.* (= v1) ``` ## Architecture Overview @@ -56,6 +57,39 @@ The API follows a clean layered architecture: - Defines interfaces for middleware interaction - Keeps API decoupled from implementation +#### API v2 lives in its own tree + +`/handler/`, `/service/` and `/model/` above are **v1 only**. API v2 has the +same three layers under `/v2/`: + +``` +core/api/ + core/ util/ pagination/ SHARED by both versions + server/ the one gin engine, auth, rate limit, analytics + handler/ service/ model/ v1 + v2/ package apiv2 — router.go, middleware.go, doc.go + handler/ package v2handler + service/ package v2service + model/ package v2model + docs/v1/ docs/v2/ the two generated documents (data only) + wrapper/ the v2 task-tool wrapper +``` + +v2 shares **nothing** with v1 at the type level, and the package split is what +makes that a compile error rather than a convention. Dependencies point one +way: `server` → `v2`, never back. `server` hands v2 the shared middleware +through `apiv2.RouteDeps`; v2 owns its own routes, its C8 idempotency and C9 +dry-run middleware, and its C10 pagination defaults. + +v2 model types carry **no `V2` prefix** — the package qualifier carries the +version (`v2model.Error`, `v2model.ListResponse[v2model.ObjectRow]`), and each +version gets its own OpenAPI document, so schema names cannot collide. + +The three object adapters and `chatsubadapter.go` stay in package `api`: that +package is the composition root (the only one that touches `*app.App` and +heart-internal services), and what they produce are implementations of the +SHARED `apicore` ports. + ### Key Patterns #### Handler Pattern @@ -231,7 +265,7 @@ Request processing order: 1. Define request/response models in `/model/` 2. Add service method in `/service/` 3. Create handler in `/handler/` -4. Add route in `/server/routes.go` +4. Add route in `/server/router.go` (v1) or `/v2/router.go` (v2) 5. Write tests for both service and handler 6. Update OpenAPI annotations 7. Run `make openapi` to regenerate docs diff --git a/core/api/core/core.go b/core/api/core/core.go index c843c7ac38..4dc904aa7d 100644 --- a/core/api/core/core.go +++ b/core/api/core/core.go @@ -3,6 +3,7 @@ package apicore import ( "context" + "github.com/anyproto/anytype-heart/core/block/editor/state" "github.com/anyproto/anytype-heart/core/domain" "github.com/anyproto/anytype-heart/core/files" "github.com/anyproto/anytype-heart/core/subscription" @@ -37,6 +38,111 @@ type FileObjectService interface { GetImageDataFromRawId(ctx context.Context, fileId domain.FileId) (files.Image, error) } +// ObjectRead is one consistent read of an object's live state: snapshot and +// tree heads come from the same locked state read, so the derived etag and +// the content always agree (APIV2.md §8 read path). +type ObjectRead struct { + SbType model.SmartBlockType + Snapshot *model.SmartBlockSnapshotBase + Heads []string + // BlocksRefused and DetailsRefused carry the object-level restriction + // verdicts from the same locked read, so a dry run reaches the same + // conclusion as the real edit (review C′3). nil means that axis is + // editable. They are separate because the restrictions are: a set and a + // collection carry Restrictions_Blocks but NOT Restrictions_Details, so + // one blanket verdict made renaming a set — and every add_items — refuse + // (surface review M1). + BlocksRefused error + DetailsRefused error +} + +// EditNeeds declares which object-level restriction axes an edit touches, so +// the gate can be per-op instead of per-request. Item ops (add_items / +// remove_items) need NEITHER: they mutate the collection store, which no +// object restriction governs — matching v1's ObjectCollectionAdd. +type EditNeeds struct { + Blocks bool + Details bool +} + +// Needs reports whether anything is required at all. +func (n EditNeeds) Needs() bool { return n.Blocks || n.Details } + +// ObjectReader reads the live smartblock state of an object — the API v2 +// read path (APIV2.md §8: not ObjectShow, not the store snapshot). +type ObjectReader interface { + ReadObject(ctx context.Context, spaceId string, objectId string) (ObjectRead, error) +} + +// ObjectCreator creates objects from AnyBlock snapshots — the API v2 create +// path (APIV2.md §2 Phase 2). CreateObjectFromSnapshot builds the object's +// initial state from the snapshot and creates it in one change set (atomic +// composite creates, §8/R10); the object's type keys come from the +// snapshot's ObjectTypes. TypeIdByKey derives the space-local object id of a +// type key (needed for setOf/targetObjectType details before the object +// exists). RelationIdByKey is the relation twin: derived objects' ids are a +// pure function of (space, kind, internal key) (ADDRESSING §2.4), so the id +// is computable whether or not the relation object — or even its index +// row — exists; the corpse probes use it to see a tombstoned row that no +// key-filtered query can return (§8.41). +type ObjectCreator interface { + CreateObjectFromSnapshot(ctx context.Context, spaceId string, snapshot *model.SmartBlockSnapshotBase) (id string, err error) + TypeIdByKey(ctx context.Context, spaceId string, key domain.TypeKey) (string, error) + RelationIdByKey(ctx context.Context, spaceId string, key domain.RelationKey) (string, error) +} + +// ObjectEdit is one locked editing session on a live object — what the +// PATCH pipeline works on (APIV2.md §2 Phase 3). SbType and Heads are the +// same consistent read the Phase-1 reader produces (the If-Match inputs); +// State is a child state of the live document, so ops mutate it directly and +// the adapter commits it with ONE ordinary smartblock Apply — per-block +// restriction checks, undo recording, hooks/events and the minimal +// id-matched change diff all ride the normal editor path. +type ObjectEdit struct { + SbType model.SmartBlockType + Heads []string + State *state.State +} + +// ObjectMutator applies one atomic mutation to a live object — the API v2 +// edit path (APIV2.md §2 Phase 3). +// +// MutateObject (PATCH) locks the object, hands apply an ObjectEdit whose +// State is a fresh child of the live state, and — when apply returns nil — +// commits that state with one ordinary Apply. The returned heads are the +// post-apply tree heads, the input of the new etag. +// +// There is deliberately no snapshot-shaped sibling: the reset-to-version +// document replace that once served PUT was removed with that surface +// (APIV2.md §8.27 — snapshots are for creates, edits are ops), and with it +// the state repair a snapshot round trip needed. +type ObjectMutator interface { + // MutateObject takes the restriction axes the batch actually touches; + // the adapter re-checks them under the lock (the apply itself runs + // without object-level restriction checks, so this gate is the only one). + MutateObject(ctx context.Context, spaceId string, objectId string, needs EditNeeds, apply func(edit ObjectEdit) error) (heads []string, err error) +} + +// ObjectProvenance reads an object's creator provenance from validated +// change storage — never from details (APIV2_OBJECT_DELETE.md §10). It is +// the enforcement read behind DELETE /v2/spaces/{space_id}/objects/{object_id}: +// +// - accountMatch reports the root clause — the tree's signed root carries +// this account's identity (other members' objects report false; note +// that some derived shapes DO carry a signed root — the personal space's +// derive path and every FileObject sign with the account key — so a +// true here does not by itself mean "user content": the route's sbType +// allowlist owns that exclusion); +// - integrationName is the key clause — the raw app name recorded on the +// first account-signed content change (never normalized, compared +// exactly by the caller), or "" when none is recorded (legacy objects, +// app-created objects, the §10 same-account race edge); +// - err is an infrastructure failure (space/tree unavailable). Callers +// MUST refuse on err — the rule is fail-closed in every direction. +type ObjectProvenance interface { + CreatorProvenance(ctx context.Context, spaceId string, objectId string) (accountMatch bool, integrationName string, err error) +} + type ClientCommands interface { // Wallet AccountLocalLinkNewChallenge(context.Context, *pb.RpcAccountLocalLinkNewChallengeRequest) *pb.RpcAccountLocalLinkNewChallengeResponse diff --git a/core/api/core/mock_apicore/mock_ObjectCreator.go b/core/api/core/mock_apicore/mock_ObjectCreator.go new file mode 100644 index 0000000000..d84f55adb3 --- /dev/null +++ b/core/api/core/mock_apicore/mock_ObjectCreator.go @@ -0,0 +1,213 @@ +// Code generated by mockery. DO NOT EDIT. + +package mock_apicore + +import ( + context "context" + + domain "github.com/anyproto/anytype-heart/core/domain" + mock "github.com/stretchr/testify/mock" + + model "github.com/anyproto/anytype-heart/pkg/lib/pb/model" +) + +// MockObjectCreator is an autogenerated mock type for the ObjectCreator type +type MockObjectCreator struct { + mock.Mock +} + +type MockObjectCreator_Expecter struct { + mock *mock.Mock +} + +func (_m *MockObjectCreator) EXPECT() *MockObjectCreator_Expecter { + return &MockObjectCreator_Expecter{mock: &_m.Mock} +} + +// CreateObjectFromSnapshot provides a mock function with given fields: ctx, spaceId, snapshot +func (_m *MockObjectCreator) CreateObjectFromSnapshot(ctx context.Context, spaceId string, snapshot *model.SmartBlockSnapshotBase) (string, error) { + ret := _m.Called(ctx, spaceId, snapshot) + + if len(ret) == 0 { + panic("no return value specified for CreateObjectFromSnapshot") + } + + var r0 string + var r1 error + if rf, ok := ret.Get(0).(func(context.Context, string, *model.SmartBlockSnapshotBase) (string, error)); ok { + return rf(ctx, spaceId, snapshot) + } + if rf, ok := ret.Get(0).(func(context.Context, string, *model.SmartBlockSnapshotBase) string); ok { + r0 = rf(ctx, spaceId, snapshot) + } else { + r0 = ret.Get(0).(string) + } + + if rf, ok := ret.Get(1).(func(context.Context, string, *model.SmartBlockSnapshotBase) error); ok { + r1 = rf(ctx, spaceId, snapshot) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// MockObjectCreator_CreateObjectFromSnapshot_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'CreateObjectFromSnapshot' +type MockObjectCreator_CreateObjectFromSnapshot_Call struct { + *mock.Call +} + +// CreateObjectFromSnapshot is a helper method to define mock.On call +// - ctx context.Context +// - spaceId string +// - snapshot *model.SmartBlockSnapshotBase +func (_e *MockObjectCreator_Expecter) CreateObjectFromSnapshot(ctx interface{}, spaceId interface{}, snapshot interface{}) *MockObjectCreator_CreateObjectFromSnapshot_Call { + return &MockObjectCreator_CreateObjectFromSnapshot_Call{Call: _e.mock.On("CreateObjectFromSnapshot", ctx, spaceId, snapshot)} +} + +func (_c *MockObjectCreator_CreateObjectFromSnapshot_Call) Run(run func(ctx context.Context, spaceId string, snapshot *model.SmartBlockSnapshotBase)) *MockObjectCreator_CreateObjectFromSnapshot_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context), args[1].(string), args[2].(*model.SmartBlockSnapshotBase)) + }) + return _c +} + +func (_c *MockObjectCreator_CreateObjectFromSnapshot_Call) Return(id string, err error) *MockObjectCreator_CreateObjectFromSnapshot_Call { + _c.Call.Return(id, err) + return _c +} + +func (_c *MockObjectCreator_CreateObjectFromSnapshot_Call) RunAndReturn(run func(context.Context, string, *model.SmartBlockSnapshotBase) (string, error)) *MockObjectCreator_CreateObjectFromSnapshot_Call { + _c.Call.Return(run) + return _c +} + +// RelationIdByKey provides a mock function with given fields: ctx, spaceId, key +func (_m *MockObjectCreator) RelationIdByKey(ctx context.Context, spaceId string, key domain.RelationKey) (string, error) { + ret := _m.Called(ctx, spaceId, key) + + if len(ret) == 0 { + panic("no return value specified for RelationIdByKey") + } + + var r0 string + var r1 error + if rf, ok := ret.Get(0).(func(context.Context, string, domain.RelationKey) (string, error)); ok { + return rf(ctx, spaceId, key) + } + if rf, ok := ret.Get(0).(func(context.Context, string, domain.RelationKey) string); ok { + r0 = rf(ctx, spaceId, key) + } else { + r0 = ret.Get(0).(string) + } + + if rf, ok := ret.Get(1).(func(context.Context, string, domain.RelationKey) error); ok { + r1 = rf(ctx, spaceId, key) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// MockObjectCreator_RelationIdByKey_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'RelationIdByKey' +type MockObjectCreator_RelationIdByKey_Call struct { + *mock.Call +} + +// RelationIdByKey is a helper method to define mock.On call +// - ctx context.Context +// - spaceId string +// - key domain.RelationKey +func (_e *MockObjectCreator_Expecter) RelationIdByKey(ctx interface{}, spaceId interface{}, key interface{}) *MockObjectCreator_RelationIdByKey_Call { + return &MockObjectCreator_RelationIdByKey_Call{Call: _e.mock.On("RelationIdByKey", ctx, spaceId, key)} +} + +func (_c *MockObjectCreator_RelationIdByKey_Call) Run(run func(ctx context.Context, spaceId string, key domain.RelationKey)) *MockObjectCreator_RelationIdByKey_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context), args[1].(string), args[2].(domain.RelationKey)) + }) + return _c +} + +func (_c *MockObjectCreator_RelationIdByKey_Call) Return(_a0 string, _a1 error) *MockObjectCreator_RelationIdByKey_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *MockObjectCreator_RelationIdByKey_Call) RunAndReturn(run func(context.Context, string, domain.RelationKey) (string, error)) *MockObjectCreator_RelationIdByKey_Call { + _c.Call.Return(run) + return _c +} + +// TypeIdByKey provides a mock function with given fields: ctx, spaceId, key +func (_m *MockObjectCreator) TypeIdByKey(ctx context.Context, spaceId string, key domain.TypeKey) (string, error) { + ret := _m.Called(ctx, spaceId, key) + + if len(ret) == 0 { + panic("no return value specified for TypeIdByKey") + } + + var r0 string + var r1 error + if rf, ok := ret.Get(0).(func(context.Context, string, domain.TypeKey) (string, error)); ok { + return rf(ctx, spaceId, key) + } + if rf, ok := ret.Get(0).(func(context.Context, string, domain.TypeKey) string); ok { + r0 = rf(ctx, spaceId, key) + } else { + r0 = ret.Get(0).(string) + } + + if rf, ok := ret.Get(1).(func(context.Context, string, domain.TypeKey) error); ok { + r1 = rf(ctx, spaceId, key) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// MockObjectCreator_TypeIdByKey_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'TypeIdByKey' +type MockObjectCreator_TypeIdByKey_Call struct { + *mock.Call +} + +// TypeIdByKey is a helper method to define mock.On call +// - ctx context.Context +// - spaceId string +// - key domain.TypeKey +func (_e *MockObjectCreator_Expecter) TypeIdByKey(ctx interface{}, spaceId interface{}, key interface{}) *MockObjectCreator_TypeIdByKey_Call { + return &MockObjectCreator_TypeIdByKey_Call{Call: _e.mock.On("TypeIdByKey", ctx, spaceId, key)} +} + +func (_c *MockObjectCreator_TypeIdByKey_Call) Run(run func(ctx context.Context, spaceId string, key domain.TypeKey)) *MockObjectCreator_TypeIdByKey_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context), args[1].(string), args[2].(domain.TypeKey)) + }) + return _c +} + +func (_c *MockObjectCreator_TypeIdByKey_Call) Return(_a0 string, _a1 error) *MockObjectCreator_TypeIdByKey_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *MockObjectCreator_TypeIdByKey_Call) RunAndReturn(run func(context.Context, string, domain.TypeKey) (string, error)) *MockObjectCreator_TypeIdByKey_Call { + _c.Call.Return(run) + return _c +} + +// NewMockObjectCreator creates a new instance of MockObjectCreator. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewMockObjectCreator(t interface { + mock.TestingT + Cleanup(func()) +}) *MockObjectCreator { + mock := &MockObjectCreator{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} diff --git a/core/api/core/mock_apicore/mock_ObjectMutator.go b/core/api/core/mock_apicore/mock_ObjectMutator.go new file mode 100644 index 0000000000..30df2a7fc0 --- /dev/null +++ b/core/api/core/mock_apicore/mock_ObjectMutator.go @@ -0,0 +1,100 @@ +// Code generated by mockery. DO NOT EDIT. + +package mock_apicore + +import ( + context "context" + + apicore "github.com/anyproto/anytype-heart/core/api/core" + + mock "github.com/stretchr/testify/mock" +) + +// MockObjectMutator is an autogenerated mock type for the ObjectMutator type +type MockObjectMutator struct { + mock.Mock +} + +type MockObjectMutator_Expecter struct { + mock *mock.Mock +} + +func (_m *MockObjectMutator) EXPECT() *MockObjectMutator_Expecter { + return &MockObjectMutator_Expecter{mock: &_m.Mock} +} + +// MutateObject provides a mock function with given fields: ctx, spaceId, objectId, needs, apply +func (_m *MockObjectMutator) MutateObject(ctx context.Context, spaceId string, objectId string, needs apicore.EditNeeds, apply func(apicore.ObjectEdit) error) ([]string, error) { + ret := _m.Called(ctx, spaceId, objectId, needs, apply) + + if len(ret) == 0 { + panic("no return value specified for MutateObject") + } + + var r0 []string + var r1 error + if rf, ok := ret.Get(0).(func(context.Context, string, string, apicore.EditNeeds, func(apicore.ObjectEdit) error) ([]string, error)); ok { + return rf(ctx, spaceId, objectId, needs, apply) + } + if rf, ok := ret.Get(0).(func(context.Context, string, string, apicore.EditNeeds, func(apicore.ObjectEdit) error) []string); ok { + r0 = rf(ctx, spaceId, objectId, needs, apply) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]string) + } + } + + if rf, ok := ret.Get(1).(func(context.Context, string, string, apicore.EditNeeds, func(apicore.ObjectEdit) error) error); ok { + r1 = rf(ctx, spaceId, objectId, needs, apply) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// MockObjectMutator_MutateObject_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'MutateObject' +type MockObjectMutator_MutateObject_Call struct { + *mock.Call +} + +// MutateObject is a helper method to define mock.On call +// - ctx context.Context +// - spaceId string +// - objectId string +// - needs apicore.EditNeeds +// - apply func(apicore.ObjectEdit) error +func (_e *MockObjectMutator_Expecter) MutateObject(ctx interface{}, spaceId interface{}, objectId interface{}, needs interface{}, apply interface{}) *MockObjectMutator_MutateObject_Call { + return &MockObjectMutator_MutateObject_Call{Call: _e.mock.On("MutateObject", ctx, spaceId, objectId, needs, apply)} +} + +func (_c *MockObjectMutator_MutateObject_Call) Run(run func(ctx context.Context, spaceId string, objectId string, needs apicore.EditNeeds, apply func(apicore.ObjectEdit) error)) *MockObjectMutator_MutateObject_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context), args[1].(string), args[2].(string), args[3].(apicore.EditNeeds), args[4].(func(apicore.ObjectEdit) error)) + }) + return _c +} + +func (_c *MockObjectMutator_MutateObject_Call) Return(heads []string, err error) *MockObjectMutator_MutateObject_Call { + _c.Call.Return(heads, err) + return _c +} + +func (_c *MockObjectMutator_MutateObject_Call) RunAndReturn(run func(context.Context, string, string, apicore.EditNeeds, func(apicore.ObjectEdit) error) ([]string, error)) *MockObjectMutator_MutateObject_Call { + _c.Call.Return(run) + return _c +} + +// NewMockObjectMutator creates a new instance of MockObjectMutator. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewMockObjectMutator(t interface { + mock.TestingT + Cleanup(func()) +}) *MockObjectMutator { + mock := &MockObjectMutator{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} diff --git a/core/api/core/mock_apicore/mock_ObjectProvenance.go b/core/api/core/mock_apicore/mock_ObjectProvenance.go new file mode 100644 index 0000000000..170c8d5a17 --- /dev/null +++ b/core/api/core/mock_apicore/mock_ObjectProvenance.go @@ -0,0 +1,101 @@ +// Code generated by mockery. DO NOT EDIT. + +package mock_apicore + +import ( + context "context" + + mock "github.com/stretchr/testify/mock" +) + +// MockObjectProvenance is an autogenerated mock type for the ObjectProvenance type +type MockObjectProvenance struct { + mock.Mock +} + +type MockObjectProvenance_Expecter struct { + mock *mock.Mock +} + +func (_m *MockObjectProvenance) EXPECT() *MockObjectProvenance_Expecter { + return &MockObjectProvenance_Expecter{mock: &_m.Mock} +} + +// CreatorProvenance provides a mock function with given fields: ctx, spaceId, objectId +func (_m *MockObjectProvenance) CreatorProvenance(ctx context.Context, spaceId string, objectId string) (bool, string, error) { + ret := _m.Called(ctx, spaceId, objectId) + + if len(ret) == 0 { + panic("no return value specified for CreatorProvenance") + } + + var r0 bool + var r1 string + var r2 error + if rf, ok := ret.Get(0).(func(context.Context, string, string) (bool, string, error)); ok { + return rf(ctx, spaceId, objectId) + } + if rf, ok := ret.Get(0).(func(context.Context, string, string) bool); ok { + r0 = rf(ctx, spaceId, objectId) + } else { + r0 = ret.Get(0).(bool) + } + + if rf, ok := ret.Get(1).(func(context.Context, string, string) string); ok { + r1 = rf(ctx, spaceId, objectId) + } else { + r1 = ret.Get(1).(string) + } + + if rf, ok := ret.Get(2).(func(context.Context, string, string) error); ok { + r2 = rf(ctx, spaceId, objectId) + } else { + r2 = ret.Error(2) + } + + return r0, r1, r2 +} + +// MockObjectProvenance_CreatorProvenance_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'CreatorProvenance' +type MockObjectProvenance_CreatorProvenance_Call struct { + *mock.Call +} + +// CreatorProvenance is a helper method to define mock.On call +// - ctx context.Context +// - spaceId string +// - objectId string +func (_e *MockObjectProvenance_Expecter) CreatorProvenance(ctx interface{}, spaceId interface{}, objectId interface{}) *MockObjectProvenance_CreatorProvenance_Call { + return &MockObjectProvenance_CreatorProvenance_Call{Call: _e.mock.On("CreatorProvenance", ctx, spaceId, objectId)} +} + +func (_c *MockObjectProvenance_CreatorProvenance_Call) Run(run func(ctx context.Context, spaceId string, objectId string)) *MockObjectProvenance_CreatorProvenance_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context), args[1].(string), args[2].(string)) + }) + return _c +} + +func (_c *MockObjectProvenance_CreatorProvenance_Call) Return(accountMatch bool, integrationName string, err error) *MockObjectProvenance_CreatorProvenance_Call { + _c.Call.Return(accountMatch, integrationName, err) + return _c +} + +func (_c *MockObjectProvenance_CreatorProvenance_Call) RunAndReturn(run func(context.Context, string, string) (bool, string, error)) *MockObjectProvenance_CreatorProvenance_Call { + _c.Call.Return(run) + return _c +} + +// NewMockObjectProvenance creates a new instance of MockObjectProvenance. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewMockObjectProvenance(t interface { + mock.TestingT + Cleanup(func()) +}) *MockObjectProvenance { + mock := &MockObjectProvenance{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} diff --git a/core/api/core/mock_apicore/mock_ObjectReader.go b/core/api/core/mock_apicore/mock_ObjectReader.go new file mode 100644 index 0000000000..e2957c9039 --- /dev/null +++ b/core/api/core/mock_apicore/mock_ObjectReader.go @@ -0,0 +1,96 @@ +// Code generated by mockery. DO NOT EDIT. + +package mock_apicore + +import ( + context "context" + + apicore "github.com/anyproto/anytype-heart/core/api/core" + + mock "github.com/stretchr/testify/mock" +) + +// MockObjectReader is an autogenerated mock type for the ObjectReader type +type MockObjectReader struct { + mock.Mock +} + +type MockObjectReader_Expecter struct { + mock *mock.Mock +} + +func (_m *MockObjectReader) EXPECT() *MockObjectReader_Expecter { + return &MockObjectReader_Expecter{mock: &_m.Mock} +} + +// ReadObject provides a mock function with given fields: ctx, spaceId, objectId +func (_m *MockObjectReader) ReadObject(ctx context.Context, spaceId string, objectId string) (apicore.ObjectRead, error) { + ret := _m.Called(ctx, spaceId, objectId) + + if len(ret) == 0 { + panic("no return value specified for ReadObject") + } + + var r0 apicore.ObjectRead + var r1 error + if rf, ok := ret.Get(0).(func(context.Context, string, string) (apicore.ObjectRead, error)); ok { + return rf(ctx, spaceId, objectId) + } + if rf, ok := ret.Get(0).(func(context.Context, string, string) apicore.ObjectRead); ok { + r0 = rf(ctx, spaceId, objectId) + } else { + r0 = ret.Get(0).(apicore.ObjectRead) + } + + if rf, ok := ret.Get(1).(func(context.Context, string, string) error); ok { + r1 = rf(ctx, spaceId, objectId) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// MockObjectReader_ReadObject_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ReadObject' +type MockObjectReader_ReadObject_Call struct { + *mock.Call +} + +// ReadObject is a helper method to define mock.On call +// - ctx context.Context +// - spaceId string +// - objectId string +func (_e *MockObjectReader_Expecter) ReadObject(ctx interface{}, spaceId interface{}, objectId interface{}) *MockObjectReader_ReadObject_Call { + return &MockObjectReader_ReadObject_Call{Call: _e.mock.On("ReadObject", ctx, spaceId, objectId)} +} + +func (_c *MockObjectReader_ReadObject_Call) Run(run func(ctx context.Context, spaceId string, objectId string)) *MockObjectReader_ReadObject_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context), args[1].(string), args[2].(string)) + }) + return _c +} + +func (_c *MockObjectReader_ReadObject_Call) Return(_a0 apicore.ObjectRead, _a1 error) *MockObjectReader_ReadObject_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *MockObjectReader_ReadObject_Call) RunAndReturn(run func(context.Context, string, string) (apicore.ObjectRead, error)) *MockObjectReader_ReadObject_Call { + _c.Call.Return(run) + return _c +} + +// NewMockObjectReader creates a new instance of MockObjectReader. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewMockObjectReader(t interface { + mock.TestingT + Cleanup(func()) +}) *MockObjectReader { + mock := &MockObjectReader{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} diff --git a/core/api/docs/docs.go b/core/api/docs/docs.go deleted file mode 100644 index 4a56cdb3b6..0000000000 --- a/core/api/docs/docs.go +++ /dev/null @@ -1,32 +0,0 @@ -// Code generated by swaggo/swag. DO NOT EDIT. - -package docs - -import "github.com/swaggo/swag/v2" - -const docTemplate = `{ - "schemes": {{ marshal .Schemes }}, - "components": {"schemas":{"apimodel.AddChatMessageRequest":{"properties":{"attachments":{"items":{"$ref":"#/components/schemas/apimodel.ChatAttachment"},"type":"array","uniqueItems":false},"marks":{"items":{"$ref":"#/components/schemas/apimodel.TextMark"},"type":"array","uniqueItems":false},"reply_to_message_id":{"example":"msg-def456","type":"string"},"style":{"example":"paragraph","type":"string"},"text":{"example":"Hello, world!","type":"string"}},"required":["text"],"type":"object"},"apimodel.AddChatMessageResponse":{"properties":{"message_id":{"example":"msg-abc123","type":"string"}},"type":"object"},"apimodel.AddObjectsToListRequest":{"properties":{"objects":{"description":"The list of object IDs to add to the list","example":["[\"bafyreie6n5l5nkbjal37su54cha4coy7qzuhrnajluzv5qd5jvtsrxkequ\"]"],"items":{"type":"string"},"type":"array","uniqueItems":false}},"type":"object"},"apimodel.ChatAttachment":{"properties":{"target":{"example":"bafyreie6n5l5nkbjal37su54cha4coy","type":"string"},"type":{"example":"image","type":"string"}},"type":"object"},"apimodel.ChatMessage":{"properties":{"attachments":{"items":{"$ref":"#/components/schemas/apimodel.ChatAttachment"},"type":"array","uniqueItems":false},"content":{"$ref":"#/components/schemas/apimodel.ChatMessageContent"},"created_at":{"example":1717405200,"type":"integer"},"creator":{"example":"_participant_bafyreigyfkt6rbv24sbv5aq2hko3bhmv5xxlf22b4bypdu6j7hnphm3psq_23me69r569oi1_AAjEbEzQx9FNvf5LQFEJEGRojZt3L1MRmBFzP2Q","type":"string"},"creator_name":{"example":"Alice","type":"string"},"id":{"example":"msg-abc123","type":"string"},"modified_at":{"example":1717405200,"type":"integer"},"order_id":{"example":"00a1b2c3d4e5f6","type":"string"},"pinned":{"type":"boolean"},"reactions":{"additionalProperties":{"items":{"type":"string"},"type":"array"},"type":"object"},"reply_to_message_id":{"example":"msg-def456","type":"string"}},"type":"object"},"apimodel.ChatMessageContent":{"properties":{"marks":{"items":{"$ref":"#/components/schemas/apimodel.TextMark"},"type":"array","uniqueItems":false},"style":{"example":"paragraph","type":"string"},"text":{"example":"Hello, world!","type":"string"}},"type":"object"},"apimodel.ChatMessageResponse":{"properties":{"message":{"$ref":"#/components/schemas/apimodel.ChatMessage"}},"type":"object"},"apimodel.ChatMessageSearchResult":{"properties":{"highlight":{"example":"...the **search** match in context...","type":"string"},"highlight_ranges":{"items":{"$ref":"#/components/schemas/apimodel.TextRange"},"type":"array","uniqueItems":false},"message":{"$ref":"#/components/schemas/apimodel.ChatMessage"},"score":{"example":42,"type":"integer"}},"type":"object"},"apimodel.ChatMessagesResponse":{"properties":{"messages":{"items":{"$ref":"#/components/schemas/apimodel.ChatMessage"},"type":"array","uniqueItems":false}},"type":"object"},"apimodel.CheckboxFilterItem":{"properties":{"checkbox":{"description":"The checkbox value to filter by","example":true,"type":"boolean"},"condition":{"$ref":"#/components/schemas/apimodel.FilterCondition"},"property_key":{"description":"The property key to filter on","example":"done","type":"string"}},"type":"object"},"apimodel.CheckboxPropertyLinkValue":{"properties":{"checkbox":{"description":"The checkbox value of the property","example":true,"type":"boolean"},"key":{"example":"done","type":"string"}},"type":"object"},"apimodel.CheckboxPropertyValue":{"properties":{"checkbox":{"description":"The checkbox value of the property","example":true,"type":"boolean"},"format":{"$ref":"#/components/schemas/apimodel.PropertyFormat"},"id":{"description":"The id of the property","example":"bafyreids36kpw5ppuwm3ce2p4ezb3ab7cihhkq6yfbwzwpp4mln7rcgw7a","type":"string"},"key":{"description":"The key of the property","example":"done","type":"string"},"name":{"description":"The name of the property","example":"Done","type":"string"},"object":{"description":"The data model of the object","example":"property","type":"string"}},"type":"object"},"apimodel.Color":{"description":"The color of the icon","enum":["grey","yellow","orange","red","pink","purple","blue","ice","teal","lime"],"example":"yellow","type":"string","x-enum-varnames":["ColorGrey","ColorYellow","ColorOrange","ColorRed","ColorPink","ColorPurple","ColorBlue","ColorIce","ColorTeal","ColorLime"]},"apimodel.CreateApiKeyRequest":{"properties":{"challenge_id":{"description":"The challenge id associated with the previously displayed code","example":"67647f5ecda913e9a2e11b26","type":"string"},"code":{"description":"The 4-digit code retrieved from Anytype Desktop app","example":"1234","type":"string"}},"type":"object"},"apimodel.CreateApiKeyResponse":{"properties":{"api_key":{"description":"The api key used to authenticate requests","example":"zhSG/zQRmgADyilWPtgdnfo1qD60oK02/SVgi1GaFt6=","type":"string"}},"type":"object"},"apimodel.CreateChallengeRequest":{"properties":{"app_name":{"description":"The name of the app that is requesting the challenge","example":"anytype_mcp","type":"string"}},"type":"object"},"apimodel.CreateChallengeResponse":{"properties":{"challenge_id":{"description":"The challenge id associated with the displayed code and needed to solve the challenge for api_key","example":"67647f5ecda913e9a2e11b26","type":"string"}},"type":"object"},"apimodel.CreateChatRequest":{"properties":{"icon":{"$ref":"#/components/schemas/apimodel.Icon"},"name":{"description":"The name of the chat","example":"My chat","type":"string"}},"type":"object"},"apimodel.CreateObjectRequest":{"properties":{"body":{"description":"The body of the object","example":"This is the body of the object. Markdown syntax is supported here.","type":"string"},"icon":{"$ref":"#/components/schemas/apimodel.Icon"},"name":{"description":"The name of the object","example":"My object","type":"string"},"properties":{"description":"The properties to set on the object; see ListTypes or GetType endpoints for linked properties","items":{"$ref":"#/components/schemas/apimodel.PropertyLinkWithValue"},"type":"array","uniqueItems":false},"template_id":{"description":"The id of the template to use","example":"bafyreictrp3obmnf6dwejy5o4p7bderaaia4bdg2psxbfzf44yya5uutge","type":"string"},"type_key":{"description":"The key of the type of object to create","example":"page","type":"string"}},"required":["type_key"],"type":"object"},"apimodel.CreatePropertyRequest":{"properties":{"format":{"$ref":"#/components/schemas/apimodel.PropertyFormat"},"key":{"description":"The key of the property; should always be snake_case, otherwise it will be converted to snake_case","example":"some_user_defined_property_key","type":"string"},"name":{"description":"The name of the property","example":"Last modified date","type":"string"},"tags":{"description":"Tags to create for select/multi_select properties","items":{"$ref":"#/components/schemas/apimodel.CreateTagRequest"},"type":"array","uniqueItems":false}},"required":["format","name"],"type":"object"},"apimodel.CreateSpaceRequest":{"properties":{"description":{"description":"The description of the space","example":"The local-first wiki","type":"string"},"name":{"description":"The name of the space","example":"New Space","type":"string"}},"required":["name"],"type":"object"},"apimodel.CreateTagRequest":{"properties":{"color":{"$ref":"#/components/schemas/apimodel.Color"},"key":{"description":"The optional custom key for the tag","example":"in_progress","type":"string"},"name":{"description":"The name of the tag","example":"In progress","type":"string"}},"required":["color","name"],"type":"object"},"apimodel.CreateTypeRequest":{"properties":{"icon":{"$ref":"#/components/schemas/apimodel.Icon"},"key":{"description":"The key of the type; should always be snake_case, otherwise it will be converted to snake_case","example":"some_user_defined_type_key","type":"string"},"layout":{"$ref":"#/components/schemas/apimodel.TypeLayout"},"name":{"description":"The name of the type","example":"Page","type":"string"},"plural_name":{"description":"The plural name of the type","example":"Pages","type":"string"},"properties":{"description":"The properties linked to the type","items":{"$ref":"#/components/schemas/apimodel.PropertyLink"},"type":"array","uniqueItems":false}},"required":["layout","name","plural_name"],"type":"object"},"apimodel.DateFilterItem":{"properties":{"condition":{"$ref":"#/components/schemas/apimodel.FilterCondition"},"date":{"description":"The date value to filter by. Accepts dates in RFC3339 format (2006-01-02T15:04:05Z) or date-only format (2006-01-02)","example":"2006-01-02T15:04:05Z","type":"string"},"property_key":{"description":"The property key to filter on","example":"last_modified_date","type":"string"}},"type":"object"},"apimodel.DatePropertyLinkValue":{"properties":{"date":{"description":"The date value of the property. Accepts dates in RFC3339 format (2006-01-02T15:04:05Z) or date-only format (2006-01-02)","example":"2006-01-02T15:04:05Z","type":"string"},"key":{"example":"last_modified_date","type":"string"}},"type":"object"},"apimodel.DatePropertyValue":{"properties":{"date":{"description":"The date value of the property. Returns dates in RFC3339 format (2006-01-02T15:04:05Z)","example":"2006-01-02T15:04:05Z","type":"string"},"format":{"$ref":"#/components/schemas/apimodel.PropertyFormat"},"id":{"description":"The id of the property","example":"bafyreids36kpw5ppuwm3ce2p4ezb3ab7cihhkq6yfbwzwpp4mln7rcgw7a","type":"string"},"key":{"description":"The key of the property","example":"last_modified_date","type":"string"},"name":{"description":"The name of the property","example":"Last modified date","type":"string"},"object":{"description":"The data model of the object","example":"property","type":"string"}},"type":"object"},"apimodel.EditChatMessageRequest":{"properties":{"attachments":{"items":{"$ref":"#/components/schemas/apimodel.ChatAttachment"},"type":"array","uniqueItems":false},"marks":{"items":{"$ref":"#/components/schemas/apimodel.TextMark"},"type":"array","uniqueItems":false},"style":{"example":"paragraph","type":"string"},"text":{"example":"Updated message text","type":"string"}},"required":["text"],"type":"object"},"apimodel.EmailFilterItem":{"properties":{"condition":{"$ref":"#/components/schemas/apimodel.FilterCondition"},"email":{"description":"The email value to filter by","example":"example@example.com","type":"string"},"property_key":{"description":"The property key to filter on","example":"email","type":"string"}},"type":"object"},"apimodel.EmailPropertyLinkValue":{"properties":{"email":{"description":"The email value of the property","example":"example@example.com","type":"string"},"key":{"example":"email","type":"string"}},"type":"object"},"apimodel.EmailPropertyValue":{"properties":{"email":{"description":"The email value of the property","example":"example@example.com","type":"string"},"format":{"$ref":"#/components/schemas/apimodel.PropertyFormat"},"id":{"description":"The id of the property","example":"bafyreids36kpw5ppuwm3ce2p4ezb3ab7cihhkq6yfbwzwpp4mln7rcgw7a","type":"string"},"key":{"description":"The key of the property","example":"email","type":"string"},"name":{"description":"The name of the property","example":"Email","type":"string"},"object":{"description":"The data model of the object","example":"property","type":"string"}},"type":"object"},"apimodel.EmojiIcon":{"properties":{"emoji":{"description":"The emoji of the icon","example":"📄","type":"string"},"format":{"$ref":"#/components/schemas/apimodel.IconFormat"}},"type":"object"},"apimodel.EmptyFilterItem":{"properties":{"condition":{"$ref":"#/components/schemas/apimodel.FilterCondition"},"property_key":{"description":"The property key to filter on","example":"description","type":"string"}},"type":"object"},"apimodel.FileIcon":{"properties":{"file":{"description":"The file of the icon","example":"bafybeieptz5hvcy6txplcvphjbbh5yjc2zqhmihs3owkh5oab4ezauzqay","type":"string"},"format":{"$ref":"#/components/schemas/apimodel.IconFormat"}},"type":"object"},"apimodel.FileUploadResponse":{"properties":{"extension":{"description":"File extension without dot, when known","type":"string"},"media":{"description":"MIME type (e.g. \"image/png\")","type":"string"},"name":{"description":"Original file name as stored","type":"string"},"object_id":{"description":"File object ID","type":"string"},"size_in_bytes":{"description":"Size of the uploaded file","type":"integer"}},"type":"object"},"apimodel.FilesFilterItem":{"properties":{"condition":{"$ref":"#/components/schemas/apimodel.FilterCondition"},"files":{"description":"File IDs for contains condition","example":["bafyreie6n5l5nkbjal37su54cha4coy7qzuhrnajluzv5qd5jvtsrxkequ"],"items":{"type":"string"},"type":"array","uniqueItems":false},"property_key":{"description":"The property key to filter on","example":"files","type":"string"}},"type":"object"},"apimodel.FilesPropertyLinkValue":{"properties":{"files":{"description":"The file ids of the property","example":["bafyreie6n5l5nkbjal37su54cha4coy7qzuhrnajluzv5qd5jvtsrxkequ"],"items":{"type":"string"},"type":"array","uniqueItems":false},"key":{"example":"files","type":"string"}},"type":"object"},"apimodel.FilesPropertyValue":{"properties":{"files":{"description":"The file values of the property","example":["bafyreie6n5l5nkbjal37su54cha4coy7qzuhrnajluzv5qd5jvtsrxkequ"],"items":{"type":"string"},"type":"array","uniqueItems":false},"format":{"$ref":"#/components/schemas/apimodel.PropertyFormat"},"id":{"description":"The id of the property","example":"bafyreids36kpw5ppuwm3ce2p4ezb3ab7cihhkq6yfbwzwpp4mln7rcgw7a","type":"string"},"key":{"description":"The key of the property","example":"files","type":"string"},"name":{"description":"The name of the property","example":"Files","type":"string"},"object":{"description":"The data model of the object","example":"property","type":"string"}},"type":"object"},"apimodel.Filter":{"properties":{"condition":{"$ref":"#/components/schemas/apimodel.FilterCondition"},"format":{"$ref":"#/components/schemas/apimodel.PropertyFormat"},"id":{"description":"The id of the filter","example":"67bf3f21cda9134102e2422c","type":"string"},"property_key":{"description":"The property key used for filtering","example":"name","type":"string"},"value":{"description":"The value used for filtering","example":"Some value...","type":"string"}},"type":"object"},"apimodel.FilterCondition":{"description":"The filter condition","enum":["eq","ne","gt","gte","lt","lte","contains","ncontains","in","nin","all","empty","nempty"],"example":"empty","type":"string","x-enum-comments":{"FilterConditionAll":"Contains all specified values","FilterConditionContains":"Contains substring","FilterConditionEmpty":"Property value is empty","FilterConditionEq":"Equal to value","FilterConditionGt":"Greater than value","FilterConditionGte":"Greater than or equal to value","FilterConditionIn":"Value is in the specified array","FilterConditionLt":"Less than value","FilterConditionLte":"Less than or equal to value","FilterConditionNContains":"Does not contain substring","FilterConditionNEmpty":"Property value is not empty","FilterConditionNe":"Not equal to value","FilterConditionNin":"Value is not in the specified array"},"x-enum-varnames":["FilterConditionEq","FilterConditionNe","FilterConditionGt","FilterConditionGte","FilterConditionLt","FilterConditionLte","FilterConditionContains","FilterConditionNContains","FilterConditionIn","FilterConditionNin","FilterConditionAll","FilterConditionEmpty","FilterConditionNEmpty"]},"apimodel.FilterExpression":{"description":"Expression filter with nested AND/OR conditions. Supports recursive nesting for complex queries. The 'filters' array can contain nested FilterExpression objects, creating a tree structure for complex logic. Example: (status=\"done\" AND priority=\"high\") OR (created_date \u003e \"2024-01-01\") ` + "`" + `` + "`" + `` + "`" + ` { \"operator\": \"or\", \"filters\": [ { \"operator\": \"and\", \"conditions\": [ {\"property_key\": \"status\", \"condition\": \"eq\", \"select\": \"done_tag_id\"}, {\"property_key\": \"priority\", \"condition\": \"eq\", \"select\": \"high_tag_id\"} ] }, { \"operator\": \"and\", \"conditions\": [ {\"property_key\": \"created_date\", \"condition\": \"gt\", \"date\": \"2024-01-01\"} ] } ] } ` + "`" + `` + "`" + `` + "`" + `","properties":{"conditions":{"description":"List of format-specific filter conditions","items":{"$ref":"#/components/schemas/apimodel.FilterItem"},"type":"array","uniqueItems":false},"filters":{"description":"Nested filter expressions for complex logic","items":{"$ref":"#/components/schemas/apimodel.FilterExpression"},"type":"array","uniqueItems":false},"operator":{"$ref":"#/components/schemas/apimodel.FilterOperator"}},"type":"object"},"apimodel.FilterItem":{"description":"A filter condition that matches a specific property format (text, number, select, date, etc.). Each filter item contains a property_key, condition, and a value field specific to the property format.","oneOf":[{"$ref":"#/components/schemas/apimodel.TextFilterItem"},{"$ref":"#/components/schemas/apimodel.NumberFilterItem"},{"$ref":"#/components/schemas/apimodel.SelectFilterItem"},{"$ref":"#/components/schemas/apimodel.MultiSelectFilterItem"},{"$ref":"#/components/schemas/apimodel.DateFilterItem"},{"$ref":"#/components/schemas/apimodel.CheckboxFilterItem"},{"$ref":"#/components/schemas/apimodel.FilesFilterItem"},{"$ref":"#/components/schemas/apimodel.UrlFilterItem"},{"$ref":"#/components/schemas/apimodel.EmailFilterItem"},{"$ref":"#/components/schemas/apimodel.PhoneFilterItem"},{"$ref":"#/components/schemas/apimodel.ObjectsFilterItem"},{"$ref":"#/components/schemas/apimodel.EmptyFilterItem"}],"type":"object"},"apimodel.FilterOperator":{"description":"Logical operator for combining filters (and, or)","enum":["and","or"],"type":"string","x-enum-varnames":["FilterOperatorAnd","FilterOperatorOr"]},"apimodel.Icon":{"description":"The icon of the object, or null if the object has no icon","nullable":true,"oneOf":[{"$ref":"#/components/schemas/apimodel.EmojiIcon"},{"$ref":"#/components/schemas/apimodel.FileIcon"},{"$ref":"#/components/schemas/apimodel.NamedIcon"}],"type":"object"},"apimodel.IconFormat":{"description":"The format of the icon","enum":["emoji","file","icon"],"type":"string","x-enum-varnames":["IconFormatEmoji","IconFormatFile","IconFormatIcon"]},"apimodel.IconName":{"description":"The name of the icon","enum":["accessibility","add-circle","airplane","alarm","albums","alert-circle","american-football","analytics","aperture","apps","archive","arrow-back-circle","arrow-down-circle","arrow-forward-circle","arrow-redo-circle","arrow-redo","arrow-undo-circle","arrow-undo","arrow-up-circle","at-circle","attach","backspace","bag-add","bag-check","bag-handle","bag-remove","bag","balloon","ban","bandage","bar-chart","barbell","barcode","baseball","basket","basketball","battery-charging","battery-dead","battery-full","battery-half","beaker","bed","beer","bicycle","binoculars","bluetooth","boat","body","bonfire","book","bookmark","bookmarks","bowling-ball","briefcase","browsers","brush","bug","build","bulb","bus","business","cafe","calculator","calendar-clear","calendar-number","calendar","call","camera-reverse","camera","car-sport","car","card","caret-back-circle","caret-back","caret-down-circle","caret-down","caret-forward-circle","caret-forward","caret-up-circle","caret-up","cart","cash","cellular","chatbox-ellipses","chatbox","chatbubble-ellipses","chatbubble","chatbubbles","checkbox","checkmark-circle","checkmark-done-circle","chevron-back-circle","chevron-down-circle","chevron-forward-circle","chevron-up-circle","clipboard","close-circle","cloud-circle","cloud-done","cloud-download","cloud-offline","cloud-upload","cloud","cloudy-night","cloudy","code-slash","code","cog","color-fill","color-filter","color-palette","color-wand","compass","construct","contact","contract","contrast","copy","create","crop","cube","cut","desktop","diamond","dice","disc","document-attach","document-lock","document-text","document","documents","download","duplicate","ear","earth","easel","egg","ellipse","ellipsis-horizontal-circle","ellipsis-vertical-circle","enter","exit","expand","extension-puzzle","eye-off","eye","eyedrop","fast-food","female","file-tray-full","file-tray-stacked","file-tray","film","filter-circle","finger-print","fish","fitness","flag","flame","flash-off","flash","flashlight","flask","flower","folder-open","folder","football","footsteps","funnel","game-controller","gift","git-branch","git-commit","git-compare","git-merge","git-network","git-pull-request","glasses","globe","golf","grid","hammer","hand-left","hand-right","happy","hardware-chip","headset","heart-circle","heart-dislike-circle","heart-dislike","heart-half","heart","help-buoy","help-circle","home","hourglass","ice-cream","id-card","image","images","infinite","information-circle","invert-mode","journal","key","keypad","language","laptop","layers","leaf","library","link","list-circle","list","locate","location","lock-closed","lock-open","log-in","log-out","logo-alipay","logo-amazon","logo-amplify","logo-android","magnet","mail-open","mail-unread","mail","male-female","male","man","map","medal","medical","medkit","megaphone","menu","mic-circle","mic-off-circle","mic-off","mic","moon","move","musical-note","musical-notes","navigate-circle","navigate","newspaper","notifications-circle","notifications-off-circle","notifications-off","notifications","nuclear","nutrition","options","paper-plane","partly-sunny","pause-circle","pause","paw","pencil","people-circle","people","person-add","person-circle","person-remove","person","phone-landscape","phone-portrait","pie-chart","pin","pint","pizza","planet","play-back-circle","play-back","play-circle","play-forward-circle","play-forward","play-skip-back-circle","play-skip-back","play-skip-forward-circle","play-skip-forward","play","podium","power","pricetag","pricetags","print","prism","pulse","push","qr-code","radio-button-off","radio-button-on","radio","rainy","reader","receipt","recording","refresh-circle","refresh","reload-circle","reload","remove-circle","repeat","resize","restaurant","ribbon","rocket","rose","sad","save","scale","scan-circle","scan","school","search-circle","search","send","server","settings","shapes","share-social","share","shield-checkmark","shield-half","shield","shirt","shuffle","skull","snow","sparkles","speedometer","square","star-half","star","stats-chart","stop-circle","stop","stopwatch","storefront","subway","sunny","swap-horizontal","swap-vertical","sync-circle","sync","t.txt","tablet-landscape","tablet-portrait","telescope","tennisball","terminal","text","thermometer","thumbs-down","thumbs-up","thunderstorm","ticket","time","timer","today","toggle","trail-sign","train","transgender","trash-bin","trash","trending-down","trending-up","triangle","trophy","tv","umbrella","unlink","videocam-off","videocam","volume-high","volume-low","volume-medium","volume-mute","volume-off","walk","wallet","warning","watch","water","wifi","wine","woman"],"type":"string","x-enum-varnames":["IconNameAccessibility","IconNameAddCircle","IconNameAirplane","IconNameAlarm","IconNameAlbums","IconNameAlertCircle","IconNameAmericanFootball","IconNameAnalytics","IconNameAperture","IconNameApps","IconNameArchive","IconNameArrowBackCircle","IconNameArrowDownCircle","IconNameArrowForwardCircle","IconNameArrowRedoCircle","IconNameArrowRedo","IconNameArrowUndoCircle","IconNameArrowUndo","IconNameArrowUpCircle","IconNameAtCircle","IconNameAttach","IconNameBackspace","IconNameBagAdd","IconNameBagCheck","IconNameBagHandle","IconNameBagRemove","IconNameBag","IconNameBalloon","IconNameBan","IconNameBandage","IconNameBarChart","IconNameBarbell","IconNameBarcode","IconNameBaseball","IconNameBasket","IconNameBasketball","IconNameBatteryCharging","IconNameBatteryDead","IconNameBatteryFull","IconNameBatteryHalf","IconNameBeaker","IconNameBed","IconNameBeer","IconNameBicycle","IconNameBinoculars","IconNameBluetooth","IconNameBoat","IconNameBody","IconNameBonfire","IconNameBook","IconNameBookmark","IconNameBookmarks","IconNameBowlingBall","IconNameBriefcase","IconNameBrowsers","IconNameBrush","IconNameBug","IconNameBuild","IconNameBulb","IconNameBus","IconNameBusiness","IconNameCafe","IconNameCalculator","IconNameCalendarClear","IconNameCalendarNumber","IconNameCalendar","IconNameCall","IconNameCameraReverse","IconNameCamera","IconNameCarSport","IconNameCar","IconNameCard","IconNameCaretBackCircle","IconNameCaretBack","IconNameCaretDownCircle","IconNameCaretDown","IconNameCaretForwardCircle","IconNameCaretForward","IconNameCaretUpCircle","IconNameCaretUp","IconNameCart","IconNameCash","IconNameCellular","IconNameChatboxEllipses","IconNameChatbox","IconNameChatbubbleEllipses","IconNameChatbubble","IconNameChatbubbles","IconNameCheckbox","IconNameCheckmarkCircle","IconNameCheckmarkDoneCircle","IconNameChevronBackCircle","IconNameChevronDownCircle","IconNameChevronForwardCircle","IconNameChevronUpCircle","IconNameClipboard","IconNameCloseCircle","IconNameCloudCircle","IconNameCloudDone","IconNameCloudDownload","IconNameCloudOffline","IconNameCloudUpload","IconNameCloud","IconNameCloudyNight","IconNameCloudy","IconNameCodeSlash","IconNameCode","IconNameCog","IconNameColorFill","IconNameColorFilter","IconNameColorPalette","IconNameColorWand","IconNameCompass","IconNameConstruct","IconNameContact","IconNameContract","IconNameContrast","IconNameCopy","IconNameCreate","IconNameCrop","IconNameCube","IconNameCut","IconNameDesktop","IconNameDiamond","IconNameDice","IconNameDisc","IconNameDocumentAttach","IconNameDocumentLock","IconNameDocumentText","IconNameDocument","IconNameDocuments","IconNameDownload","IconNameDuplicate","IconNameEar","IconNameEarth","IconNameEasel","IconNameEgg","IconNameEllipse","IconNameEllipsisHorizontalCircle","IconNameEllipsisVerticalCircle","IconNameEnter","IconNameExit","IconNameExpand","IconNameExtensionPuzzle","IconNameEyeOff","IconNameEye","IconNameEyedrop","IconNameFastFood","IconNameFemale","IconNameFileTrayFull","IconNameFileTrayStacked","IconNameFileTray","IconNameFilm","IconNameFilterCircle","IconNameFingerPrint","IconNameFish","IconNameFitness","IconNameFlag","IconNameFlame","IconNameFlashOff","IconNameFlash","IconNameFlashlight","IconNameFlask","IconNameFlower","IconNameFolderOpen","IconNameFolder","IconNameFootball","IconNameFootsteps","IconNameFunnel","IconNameGameController","IconNameGift","IconNameGitBranch","IconNameGitCommit","IconNameGitCompare","IconNameGitMerge","IconNameGitNetwork","IconNameGitPullRequest","IconNameGlasses","IconNameGlobe","IconNameGolf","IconNameGrid","IconNameHammer","IconNameHandLeft","IconNameHandRight","IconNameHappy","IconNameHardwareChip","IconNameHeadset","IconNameHeartCircle","IconNameHeartDislikeCircle","IconNameHeartDislike","IconNameHeartHalf","IconNameHeart","IconNameHelpBuoy","IconNameHelpCircle","IconNameHome","IconNameHourglass","IconNameIceCream","IconNameIdCard","IconNameImage","IconNameImages","IconNameInfinite","IconNameInformationCircle","IconNameInvertMode","IconNameJournal","IconNameKey","IconNameKeypad","IconNameLanguage","IconNameLaptop","IconNameLayers","IconNameLeaf","IconNameLibrary","IconNameLink","IconNameListCircle","IconNameList","IconNameLocate","IconNameLocation","IconNameLockClosed","IconNameLockOpen","IconNameLogIn","IconNameLogOut","IconNameLogoAlipay","IconNameLogoAmazon","IconNameLogoAmplify","IconNameLogoAndroid","IconNameMagnet","IconNameMailOpen","IconNameMailUnread","IconNameMail","IconNameMaleFemale","IconNameMale","IconNameMan","IconNameMap","IconNameMedal","IconNameMedical","IconNameMedkit","IconNameMegaphone","IconNameMenu","IconNameMicCircle","IconNameMicOffCircle","IconNameMicOff","IconNameMic","IconNameMoon","IconNameMove","IconNameMusicalNote","IconNameMusicalNotes","IconNameNavigateCircle","IconNameNavigate","IconNameNewspaper","IconNameNotificationsCircle","IconNameNotificationsOffCircle","IconNameNotificationsOff","IconNameNotifications","IconNameNuclear","IconNameNutrition","IconNameOptions","IconNamePaperPlane","IconNamePartlySunny","IconNamePauseCircle","IconNamePause","IconNamePaw","IconNamePencil","IconNamePeopleCircle","IconNamePeople","IconNamePersonAdd","IconNamePersonCircle","IconNamePersonRemove","IconNamePerson","IconNamePhoneLandscape","IconNamePhonePortrait","IconNamePieChart","IconNamePin","IconNamePint","IconNamePizza","IconNamePlanet","IconNamePlayBackCircle","IconNamePlayBack","IconNamePlayCircle","IconNamePlayForwardCircle","IconNamePlayForward","IconNamePlaySkipBackCircle","IconNamePlaySkipBack","IconNamePlaySkipForwardCircle","IconNamePlaySkipForward","IconNamePlay","IconNamePodium","IconNamePower","IconNamePricetag","IconNamePricetags","IconNamePrint","IconNamePrism","IconNamePulse","IconNamePush","IconNameQrCode","IconNameRadioButtonOff","IconNameRadioButtonOn","IconNameRadio","IconNameRainy","IconNameReader","IconNameReceipt","IconNameRecording","IconNameRefreshCircle","IconNameRefresh","IconNameReloadCircle","IconNameReload","IconNameRemoveCircle","IconNameRepeat","IconNameResize","IconNameRestaurant","IconNameRibbon","IconNameRocket","IconNameRose","IconNameSad","IconNameSave","IconNameScale","IconNameScanCircle","IconNameScan","IconNameSchool","IconNameSearchCircle","IconNameSearch","IconNameSend","IconNameServer","IconNameSettings","IconNameShapes","IconNameShareSocial","IconNameShare","IconNameShieldCheckmark","IconNameShieldHalf","IconNameShield","IconNameShirt","IconNameShuffle","IconNameSkull","IconNameSnow","IconNameSparkles","IconNameSpeedometer","IconNameSquare","IconNameStarHalf","IconNameStar","IconNameStatsChart","IconNameStopCircle","IconNameStop","IconNameStopwatch","IconNameStorefront","IconNameSubway","IconNameSunny","IconNameSwapHorizontal","IconNameSwapVertical","IconNameSyncCircle","IconNameSync","IconNameTabletLandscape","IconNameTabletPortrait","IconNameTelescope","IconNameTennisball","IconNameTerminal","IconNameText","IconNameThermometer","IconNameThumbsDown","IconNameThumbsUp","IconNameThunderstorm","IconNameTicket","IconNameTime","IconNameTimer","IconNameToday","IconNameToggle","IconNameTrailSign","IconNameTrain","IconNameTransgender","IconNameTrashBin","IconNameTrash","IconNameTrendingDown","IconNameTrendingUp","IconNameTriangle","IconNameTrophy","IconNameTv","IconNameUmbrella","IconNameUnlink","IconNameVideocamOff","IconNameVideocam","IconNameVolumeHigh","IconNameVolumeLow","IconNameVolumeMedium","IconNameVolumeMute","IconNameVolumeOff","IconNameWalk","IconNameWallet","IconNameWarning","IconNameWatch","IconNameWater","IconNameWifi","IconNameWine","IconNameWoman"]},"apimodel.Member":{"description":"The member","properties":{"global_name":{"description":"The global name of the member in the network","example":"john.any","type":"string"},"icon":{"$ref":"#/components/schemas/apimodel.Icon"},"id":{"description":"The profile object id of the member","example":"_participant_bafyreigyfkt6rbv24sbv5aq2hko1bhmv5xxlf22b4bypdu6j7hnphm3psq_23me69r569oi1_AAjEaEwPF4nkEh9AWkqEnzcQ8HziBB4ETjiTpvRCQvWnSMDZ","type":"string"},"identity":{"description":"The identity of the member in the network","example":"AAjEaEwPF4nkEh7AWkqEnzcQ8HziGB4ETjiTpvRCQvWnSMDZ","type":"string"},"name":{"description":"The name of the member","example":"John Doe","type":"string"},"object":{"description":"The data model of the object","example":"member","type":"string"},"role":{"description":"The role of the member","enum":["viewer","editor","admin","owner","no_permission"],"example":"owner","type":"string"},"status":{"description":"The status of the member","enum":["joining","active","removed","declined","removing","canceled"],"example":"active","type":"string"}},"type":"object"},"apimodel.MemberResponse":{"properties":{"member":{"$ref":"#/components/schemas/apimodel.Member"}},"type":"object"},"apimodel.MultiSelectFilterItem":{"properties":{"condition":{"$ref":"#/components/schemas/apimodel.FilterCondition"},"multi_select":{"description":"The tag IDs to filter by","example":["bafyreiaixlnaefu3ci22zdenjhsdlyaeeoyjrsid5qhfeejzlccijbj7sq","bafyreie6n5l5nkbjal37su54cha4coy7qzuhrnajluzv5qd5jvtsrxkequ"],"items":{"type":"string"},"type":"array","uniqueItems":false},"property_key":{"description":"The property key to filter on","example":"tag","type":"string"}},"type":"object"},"apimodel.MultiSelectPropertyLinkValue":{"properties":{"key":{"example":"tag","type":"string"},"multi_select":{"description":"The selected tags (by key, e.g., \"important\", or ID, e.g., \"bafyrei...\") of the property; see ListTags endpoint for valid values","example":["important","bafyreie6n5l5nkbjal37su54cha4coy7qzuhrnajluzv5qd5jvtsrxkequ"],"items":{"type":"string"},"type":"array","uniqueItems":false}},"type":"object"},"apimodel.MultiSelectPropertyValue":{"properties":{"format":{"$ref":"#/components/schemas/apimodel.PropertyFormat"},"id":{"description":"The id of the property","example":"bafyreids36kpw5ppuwm3ce2p4ezb3ab7cihhkq6yfbwzwpp4mln7rcgw7a","type":"string"},"key":{"description":"The key of the property","example":"tag","type":"string"},"multi_select":{"description":"The selected tag values of the property","items":{"$ref":"#/components/schemas/apimodel.Tag"},"type":"array","uniqueItems":false},"name":{"description":"The name of the property","example":"Tag","type":"string"},"object":{"description":"The data model of the object","example":"property","type":"string"}},"type":"object"},"apimodel.NamedIcon":{"properties":{"color":{"$ref":"#/components/schemas/apimodel.Color"},"format":{"$ref":"#/components/schemas/apimodel.IconFormat"},"name":{"$ref":"#/components/schemas/apimodel.IconName"}},"type":"object"},"apimodel.NumberFilterItem":{"properties":{"condition":{"$ref":"#/components/schemas/apimodel.FilterCondition"},"number":{"description":"The number value to filter by","example":42,"type":"number"},"property_key":{"description":"The property key to filter on","example":"height","type":"string"}},"type":"object"},"apimodel.NumberPropertyLinkValue":{"properties":{"key":{"example":"height","type":"string"},"number":{"description":"The number value of the property","example":42,"type":"number"}},"type":"object"},"apimodel.NumberPropertyValue":{"properties":{"format":{"$ref":"#/components/schemas/apimodel.PropertyFormat"},"id":{"description":"The id of the property","example":"bafyreids36kpw5ppuwm3ce2p4ezb3ab7cihhkq6yfbwzwpp4mln7rcgw7a","type":"string"},"key":{"description":"The key of the property","example":"height","type":"string"},"name":{"description":"The name of the property","example":"Height","type":"string"},"number":{"description":"The number value of the property","example":42,"type":"number"},"object":{"description":"The data model of the object","example":"property","type":"string"}},"type":"object"},"apimodel.Object":{"properties":{"archived":{"description":"Whether the object is archived","example":false,"type":"boolean"},"icon":{"$ref":"#/components/schemas/apimodel.Icon"},"id":{"description":"The id of the object","example":"bafyreie6n5l5nkbjal37su54cha4coy7qzuhrnajluzv5qd5jvtsrxkequ","type":"string"},"layout":{"$ref":"#/components/schemas/apimodel.ObjectLayout"},"name":{"description":"The name of the object","example":"My object","type":"string"},"object":{"description":"The data model of the object","example":"object","type":"string"},"properties":{"description":"The properties of the object","items":{"$ref":"#/components/schemas/apimodel.PropertyWithValue"},"type":"array","uniqueItems":false},"snippet":{"description":"The snippet of the object, especially important for notes as they don't have a name","example":"The beginning of the object body...","type":"string"},"space_id":{"description":"The id of the space the object is in","example":"bafyreigyfkt6rbv24sbv5aq2hko3bhmv5xxlf22b4bypdu6j7hnphm3psq.23me69r569oi1","type":"string"},"type":{"$ref":"#/components/schemas/apimodel.Type"}},"type":"object"},"apimodel.ObjectLayout":{"description":"The layout of the object","example":"basic","type":"string","x-enum-varnames":["ObjectLayoutBasic","ObjectLayoutProfile","ObjectLayoutAction","ObjectLayoutNote","ObjectLayoutBookmark","ObjectLayoutSet","ObjectLayoutCollection","ObjectLayoutParticipant","ObjectLayoutChat"]},"apimodel.ObjectResponse":{"properties":{"object":{"$ref":"#/components/schemas/apimodel.ObjectWithBody"}},"type":"object"},"apimodel.ObjectWithBody":{"description":"The object","properties":{"archived":{"description":"Whether the object is archived","example":false,"type":"boolean"},"icon":{"$ref":"#/components/schemas/apimodel.Icon"},"id":{"description":"The id of the object","example":"bafyreie6n5l5nkbjal37su54cha4coy7qzuhrnajluzv5qd5jvtsrxkequ","type":"string"},"layout":{"description":"The layout of the object","example":"basic","type":"string","x-enum-varnames":["ObjectLayoutBasic","ObjectLayoutProfile","ObjectLayoutAction","ObjectLayoutNote","ObjectLayoutBookmark","ObjectLayoutSet","ObjectLayoutCollection","ObjectLayoutParticipant","ObjectLayoutChat"]},"markdown":{"description":"The markdown body of the object","example":"# This is the title\n...","type":"string"},"name":{"description":"The name of the object","example":"My object","type":"string"},"object":{"description":"The data model of the object","example":"object","type":"string"},"properties":{"description":"The properties of the object","items":{"$ref":"#/components/schemas/apimodel.PropertyWithValue"},"type":"array","uniqueItems":false},"snippet":{"description":"The snippet of the object, especially important for notes as they don't have a name","example":"The beginning of the object body...","type":"string"},"space_id":{"description":"The id of the space the object is in","example":"bafyreigyfkt6rbv24sbv5aq2hko3bhmv5xxlf22b4bypdu6j7hnphm3psq.23me69r569oi1","type":"string"},"type":{"$ref":"#/components/schemas/apimodel.Type"}},"type":"object"},"apimodel.ObjectsFilterItem":{"properties":{"condition":{"$ref":"#/components/schemas/apimodel.FilterCondition"},"objects":{"description":"Object Ids to filter by","example":["bafyreie6n5l5nkbjal37su54cha4coy7qzuhrnajluzv5qd5jvtsrxkequ"],"items":{"type":"string"},"type":"array","uniqueItems":false},"property_key":{"description":"The property key to filter on","example":"creator","type":"string"}},"type":"object"},"apimodel.ObjectsPropertyLinkValue":{"properties":{"key":{"example":"creator","type":"string"},"objects":{"description":"The object ids of the property","example":["bafyreie6n5l5nkbjal37su54cha4coy7qzuhrnajluzv5qd5jvtsrxkequ"],"items":{"type":"string"},"type":"array","uniqueItems":false}},"type":"object"},"apimodel.ObjectsPropertyValue":{"properties":{"format":{"$ref":"#/components/schemas/apimodel.PropertyFormat"},"id":{"description":"The id of the property","example":"bafyreids36kpw5ppuwm3ce2p4ezb3ab7cihhkq6yfbwzwpp4mln7rcgw7a","type":"string"},"key":{"description":"The key of the property","example":"creator","type":"string"},"name":{"description":"The name of the property","example":"Created by","type":"string"},"object":{"description":"The data model of the object","example":"property","type":"string"},"objects":{"description":"The object values of the property","example":["bafyreie6n5l5nkbjal37su54cha4coy7qzuhrnajluzv5qd5jvtsrxkequ"],"items":{"type":"string"},"type":"array","uniqueItems":false}},"type":"object"},"apimodel.PhoneFilterItem":{"properties":{"condition":{"$ref":"#/components/schemas/apimodel.FilterCondition"},"phone":{"description":"The phone value to filter by","example":"+1234567890","type":"string"},"property_key":{"description":"The property key to filter on","example":"phone","type":"string"}},"type":"object"},"apimodel.PhonePropertyLinkValue":{"properties":{"key":{"example":"phone","type":"string"},"phone":{"description":"The phone value of the property","example":"+1234567890","type":"string"}},"type":"object"},"apimodel.PhonePropertyValue":{"properties":{"format":{"$ref":"#/components/schemas/apimodel.PropertyFormat"},"id":{"description":"The id of the property","example":"bafyreids36kpw5ppuwm3ce2p4ezb3ab7cihhkq6yfbwzwpp4mln7rcgw7a","type":"string"},"key":{"description":"The key of the property","example":"phone","type":"string"},"name":{"description":"The name of the property","example":"Phone","type":"string"},"object":{"description":"The data model of the object","example":"property","type":"string"},"phone":{"description":"The phone value of the property","example":"+1234567890","type":"string"}},"type":"object"},"apimodel.Property":{"description":"The property","properties":{"format":{"$ref":"#/components/schemas/apimodel.PropertyFormat"},"id":{"description":"The id of the property","example":"bafyreids36kpw5ppuwm3ce2p4ezb3ab7cihhkq6yfbwzwpp4mln7rcgw7a","type":"string"},"key":{"description":"The key of the property","example":"last_modified_date","type":"string"},"name":{"description":"The name of the property","example":"Last modified date","type":"string"},"object":{"description":"The data model of the object","example":"property","type":"string"}},"type":"object"},"apimodel.PropertyFormat":{"description":"The format of the property","enum":["text","number","select","multi_select","date","files","checkbox","url","email","phone","objects"],"type":"string","x-enum-varnames":["PropertyFormatText","PropertyFormatNumber","PropertyFormatSelect","PropertyFormatMultiSelect","PropertyFormatDate","PropertyFormatFiles","PropertyFormatCheckbox","PropertyFormatUrl","PropertyFormatEmail","PropertyFormatPhone","PropertyFormatObjects"]},"apimodel.PropertyLink":{"properties":{"format":{"$ref":"#/components/schemas/apimodel.PropertyFormat"},"key":{"description":"The key of the property","example":"last_modified_date","type":"string"},"name":{"description":"The name of the property","example":"Last modified date","type":"string"}},"required":["format","key","name"],"type":"object"},"apimodel.PropertyLinkWithValue":{"oneOf":[{"$ref":"#/components/schemas/apimodel.TextPropertyLinkValue"},{"$ref":"#/components/schemas/apimodel.NumberPropertyLinkValue"},{"$ref":"#/components/schemas/apimodel.SelectPropertyLinkValue"},{"$ref":"#/components/schemas/apimodel.MultiSelectPropertyLinkValue"},{"$ref":"#/components/schemas/apimodel.DatePropertyLinkValue"},{"$ref":"#/components/schemas/apimodel.FilesPropertyLinkValue"},{"$ref":"#/components/schemas/apimodel.CheckboxPropertyLinkValue"},{"$ref":"#/components/schemas/apimodel.UrlPropertyLinkValue"},{"$ref":"#/components/schemas/apimodel.EmailPropertyLinkValue"},{"$ref":"#/components/schemas/apimodel.PhonePropertyLinkValue"},{"$ref":"#/components/schemas/apimodel.ObjectsPropertyLinkValue"}],"type":"object"},"apimodel.PropertyResponse":{"properties":{"property":{"$ref":"#/components/schemas/apimodel.Property"}},"type":"object"},"apimodel.PropertyWithValue":{"oneOf":[{"$ref":"#/components/schemas/apimodel.TextPropertyValue"},{"$ref":"#/components/schemas/apimodel.NumberPropertyValue"},{"$ref":"#/components/schemas/apimodel.SelectPropertyValue"},{"$ref":"#/components/schemas/apimodel.MultiSelectPropertyValue"},{"$ref":"#/components/schemas/apimodel.DatePropertyValue"},{"$ref":"#/components/schemas/apimodel.FilesPropertyValue"},{"$ref":"#/components/schemas/apimodel.CheckboxPropertyValue"},{"$ref":"#/components/schemas/apimodel.UrlPropertyValue"},{"$ref":"#/components/schemas/apimodel.EmailPropertyValue"},{"$ref":"#/components/schemas/apimodel.PhonePropertyValue"},{"$ref":"#/components/schemas/apimodel.ObjectsPropertyValue"}],"type":"object"},"apimodel.ReadChatMessagesRequest":{"properties":{"after_order_id":{"example":"00a1b2c3d4e5f0","type":"string"},"before_order_id":{"example":"00a1b2c3d4e5f6","type":"string"},"last_state_id":{"example":"state-id","type":"string"},"type":{"enum":["messages","mentions"],"example":"messages","type":"string"}},"type":"object"},"apimodel.ReadChatReactionsRequest":{"properties":{"order_id":{"example":"00a1b2c3d4e5f6","type":"string"}},"type":"object"},"apimodel.SearchRequest":{"properties":{"filters":{"$ref":"#/components/schemas/apimodel.FilterExpression"},"query":{"description":"The text to search within object names and content; use types field for type filtering","example":"test","type":"string"},"sort":{"$ref":"#/components/schemas/apimodel.SortOptions"},"types":{"description":"The types of objects to include in results (e.g., \"page\", \"task\", \"bookmark\"); see ListTypes endpoint for valid values. File-layout types (file, image, video, audio) are excluded by default and must be listed explicitly here to be searchable.","example":["page","task","bookmark"],"items":{"type":"string"},"type":"array","uniqueItems":false}},"type":"object"},"apimodel.SelectFilterItem":{"properties":{"condition":{"$ref":"#/components/schemas/apimodel.FilterCondition"},"property_key":{"description":"The property key to filter on","example":"status","type":"string"},"select":{"description":"Tag Id - for eq/ne/in conditions (single selection)","example":"tag_id","type":"string"}},"type":"object"},"apimodel.SelectPropertyLinkValue":{"properties":{"key":{"example":"status","type":"string"},"select":{"description":"The selected tag (by key, e.g., \"important\", or ID, e.g., \"bafyrei...\") of the property; see ListTags endpoint for valid values","example":"important","type":"string"}},"type":"object"},"apimodel.SelectPropertyValue":{"properties":{"format":{"$ref":"#/components/schemas/apimodel.PropertyFormat"},"id":{"description":"The id of the property","example":"bafyreids36kpw5ppuwm3ce2p4ezb3ab7cihhkq6yfbwzwpp4mln7rcgw7a","type":"string"},"key":{"description":"The key of the property","example":"status","type":"string"},"name":{"description":"The name of the property","example":"Status","type":"string"},"object":{"description":"The data model of the object","example":"property","type":"string"},"select":{"$ref":"#/components/schemas/apimodel.Tag"}},"type":"object"},"apimodel.Sort":{"properties":{"format":{"$ref":"#/components/schemas/apimodel.PropertyFormat"},"id":{"description":"The id of the sort","example":"67bf3f21cda9134102e2422c","type":"string"},"property_key":{"description":"The property key used for sorting","example":"name","type":"string"},"sort_type":{"description":"The sort direction","enum":["asc","desc","custom"],"example":"asc","type":"string"}},"type":"object"},"apimodel.SortDirection":{"default":"desc","description":"The direction to sort the search results by","enum":["asc","desc"],"type":"string","x-enum-varnames":["Asc","Desc"]},"apimodel.SortOptions":{"description":"The sorting options for the search results","properties":{"direction":{"$ref":"#/components/schemas/apimodel.SortDirection"},"property_key":{"$ref":"#/components/schemas/apimodel.SortProperty"}},"type":"object"},"apimodel.SortProperty":{"default":"last_modified_date","description":"The key of the property to sort the search results by","enum":["created_date","last_modified_date","last_opened_date","name"],"type":"string","x-enum-varnames":["CreatedDate","LastModifiedDate","LastOpenedDate","Name"]},"apimodel.Space":{"description":"The space","properties":{"description":{"description":"The description of the space","example":"The local-first wiki","type":"string"},"gateway_url":{"description":"The gateway url to serve files and media","example":"http://127.0.0.1:31006","type":"string"},"icon":{"$ref":"#/components/schemas/apimodel.Icon"},"id":{"description":"The id of the space","example":"bafyreigyfkt6rbv24sbv5aq2hko3bhmv5xxlf22b4bypdu6j7hnphm3psq.23me69r569oi1","type":"string"},"name":{"description":"The name of the space","example":"My Space","type":"string"},"network_id":{"description":"The network id of the space","example":"N83gJpVd9MuNRZAuJLZ7LiMntTThhPc6DtzWWVjb1M3PouVU","type":"string"},"object":{"description":"The space type","enum":["anytype.space","anytype.chatspace","anytype.onetoone","anytype.techspace"],"example":"anytype.space","type":"string"}},"type":"object"},"apimodel.SpaceResponse":{"properties":{"space":{"$ref":"#/components/schemas/apimodel.Space"}},"type":"object"},"apimodel.Tag":{"description":"The selected tag value of the property","properties":{"color":{"$ref":"#/components/schemas/apimodel.Color"},"id":{"description":"The id of the tag","example":"bafyreiaixlnaefu3ci22zdenjhsdlyaeeoyjrsid5qhfeejzlccijbj7sq","type":"string"},"key":{"description":"The key of the tag","example":"67b0d3e3cda913b84c1299b1","type":"string"},"name":{"description":"The name of the tag","example":"in-progress","type":"string"},"object":{"description":"The data model of the object","example":"tag","type":"string"}},"type":"object"},"apimodel.TagResponse":{"properties":{"tag":{"$ref":"#/components/schemas/apimodel.Tag"}},"type":"object"},"apimodel.TemplateResponse":{"properties":{"template":{"$ref":"#/components/schemas/apimodel.ObjectWithBody"}},"type":"object"},"apimodel.TextFilterItem":{"properties":{"condition":{"$ref":"#/components/schemas/apimodel.FilterCondition"},"property_key":{"description":"The property key to filter on","example":"description","type":"string"},"text":{"description":"The text value to filter by","example":"Some text...","type":"string"}},"type":"object"},"apimodel.TextMark":{"properties":{"from":{"example":0,"type":"integer"},"param":{"example":"","type":"string"},"to":{"example":5,"type":"integer"},"type":{"example":"bold","type":"string"}},"type":"object"},"apimodel.TextPropertyLinkValue":{"properties":{"key":{"example":"description","type":"string"},"text":{"description":"The text value of the property","example":"Some text...","type":"string"}},"type":"object"},"apimodel.TextPropertyValue":{"properties":{"format":{"$ref":"#/components/schemas/apimodel.PropertyFormat"},"id":{"description":"The id of the property","example":"bafyreids36kpw5ppuwm3ce2p4ezb3ab7cihhkq6yfbwzwpp4mln7rcgw7a","type":"string"},"key":{"description":"The key of the property","example":"description","type":"string"},"name":{"description":"The name of the property","example":"Description","type":"string"},"object":{"description":"The data model of the object","example":"property","type":"string"},"text":{"description":"The text value of the property","example":"Some text...","type":"string"}},"type":"object"},"apimodel.TextRange":{"properties":{"from":{"example":0,"type":"integer"},"to":{"example":5,"type":"integer"}},"type":"object"},"apimodel.ToggleReactionRequest":{"properties":{"emoji":{"example":"👍","type":"string"}},"required":["emoji"],"type":"object"},"apimodel.Type":{"description":"The type of the object, or null if the type has been deleted.","nullable":true,"properties":{"archived":{"description":"Whether the type is archived","example":false,"type":"boolean"},"icon":{"$ref":"#/components/schemas/apimodel.Icon"},"id":{"description":"The id of the type (which is unique across spaces)","example":"bafyreigyb6l5szohs32ts26ku2j42yd65e6hqy2u3gtzgdwqv6hzftsetu","type":"string"},"key":{"description":"The key of the type (can be the same across spaces for known types)","example":"page","type":"string"},"layout":{"description":"The layout of the object","enum":["basic","profile","action","note","bookmark","set","collection","participant"],"type":"string","x-enum-varnames":["ObjectLayoutBasic","ObjectLayoutProfile","ObjectLayoutAction","ObjectLayoutNote","ObjectLayoutBookmark","ObjectLayoutSet","ObjectLayoutCollection","ObjectLayoutParticipant","ObjectLayoutChat"]},"name":{"description":"The name of the type","example":"Page","type":"string"},"object":{"description":"The data model of the object","example":"type","type":"string"},"plural_name":{"description":"The plural name of the type","example":"Pages","type":"string"},"properties":{"description":"The properties linked to the type","items":{"$ref":"#/components/schemas/apimodel.Property"},"type":"array","uniqueItems":false}},"type":"object"},"apimodel.TypeLayout":{"description":"The layout of the type","enum":["basic","profile","action","note"],"type":"string","x-enum-varnames":["TypeLayoutBasic","TypeLayoutProfile","TypeLayoutAction","TypeLayoutNote"]},"apimodel.TypeResponse":{"properties":{"type":{"$ref":"#/components/schemas/apimodel.Type"}},"type":"object"},"apimodel.UpdateObjectRequest":{"properties":{"icon":{"$ref":"#/components/schemas/apimodel.Icon"},"markdown":{"description":"The updated body of the object","example":"This is the updated body of the object. Markdown syntax is supported here.","type":"string"},"name":{"description":"The name of the object","example":"My object","type":"string"},"properties":{"description":"The properties to set for the object; see ListTypes or GetType endpoints for linked properties","items":{"$ref":"#/components/schemas/apimodel.PropertyLinkWithValue"},"type":"array","uniqueItems":false},"type_key":{"description":"The key of the type of object to set","example":"page","type":"string"}},"type":"object"},"apimodel.UpdatePropertyRequest":{"properties":{"key":{"description":"The key to set for the property; ; should always be snake_case, otherwise it will be converted to snake_case","example":"some_user_defined_property_key","type":"string"},"name":{"description":"The name to set for the property","example":"Last modified date","type":"string"}},"required":["name"],"type":"object"},"apimodel.UpdateSpaceRequest":{"properties":{"description":{"description":"The description of the space","example":"The local-first wiki","type":"string"},"name":{"description":"The name of the space","example":"New Space","type":"string"}},"type":"object"},"apimodel.UpdateTagRequest":{"properties":{"color":{"$ref":"#/components/schemas/apimodel.Color"},"key":{"description":"The key to set for the tag","example":"in_progress","type":"string"},"name":{"description":"The name to set for the tag","example":"In progress","type":"string"}},"type":"object"},"apimodel.UpdateTypeRequest":{"properties":{"icon":{"$ref":"#/components/schemas/apimodel.Icon"},"key":{"description":"The key to set for the type; should always be snake_case, otherwise it will be converted to snake_case","example":"some_user_defined_type_key","type":"string"},"layout":{"$ref":"#/components/schemas/apimodel.TypeLayout"},"name":{"description":"The name to set for the type","example":"Page","type":"string"},"plural_name":{"description":"The plural name to set for the type","example":"Pages","type":"string"},"properties":{"description":"The properties to set for the type","items":{"$ref":"#/components/schemas/apimodel.PropertyLink"},"type":"array","uniqueItems":false}},"type":"object"},"apimodel.UrlFilterItem":{"properties":{"condition":{"$ref":"#/components/schemas/apimodel.FilterCondition"},"property_key":{"description":"The property key to filter on","example":"source","type":"string"},"url":{"description":"The Url value to filter by","example":"https://example.com","type":"string"}},"type":"object"},"apimodel.UrlPropertyLinkValue":{"properties":{"key":{"example":"source","type":"string"},"url":{"description":"The URL value of the property","example":"https://example.com","type":"string"}},"type":"object"},"apimodel.UrlPropertyValue":{"properties":{"format":{"$ref":"#/components/schemas/apimodel.PropertyFormat"},"id":{"description":"The id of the property","example":"bafyreids36kpw5ppuwm3ce2p4ezb3ab7cihhkq6yfbwzwpp4mln7rcgw7a","type":"string"},"key":{"description":"The key of the property","example":"source","type":"string"},"name":{"description":"The name of the property","example":"Source","type":"string"},"object":{"description":"The data model of the object","example":"property","type":"string"},"url":{"description":"The URL value of the property","example":"https://example.com","type":"string"}},"type":"object"},"apimodel.View":{"properties":{"filters":{"description":"The list of filters","items":{"$ref":"#/components/schemas/apimodel.Filter"},"type":"array","uniqueItems":false},"id":{"description":"The id of the view","example":"67bf3f21cda9134102e2422c","type":"string"},"layout":{"description":"The layout of the view","enum":["grid","list","gallery","kanban","calendar","graph"],"example":"grid","type":"string"},"name":{"description":"The name of the view","example":"All","type":"string"},"sorts":{"description":"The list of sorts","items":{"$ref":"#/components/schemas/apimodel.Sort"},"type":"array","uniqueItems":false}},"type":"object"},"pagination.PaginatedResponse-apimodel_ChatMessageSearchResult":{"properties":{"data":{"description":"The list of items in the current result set","items":{"$ref":"#/components/schemas/apimodel.ChatMessageSearchResult"},"type":"array","uniqueItems":false},"pagination":{"$ref":"#/components/schemas/pagination.PaginationMeta"}},"type":"object"},"pagination.PaginatedResponse-apimodel_Member":{"properties":{"data":{"description":"The list of items in the current result set","items":{"$ref":"#/components/schemas/apimodel.Member"},"type":"array","uniqueItems":false},"pagination":{"$ref":"#/components/schemas/pagination.PaginationMeta"}},"type":"object"},"pagination.PaginatedResponse-apimodel_Object":{"properties":{"data":{"description":"The list of items in the current result set","items":{"$ref":"#/components/schemas/apimodel.Object"},"type":"array","uniqueItems":false},"pagination":{"$ref":"#/components/schemas/pagination.PaginationMeta"}},"type":"object"},"pagination.PaginatedResponse-apimodel_Property":{"properties":{"data":{"description":"The list of items in the current result set","items":{"$ref":"#/components/schemas/apimodel.Property"},"type":"array","uniqueItems":false},"pagination":{"$ref":"#/components/schemas/pagination.PaginationMeta"}},"type":"object"},"pagination.PaginatedResponse-apimodel_Space":{"properties":{"data":{"description":"The list of items in the current result set","items":{"$ref":"#/components/schemas/apimodel.Space"},"type":"array","uniqueItems":false},"pagination":{"$ref":"#/components/schemas/pagination.PaginationMeta"}},"type":"object"},"pagination.PaginatedResponse-apimodel_Tag":{"properties":{"data":{"description":"The list of items in the current result set","items":{"$ref":"#/components/schemas/apimodel.Tag"},"type":"array","uniqueItems":false},"pagination":{"$ref":"#/components/schemas/pagination.PaginationMeta"}},"type":"object"},"pagination.PaginatedResponse-apimodel_Type":{"properties":{"data":{"description":"The list of items in the current result set","items":{"$ref":"#/components/schemas/apimodel.Type"},"type":"array","uniqueItems":false},"pagination":{"$ref":"#/components/schemas/pagination.PaginationMeta"}},"type":"object"},"pagination.PaginatedResponse-apimodel_View":{"properties":{"data":{"description":"The list of items in the current result set","items":{"$ref":"#/components/schemas/apimodel.View"},"type":"array","uniqueItems":false},"pagination":{"$ref":"#/components/schemas/pagination.PaginationMeta"}},"type":"object"},"pagination.PaginationMeta":{"description":"The pagination metadata for the response","properties":{"has_more":{"description":"Indicates if there are more items available beyond the current result set","example":true,"type":"boolean"},"limit":{"description":"The maximum number of items returned in the result set","example":100,"type":"integer"},"offset":{"description":"The number of items skipped before starting to collect the result set","example":0,"type":"integer"},"total":{"description":"The total number of items available for the endpoint","example":1000,"type":"integer"}},"type":"object"},"util.ForbiddenError":{"properties":{"code":{"example":"forbidden","type":"string"},"message":{"example":"Forbidden","type":"string"},"object":{"example":"error","type":"string"},"status":{"example":403,"type":"integer"}},"type":"object"},"util.GoneError":{"properties":{"code":{"example":"resource_gone","type":"string"},"message":{"example":"Resource is gone","type":"string"},"object":{"example":"error","type":"string"},"status":{"example":410,"type":"integer"}},"type":"object"},"util.NotFoundError":{"properties":{"code":{"example":"object_not_found","type":"string"},"message":{"example":"Resource not found","type":"string"},"object":{"example":"error","type":"string"},"status":{"example":404,"type":"integer"}},"type":"object"},"util.RateLimitError":{"properties":{"code":{"example":"rate_limit_exceeded","type":"string"},"message":{"example":"Rate limit exceeded","type":"string"},"object":{"example":"error","type":"string"},"status":{"example":429,"type":"integer"}},"type":"object"},"util.ServerError":{"properties":{"code":{"example":"internal_server_error","type":"string"},"message":{"example":"Internal server error","type":"string"},"object":{"example":"error","type":"string"},"status":{"example":500,"type":"integer"}},"type":"object"},"util.UnauthorizedError":{"properties":{"code":{"example":"unauthorized","type":"string"},"message":{"example":"Unauthorized","type":"string"},"object":{"example":"error","type":"string"},"status":{"example":401,"type":"integer"}},"type":"object"},"util.ValidationError":{"properties":{"code":{"example":"bad_request","type":"string"},"message":{"example":"Bad request","type":"string"},"object":{"example":"error","type":"string"},"status":{"example":400,"type":"integer"}},"type":"object"}},"securitySchemes":{"bearerauth":{"bearerFormat":"JWT","scheme":"bearer","type":"http"}}}, - "info": {"contact":{"email":"support@anytype.io","name":"Anytype Support","url":"https://anytype.io/contact"},"description":"{{escape .Description}}","license":{"name":"Any Source Available License 1.0","url":"https://github.com/anyproto/anytype-api/blob/main/LICENSE.md"},"termsOfService":"https://anytype.io/terms_of_use","title":"{{.Title}}","version":"{{.Version}}"}, - "externalDocs": {"description":"OpenAPI","url":"https://swagger.io/resources/open-api/"}, - "paths": {"/v1/auth/api_keys":{"post":{"description":"After receiving a ` + "`" + `challenge_id` + "`" + ` from the ` + "`" + `/v1/auth/challenges` + "`" + ` endpoint, the client calls this endpoint to provide the corresponding 4-digit code along with the challenge ID. The endpoint verifies that the challenge solution is correct and, if it is, returns an ` + "`" + `api_key` + "`" + `. This endpoint is central to the authentication process, as it validates the user's identity and issues a key that can be used for further interactions with the API.","operationId":"create_api_key","parameters":[{"description":"The version of the API to use","in":"header","name":"Anytype-Version","required":true,"schema":{"default":"2025-11-08","type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/apimodel.CreateApiKeyRequest"}}},"description":"The request body containing the challenge ID and code","required":true},"responses":{"201":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/apimodel.CreateApiKeyResponse"}}},"description":"The API key that can be used in the Authorization header for subsequent requests"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/util.ValidationError"}}},"description":"Bad request"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/util.ServerError"}}},"description":"Internal server error"}},"summary":"Create API Key","tags":["Auth"]}},"/v1/auth/challenges":{"post":{"description":"Generates a one-time authentication challenge for granting API access to the user's vault. Upon providing a valid ` + "`" + `app_name` + "`" + `, the server issues a unique ` + "`" + `challenge_id` + "`" + ` and displays a 4-digit code within the Anytype Desktop. The ` + "`" + `challenge_id` + "`" + ` must then be used with the ` + "`" + `/v1/auth/api_keys` + "`" + ` endpoint to solve the challenge and retrieve an authentication token. This mechanism ensures that only trusted applications and authorized users gain access.","operationId":"create_auth_challenge","parameters":[{"description":"The version of the API to use","in":"header","name":"Anytype-Version","required":true,"schema":{"default":"2025-11-08","type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/apimodel.CreateChallengeRequest"}}},"description":"The request body containing the app name","required":true},"responses":{"201":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/apimodel.CreateChallengeResponse"}}},"description":"The challenge ID associated with the started challenge"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/util.ValidationError"}}},"description":"Bad request"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/util.ServerError"}}},"description":"Internal server error"}},"summary":"Create Challenge","tags":["Auth"]}},"/v1/search":{"post":{"description":"Executes a global search over all spaces accessible to the authenticated user. The request body must specify the ` + "`" + `query` + "`" + ` text (currently matching only name and snippet of an object), optional filters on types (e.g., \"page\", \"task\"), and sort directives (default: descending by last modified date). File-layout objects (file, image, video, audio, pdf) are excluded from results by default; to include them, list one of the file type keys (\"file\", \"image\", \"video\", \"audio\") in the ` + "`" + `types` + "`" + ` field. Pagination is controlled via ` + "`" + `offset` + "`" + ` and ` + "`" + `limit` + "`" + ` query parameters to facilitate lazy loading in client UIs. The response returns a unified list of matched objects with their metadata and properties.","operationId":"search_global","parameters":[{"description":"The version of the API to use","in":"header","name":"Anytype-Version","required":true,"schema":{"default":"2025-11-08","type":"string"}},{"description":"The number of items to skip before starting to collect the result set","in":"query","name":"offset","schema":{"default":0,"type":"integer"}},{"description":"The number of items to return","in":"query","name":"limit","schema":{"default":100,"maximum":1000,"type":"integer"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/apimodel.SearchRequest"}}},"description":"The search parameters used to filter and sort the results","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/pagination.PaginatedResponse-apimodel_Object"}}},"description":"The list of objects matching the search criteria"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/util.UnauthorizedError"}}},"description":"Unauthorized"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/util.ServerError"}}},"description":"Internal server error"}},"security":[{"bearerauth":[]}],"summary":"Search objects across all spaces","tags":["Search"]}},"/v1/spaces":{"get":{"description":"Retrieves a paginated list of all spaces that are accessible by the authenticated user. Each space record contains detailed information such as the space ID, name, icon (derived either from an emoji or image URL), and additional metadata. This endpoint is key to displaying a user's workspaces.\nSupports dynamic filtering via query parameters (e.g., ?name[contains]=project). See FilterCondition enum for available conditions.","operationId":"list_spaces","parameters":[{"description":"The version of the API to use","in":"header","name":"Anytype-Version","required":true,"schema":{"default":"2025-11-08","type":"string"}},{"description":"The number of items to skip before starting to collect the result set","in":"query","name":"offset","schema":{"default":0,"type":"integer"}},{"description":"The number of items to return","in":"query","name":"limit","schema":{"default":100,"maximum":1000,"type":"integer"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/pagination.PaginatedResponse-apimodel_Space"}}},"description":"The list of spaces accessible by the authenticated user"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/util.UnauthorizedError"}}},"description":"Unauthorized"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/util.ServerError"}}},"description":"Internal server error"}},"security":[{"bearerauth":[]}],"summary":"List spaces","tags":["Spaces"]},"post":{"description":"Creates a new space based on a supplied name and description in the JSON request body. The endpoint is subject to rate limiting and automatically applies default configurations such as generating a random icon and initializing the workspace with default settings (for example, a default dashboard or home page). On success, the new space’s full metadata is returned, enabling the client to immediately switch context to the new internal.","operationId":"create_space","parameters":[{"description":"The version of the API to use","in":"header","name":"Anytype-Version","required":true,"schema":{"default":"2025-11-08","type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/apimodel.CreateSpaceRequest"}}},"description":"The space to create","required":true},"responses":{"201":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/apimodel.SpaceResponse"}}},"description":"The created space"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/util.ValidationError"}}},"description":"Bad request"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/util.UnauthorizedError"}}},"description":"Unauthorized"},"429":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/util.RateLimitError"}}},"description":"Rate limit exceeded"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/util.ServerError"}}},"description":"Internal server error"}},"security":[{"bearerauth":[]}],"summary":"Create space","tags":["Spaces"]}},"/v1/spaces/{space_id}":{"get":{"description":"Fetches full details about a single space identified by its space ID. The response includes metadata such as the space name, icon, and various workspace IDs (home, archive, profile, etc.). This detailed view supports use cases such as displaying space-specific settings.","operationId":"get_space","parameters":[{"description":"The version of the API to use","in":"header","name":"Anytype-Version","required":true,"schema":{"default":"2025-11-08","type":"string"}},{"description":"The ID of the space to retrieve; must be retrieved from ListSpaces endpoint","in":"path","name":"space_id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/apimodel.SpaceResponse"}}},"description":"The space details"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/util.UnauthorizedError"}}},"description":"Unauthorized"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/util.NotFoundError"}}},"description":"Space not found"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/util.ServerError"}}},"description":"Internal server error"}},"security":[{"bearerauth":[]}],"summary":"Get space","tags":["Spaces"]},"patch":{"description":"Updates the name or description of an existing space. The request body should contain the new name and/or description in JSON format. This endpoint is useful for renaming or rebranding a workspace without needing to recreate it. The updated space’s metadata is returned in the response.","operationId":"update_space","parameters":[{"description":"The version of the API to use","in":"header","name":"Anytype-Version","required":true,"schema":{"default":"2025-11-08","type":"string"}},{"description":"The ID of the space to update; must be retrieved from ListSpaces endpoint","in":"path","name":"space_id","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/apimodel.UpdateSpaceRequest"}}},"description":"The space details to update","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/apimodel.SpaceResponse"}}},"description":"The updated space"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/util.ValidationError"}}},"description":"Bad request"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/util.UnauthorizedError"}}},"description":"Unauthorized"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/util.ForbiddenError"}}},"description":"Forbidden"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/util.NotFoundError"}}},"description":"Space not found"},"429":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/util.RateLimitError"}}},"description":"Rate limit exceeded"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/util.ServerError"}}},"description":"Internal server error"}},"security":[{"bearerauth":[]}],"summary":"Update space","tags":["Spaces"]}},"/v1/spaces/{space_id}/chats":{"get":{"description":"Retrieves a paginated list of chat objects in the given space. Chat objects are containers for chat messages; use the returned chat IDs with the GetChatMessages endpoint to fetch their messages.\nSupports dynamic filtering via query parameters (see ListObjects for the full filter grammar).","operationId":"list_chats","parameters":[{"description":"The version of the API to use","in":"header","name":"Anytype-Version","required":true,"schema":{"default":"2025-11-08","type":"string"}},{"description":"The ID of the space in which to list chats; must be retrieved from ListSpaces endpoint","in":"path","name":"space_id","required":true,"schema":{"type":"string"}},{"description":"The number of items to skip before starting to collect the result set","in":"query","name":"offset","schema":{"default":0,"type":"integer"}},{"description":"The number of items to return","in":"query","name":"limit","schema":{"default":100,"maximum":1000,"type":"integer"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/pagination.PaginatedResponse-apimodel_Object"}}},"description":"The list of chats in the specified space"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/util.UnauthorizedError"}}},"description":"Unauthorized"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/util.ServerError"}}},"description":"Internal server error"}},"security":[{"bearerauth":[]}],"summary":"List chats","tags":["Chat"]},"post":{"description":"Creates a new chat object in the specified space. This is a convenience endpoint that hides the internal ` + "`" + `chat_derived` + "`" + ` type key.","operationId":"create_chat","parameters":[{"description":"The version of the API to use","in":"header","name":"Anytype-Version","required":true,"schema":{"default":"2025-11-08","type":"string"}},{"description":"The ID of the space","in":"path","name":"space_id","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/apimodel.CreateChatRequest"}}},"description":"The chat to create","required":true},"responses":{"201":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/apimodel.ObjectResponse"}}},"description":"The created chat"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/util.ValidationError"}}},"description":"Bad request"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/util.UnauthorizedError"}}},"description":"Unauthorized"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/util.ServerError"}}},"description":"Internal server error"}},"security":[{"bearerauth":[]}],"summary":"Create chat","tags":["Chat"]}},"/v1/spaces/{space_id}/chats/{chat_id}/messages":{"get":{"description":"Retrieves a list of messages from a chat. Supports cursor-based pagination via before_order_id and after_order_id query parameters.","operationId":"get_chat_messages","parameters":[{"description":"The version of the API to use","in":"header","name":"Anytype-Version","required":true,"schema":{"default":"2025-11-08","type":"string"}},{"description":"The ID of the space","in":"path","name":"space_id","required":true,"schema":{"type":"string"}},{"description":"The ID of the chat object","in":"path","name":"chat_id","required":true,"schema":{"type":"string"}},{"description":"Return messages before this order ID","in":"query","name":"before_order_id","schema":{"type":"string"}},{"description":"Return messages after this order ID","in":"query","name":"after_order_id","schema":{"type":"string"}},{"description":"Maximum number of messages to return","in":"query","name":"limit","schema":{"default":50,"maximum":1000,"minimum":1,"type":"integer"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/apimodel.ChatMessagesResponse"}}},"description":"The list of messages"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/util.ValidationError"}}},"description":"Bad request"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/util.UnauthorizedError"}}},"description":"Unauthorized"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/util.ServerError"}}},"description":"Internal server error"}},"security":[{"bearerauth":[]}],"summary":"Get chat messages","tags":["Chat"]},"post":{"description":"Adds a new message to the specified chat.","operationId":"add_chat_message","parameters":[{"description":"The version of the API to use","in":"header","name":"Anytype-Version","required":true,"schema":{"default":"2025-11-08","type":"string"}},{"description":"The ID of the space","in":"path","name":"space_id","required":true,"schema":{"type":"string"}},{"description":"The ID of the chat object","in":"path","name":"chat_id","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/apimodel.AddChatMessageRequest"}}},"description":"The message to add","required":true},"responses":{"201":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/apimodel.AddChatMessageResponse"}}},"description":"The created message ID"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/util.ValidationError"}}},"description":"Bad request"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/util.UnauthorizedError"}}},"description":"Unauthorized"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/util.ServerError"}}},"description":"Internal server error"}},"security":[{"bearerauth":[]}],"summary":"Add chat message","tags":["Chat"]}},"/v1/spaces/{space_id}/chats/{chat_id}/messages/read":{"post":{"description":"Marks messages within the given order-id range as read. Pass an empty body to mark the entire chat as read. ` + "`" + `type` + "`" + ` defaults to \"messages\"; use \"mentions\" to mark unread @-mentions instead.","operationId":"read_chat_messages","parameters":[{"description":"The version of the API to use","in":"header","name":"Anytype-Version","required":true,"schema":{"default":"2025-11-08","type":"string"}},{"description":"The ID of the space","in":"path","name":"space_id","required":true,"schema":{"type":"string"}},{"description":"The ID of the chat object","in":"path","name":"chat_id","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/apimodel.ReadChatMessagesRequest"}}},"description":"Read range parameters"},"responses":{"200":{"content":{"application/json":{}},"description":"Marked as read"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/util.ValidationError"}}},"description":"Bad request"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/util.UnauthorizedError"}}},"description":"Unauthorized"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/util.ServerError"}}},"description":"Internal server error"}},"security":[{"bearerauth":[]}],"summary":"Read messages","tags":["Chat"]}},"/v1/spaces/{space_id}/chats/{chat_id}/messages/search":{"get":{"description":"Performs a full-text search over the messages in the chat. Results are sorted by relevance and include a highlight snippet for each match.","operationId":"search_chat_messages","parameters":[{"description":"The version of the API to use","in":"header","name":"Anytype-Version","required":true,"schema":{"default":"2025-11-08","type":"string"}},{"description":"The ID of the space","in":"path","name":"space_id","required":true,"schema":{"type":"string"}},{"description":"The ID of the chat object","in":"path","name":"chat_id","required":true,"schema":{"type":"string"}},{"description":"Full-text query","in":"query","name":"query","required":true,"schema":{"type":"string"}},{"description":"The number of items to skip before starting to collect the result set","in":"query","name":"offset","schema":{"default":0,"type":"integer"}},{"description":"The number of items to return","in":"query","name":"limit","schema":{"default":100,"maximum":1000,"type":"integer"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/pagination.PaginatedResponse-apimodel_ChatMessageSearchResult"}}},"description":"The search results"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/util.ValidationError"}}},"description":"Bad request"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/util.UnauthorizedError"}}},"description":"Unauthorized"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/util.ServerError"}}},"description":"Internal server error"}},"security":[{"bearerauth":[]}],"summary":"Search chat messages","tags":["Chat"]}},"/v1/spaces/{space_id}/chats/{chat_id}/messages/stream":{"get":{"description":"Opens a Server-Sent Events stream for real-time chat updates. On connect, the last N messages are sent, followed by live events (message_added, message_updated, message_deleted, reactions_updated). Periodic SSE comment lines (` + "`" + `: heartbeat` + "`" + `) keep the connection alive during idle periods; per the SSE spec these are invisible to EventSource clients. Clients can tune the cadence with the Anytype-Heartbeat-Seconds header (1-60s, default 30s; out-of-range or unparsable values fall back to the default).","operationId":"chat_message_stream","parameters":[{"description":"The version of the API to use","in":"header","name":"Anytype-Version","required":true,"schema":{"default":"2025-11-08","type":"string"}},{"description":"Heartbeat interval in seconds (1-60, default 30)","in":"header","name":"Anytype-Heartbeat-Seconds","schema":{"default":30,"maximum":60,"minimum":1,"type":"integer"}},{"description":"The ID of the space","in":"path","name":"space_id","required":true,"schema":{"type":"string"}},{"description":"The ID of the chat object","in":"path","name":"chat_id","required":true,"schema":{"type":"string"}},{"description":"Number of recent messages to send on connect","in":"query","name":"limit","schema":{"default":50,"maximum":1000,"minimum":1,"type":"integer"}}],"responses":{"200":{"content":{"text/event-stream":{"schema":{"type":"string"}}},"description":"SSE stream of chat events"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/util.ValidationError"}}},"description":"Bad request"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/util.UnauthorizedError"}}},"description":"Unauthorized"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/util.ServerError"}}},"description":"Internal server error"}},"security":[{"bearerauth":[]}],"summary":"Subscribe to chat messages (SSE)","tags":["Chat"]}},"/v1/spaces/{space_id}/chats/{chat_id}/messages/{message_id}":{"delete":{"description":"Deletes a message from the specified chat.","operationId":"delete_chat_message","parameters":[{"description":"The version of the API to use","in":"header","name":"Anytype-Version","required":true,"schema":{"default":"2025-11-08","type":"string"}},{"description":"The ID of the space","in":"path","name":"space_id","required":true,"schema":{"type":"string"}},{"description":"The ID of the chat object","in":"path","name":"chat_id","required":true,"schema":{"type":"string"}},{"description":"The ID of the message to delete","in":"path","name":"message_id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{}},"description":"Message deleted successfully"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/util.UnauthorizedError"}}},"description":"Unauthorized"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/util.ServerError"}}},"description":"Internal server error"}},"security":[{"bearerauth":[]}],"summary":"Delete chat message","tags":["Chat"]},"get":{"description":"Retrieves a single message from a chat by its id.","operationId":"get_chat_message","parameters":[{"description":"The version of the API to use","in":"header","name":"Anytype-Version","required":true,"schema":{"default":"2025-11-08","type":"string"}},{"description":"The ID of the space","in":"path","name":"space_id","required":true,"schema":{"type":"string"}},{"description":"The ID of the chat object","in":"path","name":"chat_id","required":true,"schema":{"type":"string"}},{"description":"The ID of the message","in":"path","name":"message_id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/apimodel.ChatMessageResponse"}}},"description":"The message"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/util.UnauthorizedError"}}},"description":"Unauthorized"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/util.NotFoundError"}}},"description":"Not found"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/util.ServerError"}}},"description":"Internal server error"}},"security":[{"bearerauth":[]}],"summary":"Get chat message","tags":["Chat"]},"patch":{"description":"Edits the content of an existing message in the specified chat.","operationId":"edit_chat_message","parameters":[{"description":"The version of the API to use","in":"header","name":"Anytype-Version","required":true,"schema":{"default":"2025-11-08","type":"string"}},{"description":"The ID of the space","in":"path","name":"space_id","required":true,"schema":{"type":"string"}},{"description":"The ID of the chat object","in":"path","name":"chat_id","required":true,"schema":{"type":"string"}},{"description":"The ID of the message to edit","in":"path","name":"message_id","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/apimodel.EditChatMessageRequest"}}},"description":"The updated message content","required":true},"responses":{"200":{"content":{"application/json":{}},"description":"Message updated successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/util.ValidationError"}}},"description":"Bad request"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/util.UnauthorizedError"}}},"description":"Unauthorized"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/util.ServerError"}}},"description":"Internal server error"}},"security":[{"bearerauth":[]}],"summary":"Edit chat message","tags":["Chat"]}},"/v1/spaces/{space_id}/chats/{chat_id}/messages/{message_id}/reactions":{"post":{"description":"Toggles an emoji reaction on a message. If the reaction already exists for the current user, it will be removed; otherwise, it will be added.","operationId":"toggle_chat_reaction","parameters":[{"description":"The version of the API to use","in":"header","name":"Anytype-Version","required":true,"schema":{"default":"2025-11-08","type":"string"}},{"description":"The ID of the space","in":"path","name":"space_id","required":true,"schema":{"type":"string"}},{"description":"The ID of the chat object","in":"path","name":"chat_id","required":true,"schema":{"type":"string"}},{"description":"The ID of the message","in":"path","name":"message_id","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/apimodel.ToggleReactionRequest"}}},"description":"The emoji to toggle","required":true},"responses":{"200":{"content":{"application/json":{}},"description":"Reaction toggled"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/util.ValidationError"}}},"description":"Bad request"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/util.UnauthorizedError"}}},"description":"Unauthorized"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/util.ServerError"}}},"description":"Internal server error"}},"security":[{"bearerauth":[]}],"summary":"Toggle message reaction","tags":["Chat"]}},"/v1/spaces/{space_id}/chats/{chat_id}/reactions/read":{"post":{"description":"Marks unread reactions in the chat as seen. Pass an empty body to mark every unread reaction.","operationId":"read_chat_reactions","parameters":[{"description":"The version of the API to use","in":"header","name":"Anytype-Version","required":true,"schema":{"default":"2025-11-08","type":"string"}},{"description":"The ID of the space","in":"path","name":"space_id","required":true,"schema":{"type":"string"}},{"description":"The ID of the chat object","in":"path","name":"chat_id","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/apimodel.ReadChatReactionsRequest"}}},"description":"Order id of the last read reaction"},"responses":{"200":{"content":{"application/json":{}},"description":"Reactions marked as read"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/util.ValidationError"}}},"description":"Bad request"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/util.UnauthorizedError"}}},"description":"Unauthorized"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/util.ServerError"}}},"description":"Internal server error"}},"security":[{"bearerauth":[]}],"summary":"Read reactions","tags":["Chat"]}},"/v1/spaces/{space_id}/chats/{chat_id}/read_all":{"post":{"description":"Marks every message in the chat as read for the current user.","operationId":"read_all_chat_messages","parameters":[{"description":"The version of the API to use","in":"header","name":"Anytype-Version","required":true,"schema":{"default":"2025-11-08","type":"string"}},{"description":"The ID of the space","in":"path","name":"space_id","required":true,"schema":{"type":"string"}},{"description":"The ID of the chat object","in":"path","name":"chat_id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{}},"description":"Chat marked as read"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/util.UnauthorizedError"}}},"description":"Unauthorized"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/util.ServerError"}}},"description":"Internal server error"}},"security":[{"bearerauth":[]}],"summary":"Mark chat as read","tags":["Chat"]}},"/v1/spaces/{space_id}/files":{"post":{"description":"Uploads a file to the specified space. Accepts multipart/form-data with a file field. The file is processed and stored, then a file object is created. Returns the file object ID along with its name, MIME type and size.","operationId":"upload_file","parameters":[{"description":"The version of the API to use","in":"header","name":"Anytype-Version","required":true,"schema":{"default":"2025-05-20","type":"string"}},{"description":"The ID of the space to upload the file to","in":"path","name":"space_id","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"multipart/form-data":{"schema":{"type":"file"}}},"description":"The file to upload","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/apimodel.FileUploadResponse"}}},"description":"File uploaded successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/util.ValidationError"}}},"description":"Bad request"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/util.UnauthorizedError"}}},"description":"Unauthorized"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/util.ForbiddenError"}}},"description":"Forbidden — read-only space or no permission"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/util.NotFoundError"}}},"description":"Space not found"},"410":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/util.GoneError"}}},"description":"Space was deleted"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/util.ServerError"}}},"description":"Internal server error"}},"security":[{"bearerauth":[]}],"summary":"Upload file","tags":["Files"]}},"/v1/spaces/{space_id}/files/{file_id}":{"delete":{"description":"Removes a file object. By default the file is moved to the bin and can be restored. Pass ` + "`" + `skip_bin=true` + "`" + ` to permanently delete it instead.","operationId":"delete_file","parameters":[{"description":"The version of the API to use","in":"header","name":"Anytype-Version","required":true,"schema":{"default":"2025-05-20","type":"string"}},{"description":"The ID of the space the file belongs to","in":"path","name":"space_id","required":true,"schema":{"type":"string"}},{"description":"The file object ID","in":"path","name":"file_id","required":true,"schema":{"type":"string"}},{"description":"When true, permanently delete instead of moving to bin","in":"query","name":"skip_bin","schema":{"default":false,"type":"boolean"}}],"responses":{"204":{"content":{"application/json":{"schema":{"type":"string"}}},"description":"File deleted"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/util.ValidationError"}}},"description":"Bad request"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/util.UnauthorizedError"}}},"description":"Unauthorized"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/util.ServerError"}}},"description":"Internal server error"}},"security":[{"bearerauth":[]}],"summary":"Delete file","tags":["Files"]},"get":{"description":"Streams the bytes of a file object. The response Content-Type matches the stored media type. For images, pass ` + "`" + `width` + "`" + ` to fetch a pre-rendered variant at that pixel width; SVGs are sanitized inline. ` + "`" + `width` + "`" + ` is ignored for non-image files. The id can be either a file object ID or, for images, a raw file CID.","operationId":"download_file","parameters":[{"description":"The version of the API to use","in":"header","name":"Anytype-Version","required":true,"schema":{"default":"2025-05-20","type":"string"}},{"description":"The ID of the space the file belongs to","in":"path","name":"space_id","required":true,"schema":{"type":"string"}},{"description":"The file object ID (or raw file CID for images)","in":"path","name":"file_id","required":true,"schema":{"type":"string"}},{"description":"Optional pixel width for image variants; ignored on non-images","in":"query","name":"width","schema":{"type":"integer"}}],"responses":{"200":{"content":{"application/json":{"schema":{"type":"file"}},"application/octet-stream":{"schema":{"format":"binary","type":"string"}}},"description":"File contents"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/util.ValidationError"}}},"description":"Invalid query parameters"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/util.UnauthorizedError"}}},"description":"Unauthorized"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/util.NotFoundError"}}},"description":"File not found"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/util.ServerError"}}},"description":"Internal server error"}},"security":[{"bearerauth":[]}],"summary":"Download file","tags":["Files"]}},"/v1/spaces/{space_id}/lists/{list_id}/objects":{"post":{"description":"Adds one or more objects to a specific list (collection only) by submitting a JSON array of object IDs. Upon success, the endpoint returns a confirmation message. This endpoint is vital for building user interfaces that allow drag‑and‑drop or multi‑select additions to collections, enabling users to dynamically manage their collections without needing to modify the underlying object data.","operationId":"add_list_objects","parameters":[{"description":"The version of the API to use","in":"header","name":"Anytype-Version","required":true,"schema":{"default":"2025-11-08","type":"string"}},{"description":"The ID of the space to which the list belongs; must be retrieved from ListSpaces endpoint","in":"path","name":"space_id","required":true,"schema":{"type":"string"}},{"description":"The ID of the list to which objects will be added; must be retrieved from SearchSpace endpoint with types: ['collection', 'set']","in":"path","name":"list_id","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/apimodel.AddObjectsToListRequest"}}},"description":"The list of object IDs to add to the list; must be retrieved from SearchSpace or GlobalSearch endpoints or obtained from response context","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"type":"string"}}},"description":"Objects added successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/util.ValidationError"}}},"description":"Bad request"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/util.UnauthorizedError"}}},"description":"Unauthorized"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/util.NotFoundError"}}},"description":"Not found"},"429":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/util.RateLimitError"}}},"description":"Rate limit exceeded"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/util.ServerError"}}},"description":"Internal server error"}},"security":[{"bearerauth":[]}],"summary":"Add objects to list","tags":["Lists"]}},"/v1/spaces/{space_id}/lists/{list_id}/objects/{object_id}":{"delete":{"description":"Removes a given object from the specified list (collection only) in a space. The endpoint takes the space, list, and object identifiers as path parameters and is subject to rate limiting. It is used for dynamically managing collections without affecting the underlying object data.","operationId":"remove_list_object","parameters":[{"description":"The version of the API to use","in":"header","name":"Anytype-Version","required":true,"schema":{"default":"2025-11-08","type":"string"}},{"description":"The ID of the space to which the list belongs; must be retrieved from ListSpaces endpoint","in":"path","name":"space_id","required":true,"schema":{"type":"string"}},{"description":"The ID of the list from which the object will be removed; must be retrieved from SearchSpace endpoint with types: ['collection', 'set']","in":"path","name":"list_id","required":true,"schema":{"type":"string"}},{"description":"The ID of the object to remove from the list; must be retrieved from SearchSpace or GlobalSearch endpoints or obtained from response context","in":"path","name":"object_id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"type":"string"}}},"description":"Objects removed successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/util.ValidationError"}}},"description":"Bad request"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/util.UnauthorizedError"}}},"description":"Unauthorized"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/util.NotFoundError"}}},"description":"Not found"},"429":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/util.RateLimitError"}}},"description":"Rate limit exceeded"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/util.ServerError"}}},"description":"Internal server error"}},"security":[{"bearerauth":[]}],"summary":"Remove object from list","tags":["Lists"]}},"/v1/spaces/{space_id}/lists/{list_id}/views":{"get":{"description":"Returns a paginated list of views defined for a specific list (query or collection) within a space. Each view includes details such as layout, applied filters, and sorting options, enabling clients to render the list according to user preferences and context. This endpoint is essential for applications that need to display lists in various formats (e.g., grid, table) or with different sorting/filtering criteria.","operationId":"get_list_views","parameters":[{"description":"The version of the API to use","in":"header","name":"Anytype-Version","required":true,"schema":{"default":"2025-11-08","type":"string"}},{"description":"The ID of the space to which the list belongs; must be retrieved from ListSpaces endpoint","in":"path","name":"space_id","required":true,"schema":{"type":"string"}},{"description":"The ID of the list to retrieve views for; must be retrieved from SearchSpace endpoint with types: ['collection', 'set']","in":"path","name":"list_id","required":true,"schema":{"type":"string"}},{"description":"The number of items to skip before starting to collect the result set","in":"query","name":"offset","schema":{"default":0,"type":"integer"}},{"description":"The number of items to return","in":"query","name":"limit","schema":{"type":"integer"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/pagination.PaginatedResponse-apimodel_View"}}},"description":"The list of views associated with the specified list"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/util.UnauthorizedError"}}},"description":"Unauthorized"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/util.NotFoundError"}}},"description":"Not found"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/util.ServerError"}}},"description":"Internal server error"}},"security":[{"bearerauth":[]}],"summary":"Get list views","tags":["Lists"]}},"/v1/spaces/{space_id}/lists/{list_id}/views/{view_id}/objects":{"get":{"description":"Returns a paginated list of objects associated with a specific list (query or collection) within a space. When a view ID is provided, the objects are filtered and sorted according to the view's configuration. If no view ID is specified, all list objects are returned without filtering and sorting. This endpoint helps clients to manage grouped objects (for example, tasks within a list) by returning information for each item of the list.\nSupports dynamic filtering via query parameters (e.g., ?done=false, ?created_date[gte]=2024-01-01, ?tags[in]=urgent,important). For select/tag properties use tag keys, for object properties use object IDs. See FilterCondition enum for available conditions.","operationId":"get_list_objects","parameters":[{"description":"The version of the API to use","in":"header","name":"Anytype-Version","required":true,"schema":{"default":"2025-11-08","type":"string"}},{"description":"The ID of the space to which the list belongs; must be retrieved from ListSpaces endpoint","in":"path","name":"space_id","required":true,"schema":{"type":"string"}},{"description":"The ID of the list to retrieve objects for; must be retrieved from SearchSpace endpoint with types: ['collection', 'set']","in":"path","name":"list_id","required":true,"schema":{"type":"string"}},{"description":"The ID of the view to retrieve objects for; must be retrieved from ListViews endpoint or omitted if you want to get all objects in the list","in":"path","name":"view_id","required":true,"schema":{"type":"string"}},{"description":"The number of items to skip before starting to collect the result set","in":"query","name":"offset","schema":{"default":0,"type":"integer"}},{"description":"The number of items to return","in":"query","name":"limit","schema":{"type":"integer"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/pagination.PaginatedResponse-apimodel_Object"}}},"description":"The list of objects associated with the specified list"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/util.UnauthorizedError"}}},"description":"Unauthorized"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/util.NotFoundError"}}},"description":"Not found"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/util.ServerError"}}},"description":"Internal server error"}},"security":[{"bearerauth":[]}],"summary":"Get objects in list","tags":["Lists"]}},"/v1/spaces/{space_id}/members":{"get":{"description":"Returns a paginated list of members belonging to the specified space. Each member record includes the member's profile ID, name, icon (which may be derived from an emoji or image), network identity, global name, status (e.g. joining, active) and role (e.g. Viewer, Editor, Owner). This endpoint supports collaborative features by allowing clients to show who is in a space and manage access rights.\nSupports dynamic filtering via query parameters (e.g., ?name[ne]=john). See FilterCondition enum for available conditions.","operationId":"list_members","parameters":[{"description":"The version of the API to use","in":"header","name":"Anytype-Version","required":true,"schema":{"default":"2025-11-08","type":"string"}},{"description":"The ID of the space to list members for; must be retrieved from ListSpaces endpoint","in":"path","name":"space_id","required":true,"schema":{"type":"string"}},{"description":"The number of items to skip before starting to collect the result set","in":"query","name":"offset","schema":{"default":0,"type":"integer"}},{"description":"The number of items to return","in":"query","name":"limit","schema":{"default":100,"maximum":1000,"type":"integer"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/pagination.PaginatedResponse-apimodel_Member"}}},"description":"The list of members in the space"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/util.UnauthorizedError"}}},"description":"Unauthorized"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/util.ServerError"}}},"description":"Internal server error"}},"security":[{"bearerauth":[]}],"summary":"List members","tags":["Members"]}},"/v1/spaces/{space_id}/members/{member_id}":{"get":{"description":"Fetches detailed information about a single member within a space. The endpoint returns the member’s identifier, name, icon, identity, global name, status and role. The member_id path parameter can be provided as either the member's ID (starting with ` + "`" + `_participant` + "`" + `) or the member's identity. This is useful for user profile pages, permission management, and displaying member-specific information in collaborative environments.","operationId":"get_member","parameters":[{"description":"The version of the API to use","in":"header","name":"Anytype-Version","required":true,"schema":{"default":"2025-11-08","type":"string"}},{"description":"The ID of the space to get the member from; must be retrieved from ListSpaces endpoint","in":"path","name":"space_id","required":true,"schema":{"type":"string"}},{"description":"Member ID or Identity; must be retrieved from ListMembers endpoint or obtained from response context","in":"path","name":"member_id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/apimodel.MemberResponse"}}},"description":"The member details"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/util.UnauthorizedError"}}},"description":"Unauthorized"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/util.NotFoundError"}}},"description":"Member not found"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/util.ServerError"}}},"description":"Internal server error"}},"security":[{"bearerauth":[]}],"summary":"Get member","tags":["Members"]}},"/v1/spaces/{space_id}/objects":{"get":{"description":"Retrieves a paginated list of objects in the given space. The endpoint takes query parameters for pagination (offset and limit) and returns detailed data about each object including its ID, name, icon, type information, a snippet of the content (if applicable), layout, space ID, blocks and details. It is intended for building views where users can see all objects in a space at a glance.\nSupports dynamic filtering via query parameters (e.g., ?type=page, ?done=false, ?created_date[gte]=2024-01-01, ?tags[in]=urgent,important). For type filtering use the type's API key (e.g., ?type=note, ?type=page). For select/multi_select properties you can use either tag keys or tag IDs, for object properties use object IDs. See FilterCondition enum for available conditions.","operationId":"list_objects","parameters":[{"description":"The version of the API to use","in":"header","name":"Anytype-Version","required":true,"schema":{"default":"2025-11-08","type":"string"}},{"description":"The ID of the space in which to list objects; must be retrieved from ListSpaces endpoint","in":"path","name":"space_id","required":true,"schema":{"type":"string"}},{"description":"The number of items to skip before starting to collect the result set","in":"query","name":"offset","schema":{"default":0,"type":"integer"}},{"description":"The number of items to return","in":"query","name":"limit","schema":{"default":100,"maximum":1000,"type":"integer"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/pagination.PaginatedResponse-apimodel_Object"}}},"description":"The list of objects in the specified space"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/util.UnauthorizedError"}}},"description":"Unauthorized"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/util.ServerError"}}},"description":"Internal server error"}},"security":[{"bearerauth":[]}],"summary":"List objects","tags":["Objects"]},"post":{"description":"Creates a new object in the specified space using a JSON payload. The creation process is subject to rate limiting. The payload must include key details such as the object name, icon, description, body content (which may support Markdown), source URL (required for bookmark objects), template identifier, and the type_key (which is the non-unique identifier of the type of object to create). Post-creation, additional operations (like setting featured properties or fetching bookmark metadata) may occur. The endpoint then returns the full object data, ready for further interactions.","operationId":"create_object","parameters":[{"description":"The version of the API to use","in":"header","name":"Anytype-Version","required":true,"schema":{"default":"2025-11-08","type":"string"}},{"description":"The ID of the space in which to create the object; must be retrieved from ListSpaces endpoint","in":"path","name":"space_id","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/apimodel.CreateObjectRequest"}}},"description":"The object to create","required":true},"responses":{"201":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/apimodel.ObjectResponse"}}},"description":"The created object"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/util.ValidationError"}}},"description":"Bad request"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/util.UnauthorizedError"}}},"description":"Unauthorized"},"429":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/util.RateLimitError"}}},"description":"Rate limit exceeded"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/util.ServerError"}}},"description":"Internal server error"}},"security":[{"bearerauth":[]}],"summary":"Create object","tags":["Objects"]}},"/v1/spaces/{space_id}/objects/{object_id}":{"delete":{"description":"This endpoint “deletes” an object by marking it as archived. The deletion process is performed safely and is subject to rate limiting. It returns the object’s details after it has been archived. Proper error handling is in place for situations such as when the object isn’t found or the deletion cannot be performed because of permission issues.","operationId":"delete_object","parameters":[{"description":"The version of the API to use","in":"header","name":"Anytype-Version","required":true,"schema":{"default":"2025-11-08","type":"string"}},{"description":"The ID of the space in which the object exists; must be retrieved from ListSpaces endpoint","in":"path","name":"space_id","required":true,"schema":{"type":"string"}},{"description":"The ID of the object to delete; must be retrieved from ListObjects, SearchSpace or GlobalSearch endpoints or obtained from response context","in":"path","name":"object_id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/apimodel.ObjectResponse"}}},"description":"The deleted object"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/util.UnauthorizedError"}}},"description":"Unauthorized"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/util.ForbiddenError"}}},"description":"Forbidden"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/util.NotFoundError"}}},"description":"Resource not found"},"410":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/util.GoneError"}}},"description":"Resource deleted"},"429":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/util.RateLimitError"}}},"description":"Rate limit exceeded"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/util.ServerError"}}},"description":"Internal server error"}},"security":[{"bearerauth":[]}],"summary":"Delete object","tags":["Objects"]},"get":{"description":"Fetches the full details of a single object identified by the object ID within the specified space. The response includes not only basic metadata (ID, name, icon, type) but also the complete set of blocks (which may include text, files, properties and dataviews) and extra details (such as timestamps and linked member information). This endpoint is essential when a client needs to render or edit the full object view.","operationId":"get_object","parameters":[{"description":"The version of the API to use","in":"header","name":"Anytype-Version","required":true,"schema":{"default":"2025-11-08","type":"string"}},{"description":"The ID of the space in which the object exists; must be retrieved from ListSpaces endpoint","in":"path","name":"space_id","required":true,"schema":{"type":"string"}},{"description":"The ID of the object to retrieve; must be retrieved from ListObjects, SearchSpace or GlobalSearch endpoints or obtained from response context","in":"path","name":"object_id","required":true,"schema":{"type":"string"}},{"description":"The format to return the object body in","in":"query","name":"format","schema":{"default":"md","enum":["md"],"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/apimodel.ObjectResponse"}}},"description":"The retrieved object"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/util.UnauthorizedError"}}},"description":"Unauthorized"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/util.NotFoundError"}}},"description":"Resource not found"},"410":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/util.GoneError"}}},"description":"Resource deleted"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/util.ServerError"}}},"description":"Internal server error"}},"security":[{"bearerauth":[]}],"summary":"Get object","tags":["Objects"]},"patch":{"description":"This endpoint updates an existing object in the specified space using a JSON payload. The update process is subject to rate limiting. The payload must include the details to be updated. The endpoint then returns the full object data, ready for further interactions.","operationId":"update_object","parameters":[{"description":"The version of the API to use","in":"header","name":"Anytype-Version","required":true,"schema":{"default":"2025-11-08","type":"string"}},{"description":"The ID of the space in which the object exists; must be retrieved from ListSpaces endpoint","in":"path","name":"space_id","required":true,"schema":{"type":"string"}},{"description":"The ID of the object to update; must be retrieved from ListObjects, SearchSpace or GlobalSearch endpoints or obtained from response context","in":"path","name":"object_id","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/apimodel.UpdateObjectRequest"}}},"description":"The details of the object to update","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/apimodel.ObjectResponse"}}},"description":"The updated object"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/util.ValidationError"}}},"description":"Bad request"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/util.UnauthorizedError"}}},"description":"Unauthorized"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/util.NotFoundError"}}},"description":"Resource not found"},"410":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/util.GoneError"}}},"description":"Resource deleted"},"429":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/util.RateLimitError"}}},"description":"Rate limit exceeded"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/util.ServerError"}}},"description":"Internal server error"}},"security":[{"bearerauth":[]}],"summary":"Update object","tags":["Objects"]}},"/v1/spaces/{space_id}/properties":{"get":{"description":"Retrieves a paginated list of properties available within a specific space. Each property record includes its unique identifier, name and format. This information is essential for clients to understand the available properties for filtering or creating objects.\nSupports dynamic filtering via query parameters (e.g., ?name[contains]=date). See FilterCondition enum for available conditions.","operationId":"list_properties","parameters":[{"description":"The version of the API to use","in":"header","name":"Anytype-Version","required":true,"schema":{"default":"2025-11-08","type":"string"}},{"description":"The ID of the space to list properties for; must be retrieved from ListSpaces endpoint","in":"path","name":"space_id","required":true,"schema":{"type":"string"}},{"description":"The number of items to skip before starting to collect the result set","in":"query","name":"offset","schema":{"default":0,"type":"integer"}},{"description":"The number of items to return","in":"query","name":"limit","schema":{"default":100,"maximum":1000,"type":"integer"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/pagination.PaginatedResponse-apimodel_Property"}}},"description":"The list of properties in the specified space"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/util.UnauthorizedError"}}},"description":"Unauthorized"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/util.ServerError"}}},"description":"Internal server error"}},"security":[{"bearerauth":[]}],"summary":"List properties","tags":["Properties"]},"post":{"description":"Creates a new property in the specified space using a JSON payload. The creation process is subject to rate limiting. The payload must include property details such as the name and format. The endpoint then returns the full property data, ready for further interactions.","operationId":"create_property","parameters":[{"description":"The version of the API to use","in":"header","name":"Anytype-Version","required":true,"schema":{"default":"2025-11-08","type":"string"}},{"description":"The ID of the space to create the property in; must be retrieved from ListSpaces endpoint","in":"path","name":"space_id","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/apimodel.CreatePropertyRequest"}}},"description":"The property to create","required":true},"responses":{"201":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/apimodel.PropertyResponse"}}},"description":"The created property"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/util.ValidationError"}}},"description":"Bad request"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/util.UnauthorizedError"}}},"description":"Unauthorized"},"429":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/util.RateLimitError"}}},"description":"Rate limit exceeded"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/util.ServerError"}}},"description":"Internal server error"}},"security":[{"bearerauth":[]}],"summary":"Create property","tags":["Properties"]}},"/v1/spaces/{space_id}/properties/{property_id}":{"delete":{"description":"This endpoint \"deletes\" a property by marking it as archived. The deletion process is performed safely and is subject to rate limiting. It returns the property’s details after it has been archived. Proper error handling is in place for situations such as when the property isn’t found or the deletion cannot be performed because of permission issues.","operationId":"delete_property","parameters":[{"description":"The version of the API to use","in":"header","name":"Anytype-Version","required":true,"schema":{"default":"2025-11-08","type":"string"}},{"description":"The ID of the space to which the property belongs; must be retrieved from ListSpaces endpoint","in":"path","name":"space_id","required":true,"schema":{"type":"string"}},{"description":"The ID of the property to delete; must be retrieved from ListProperties endpoint or obtained from response context","in":"path","name":"property_id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/apimodel.PropertyResponse"}}},"description":"The deleted property"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/util.UnauthorizedError"}}},"description":"Unauthorized"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/util.ForbiddenError"}}},"description":"Forbidden"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/util.NotFoundError"}}},"description":"Resource not found"},"410":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/util.GoneError"}}},"description":"Resource deleted"},"429":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/util.RateLimitError"}}},"description":"Rate limit exceeded"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/util.ServerError"}}},"description":"Internal server error"}},"security":[{"bearerauth":[]}],"summary":"Delete property","tags":["Properties"]},"get":{"description":"Fetches detailed information about one specific property by its ID. This includes the property’s unique identifier, name and format. This detailed view assists clients in showing property options to users and in guiding the user interface (such as displaying appropriate input fields or selection options).","operationId":"get_property","parameters":[{"description":"The version of the API to use","in":"header","name":"Anytype-Version","required":true,"schema":{"default":"2025-11-08","type":"string"}},{"description":"The ID of the space to which the property belongs; must be retrieved from ListSpaces endpoint","in":"path","name":"space_id","required":true,"schema":{"type":"string"}},{"description":"The ID of the property to retrieve; must be retrieved from ListProperties endpoint or obtained from response context","in":"path","name":"property_id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/apimodel.PropertyResponse"}}},"description":"The requested property"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/util.UnauthorizedError"}}},"description":"Unauthorized"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/util.NotFoundError"}}},"description":"Resource not found"},"410":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/util.GoneError"}}},"description":"Resource deleted"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/util.ServerError"}}},"description":"Internal server error"}},"security":[{"bearerauth":[]}],"summary":"Get property","tags":["Properties"]},"patch":{"description":"This endpoint updates an existing property in the specified space using a JSON payload. The update process is subject to rate limiting. The payload must include the name to be updated. The endpoint then returns the full property data, ready for further interactions.","operationId":"update_property","parameters":[{"description":"The version of the API to use","in":"header","name":"Anytype-Version","required":true,"schema":{"default":"2025-11-08","type":"string"}},{"description":"The ID of the space to which the property belongs; must be retrieved from ListSpaces endpoint","in":"path","name":"space_id","required":true,"schema":{"type":"string"}},{"description":"The ID of the property to update; must be retrieved from ListProperties endpoint or obtained from response context","in":"path","name":"property_id","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/apimodel.UpdatePropertyRequest"}}},"description":"The property to update","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/apimodel.PropertyResponse"}}},"description":"The updated property"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/util.ValidationError"}}},"description":"Bad request"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/util.UnauthorizedError"}}},"description":"Unauthorized"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/util.ForbiddenError"}}},"description":"Forbidden"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/util.NotFoundError"}}},"description":"Resource not found"},"410":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/util.GoneError"}}},"description":"Resource deleted"},"429":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/util.RateLimitError"}}},"description":"Rate limit exceeded"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/util.ServerError"}}},"description":"Internal server error"}},"security":[{"bearerauth":[]}],"summary":"Update property","tags":["Properties"]}},"/v1/spaces/{space_id}/properties/{property_id}/tags":{"get":{"description":"This endpoint retrieves a paginated list of tags available for a specific property within a space. Each tag record includes its unique identifier, name, and color. This information is essential for clients to display select or multi-select options to users when they are creating or editing objects. The endpoint also supports pagination through offset and limit parameters.\nSupports dynamic filtering via query parameters (e.g., ?name[contains]=urgent). See FilterCondition enum for available conditions.","operationId":"list_tags","parameters":[{"description":"The version of the API to use","in":"header","name":"Anytype-Version","required":true,"schema":{"default":"2025-11-08","type":"string"}},{"description":"The ID of the space to list tags for; must be retrieved from ListSpaces endpoint","in":"path","name":"space_id","required":true,"schema":{"type":"string"}},{"description":"The ID of the property to list tags for; must be retrieved from ListProperties endpoint or obtained from response context","in":"path","name":"property_id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/pagination.PaginatedResponse-apimodel_Tag"}}},"description":"The list of tags"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/util.UnauthorizedError"}}},"description":"Unauthorized"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/util.NotFoundError"}}},"description":"Property not found"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/util.ServerError"}}},"description":"Internal server error"}},"security":[{"bearerauth":[]}],"summary":"List tags","tags":["Tags"]},"post":{"description":"This endpoint creates a new tag for a given property id in a space. The creation process is subject to rate limiting. The tag is identified by its unique identifier within the specified space. The request must include the tag's name and color. The response includes the tag's details such as its ID, name, and color. This is useful for clients when users want to add new tag options to a property.","operationId":"create_tag","parameters":[{"description":"The version of the API to use","in":"header","name":"Anytype-Version","required":true,"schema":{"default":"2025-11-08","type":"string"}},{"description":"The ID of the space to create the tag in; must be retrieved from ListSpaces endpoint","in":"path","name":"space_id","required":true,"schema":{"type":"string"}},{"description":"The ID of the property to create the tag for; must be retrieved from ListProperties endpoint or obtained from response context","in":"path","name":"property_id","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/apimodel.CreateTagRequest"}}},"description":"The tag to create","required":true},"responses":{"201":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/apimodel.TagResponse"}}},"description":"The created tag"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/util.ValidationError"}}},"description":"Bad request"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/util.UnauthorizedError"}}},"description":"Unauthorized"},"429":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/util.RateLimitError"}}},"description":"Rate limit exceeded"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/util.ServerError"}}},"description":"Internal server error"}},"security":[{"bearerauth":[]}],"summary":"Create tag","tags":["Tags"]}},"/v1/spaces/{space_id}/properties/{property_id}/tags/{tag_id}":{"delete":{"description":"This endpoint “deletes” a tag by marking it as archived. The deletion process is performed safely and is subject to rate limiting. It returns the tag’s details after it has been archived. Proper error handling is in place for situations such as when the tag isn’t found or the deletion cannot be performed because of permission issues.","operationId":"delete_tag","parameters":[{"description":"The version of the API to use","in":"header","name":"Anytype-Version","required":true,"schema":{"default":"2025-11-08","type":"string"}},{"description":"The ID of the space to delete the tag from; must be retrieved from ListSpaces endpoint","in":"path","name":"space_id","required":true,"schema":{"type":"string"}},{"description":"The ID of the property to delete the tag for; must be retrieved from ListProperties endpoint or obtained from response context","in":"path","name":"property_id","required":true,"schema":{"type":"string"}},{"description":"The ID of the tag to delete; must be retrieved from ListTags endpoint or obtained from response context","in":"path","name":"tag_id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/apimodel.TagResponse"}}},"description":"The deleted tag"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/util.UnauthorizedError"}}},"description":"Unauthorized"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/util.ForbiddenError"}}},"description":"Forbidden"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/util.NotFoundError"}}},"description":"Resource not found"},"410":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/util.GoneError"}}},"description":"Resource deleted"},"429":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/util.RateLimitError"}}},"description":"Rate limit exceeded"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/util.ServerError"}}},"description":"Internal server error"}},"security":[{"bearerauth":[]}],"summary":"Delete tag","tags":["Tags"]},"get":{"description":"This endpoint retrieves a tag for a given property id. The tag is identified by its unique identifier within the specified space. The response includes the tag's details such as its ID, name, and color. This is useful for clients to display or when editing a specific tag option.","operationId":"get_tag","parameters":[{"description":"The version of the API to use","in":"header","name":"Anytype-Version","required":true,"schema":{"default":"2025-11-08","type":"string"}},{"description":"The ID of the space to retrieve the tag from; must be retrieved from ListSpaces endpoint","in":"path","name":"space_id","required":true,"schema":{"type":"string"}},{"description":"The ID of the property to retrieve the tag for; must be retrieved from ListProperties endpoint or obtained from response context","in":"path","name":"property_id","required":true,"schema":{"type":"string"}},{"description":"The ID of the tag to retrieve; must be retrieved from ListTags endpoint or obtained from response context","in":"path","name":"tag_id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/apimodel.TagResponse"}}},"description":"The retrieved tag"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/util.UnauthorizedError"}}},"description":"Unauthorized"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/util.NotFoundError"}}},"description":"Resource not found"},"410":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/util.GoneError"}}},"description":"Resource deleted"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/util.ServerError"}}},"description":"Internal server error"}},"security":[{"bearerauth":[]}],"summary":"Get tag","tags":["Tags"]},"patch":{"description":"This endpoint updates a tag for a given property id in a space. The update process is subject to rate limiting. The tag is identified by its unique identifier within the specified space. The request must include the tag's name and color. The response includes the tag's details such as its ID, name, and color. This is useful for clients when users want to edit existing tags for a property.","operationId":"update_tag","parameters":[{"description":"The version of the API to use","in":"header","name":"Anytype-Version","required":true,"schema":{"default":"2025-11-08","type":"string"}},{"description":"The ID of the space to update the tag in; must be retrieved from ListSpaces endpoint","in":"path","name":"space_id","required":true,"schema":{"type":"string"}},{"description":"The ID of the property to update the tag for; must be retrieved from ListProperties endpoint or obtained from response context","in":"path","name":"property_id","required":true,"schema":{"type":"string"}},{"description":"The ID of the tag to update; must be retrieved from ListTags endpoint or obtained from response context","in":"path","name":"tag_id","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/apimodel.UpdateTagRequest"}}},"description":"The tag to update","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/apimodel.TagResponse"}}},"description":"The updated tag"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/util.ValidationError"}}},"description":"Bad request"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/util.UnauthorizedError"}}},"description":"Unauthorized"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/util.ForbiddenError"}}},"description":"Forbidden"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/util.NotFoundError"}}},"description":"Resource not found"},"410":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/util.GoneError"}}},"description":"Resource deleted"},"429":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/util.RateLimitError"}}},"description":"Rate limit exceeded"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/util.ServerError"}}},"description":"Internal server error"}},"security":[{"bearerauth":[]}],"summary":"Update tag","tags":["Tags"]}},"/v1/spaces/{space_id}/search":{"post":{"description":"Performs a search within a single space (specified by the ` + "`" + `space_id` + "`" + ` path parameter). Like the global search, it accepts pagination parameters and a JSON payload containing the search ` + "`" + `query` + "`" + `, ` + "`" + `types` + "`" + `, and sorting preferences. File-layout objects (file, image, video, audio, pdf) are excluded from results by default; to include them, list one of the file type keys (\"file\", \"image\", \"video\", \"audio\") in the ` + "`" + `types` + "`" + ` field. The search is limited to the provided space and returns a list of objects that match the query. This allows clients to implement space‑specific filtering without having to process extraneous results.","operationId":"search_space","parameters":[{"description":"The version of the API to use","in":"header","name":"Anytype-Version","required":true,"schema":{"default":"2025-11-08","type":"string"}},{"description":"The ID of the space to search in; must be retrieved from ListSpaces endpoint","in":"path","name":"space_id","required":true,"schema":{"type":"string"}},{"description":"The number of items to skip before starting to collect the result set","in":"query","name":"offset","schema":{"default":0,"type":"integer"}},{"description":"The number of items to return","in":"query","name":"limit","schema":{"default":100,"maximum":1000,"type":"integer"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/apimodel.SearchRequest"}}},"description":"The search parameters used to filter and sort the results","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/pagination.PaginatedResponse-apimodel_Object"}}},"description":"The list of objects matching the search criteria"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/util.UnauthorizedError"}}},"description":"Unauthorized"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/util.ServerError"}}},"description":"Internal server error"}},"security":[{"bearerauth":[]}],"summary":"Search objects within a space","tags":["Search"]}},"/v1/spaces/{space_id}/types":{"get":{"description":"This endpoint retrieves a paginated list of types (e.g. 'Page', 'Note', 'Task') available within the specified space. Each type's record includes its unique identifier, type key, display name, icon, and layout. While a type's id is truly unique, a type's key can be the same across spaces for known types, e.g. 'page' for 'Page'. Clients use this information when offering choices for object creation or for filtering objects by type through search.\nSupports dynamic filtering via query parameters (e.g. ?name[contains]=task). See FilterCondition enum for available conditions.","operationId":"list_types","parameters":[{"description":"The version of the API to use","in":"header","name":"Anytype-Version","required":true,"schema":{"default":"2025-11-08","type":"string"}},{"description":"The ID of the space to retrieve types from; must be retrieved from ListSpaces endpoint","in":"path","name":"space_id","required":true,"schema":{"type":"string"}},{"description":"The number of items to skip before starting to collect the result set","in":"query","name":"offset","schema":{"default":0,"type":"integer"}},{"description":"The number of items to return","in":"query","name":"limit","schema":{"default":100,"maximum":1000,"type":"integer"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/pagination.PaginatedResponse-apimodel_Type"}}},"description":"The list of types"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/util.UnauthorizedError"}}},"description":"Unauthorized"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/util.ServerError"}}},"description":"Internal server error"}},"security":[{"bearerauth":[]}],"summary":"List types","tags":["Types"]},"post":{"description":"Creates a new type in the specified space using a JSON payload. The creation process is subject to rate limiting. The payload must include type details such as the name, icon, and layout. The endpoint then returns the full type data, ready to be used for creating objects.","operationId":"create_type","parameters":[{"description":"The version of the API to use","in":"header","name":"Anytype-Version","required":true,"schema":{"default":"2025-11-08","type":"string"}},{"description":"The ID of the space in which to create the type; must be retrieved from ListSpaces endpoint","in":"path","name":"space_id","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/apimodel.CreateTypeRequest"}}},"description":"The type to create","required":true},"responses":{"201":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/apimodel.TypeResponse"}}},"description":"The created type"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/util.ValidationError"}}},"description":"Bad request"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/util.UnauthorizedError"}}},"description":"Unauthorized"},"429":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/util.RateLimitError"}}},"description":"Rate limit exceeded"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/util.ServerError"}}},"description":"Internal server error"}},"security":[{"bearerauth":[]}],"summary":"Create type","tags":["Types"]}},"/v1/spaces/{space_id}/types/{type_id}":{"delete":{"description":"This endpoint “deletes” an type by marking it as archived. The deletion process is performed safely and is subject to rate limiting. It returns the type’s details after it has been archived. Proper error handling is in place for situations such as when the type isn’t found or the deletion cannot be performed because of permission issues.","operationId":"delete_type","parameters":[{"description":"The version of the API to use","in":"header","name":"Anytype-Version","required":true,"schema":{"default":"2025-11-08","type":"string"}},{"description":"The ID of the space from which to delete the type; must be retrieved from ListSpaces endpoint","in":"path","name":"space_id","required":true,"schema":{"type":"string"}},{"description":"The ID of the type to delete; must be retrieved from ListTypes endpoint or obtained from response context","in":"path","name":"type_id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/apimodel.TypeResponse"}}},"description":"The deleted type"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/util.UnauthorizedError"}}},"description":"Unauthorized"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/util.ForbiddenError"}}},"description":"Forbidden"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/util.NotFoundError"}}},"description":"Resource not found"},"410":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/util.GoneError"}}},"description":"Resource deleted"},"429":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/util.RateLimitError"}}},"description":"Rate limit exceeded"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/util.ServerError"}}},"description":"Internal server error"}},"security":[{"bearerauth":[]}],"summary":"Delete type","tags":["Types"]},"get":{"description":"Fetches detailed information about one specific type by its ID. This includes the type’s unique key, name, icon, and layout. This detailed view assists clients in understanding the expected structure and style for objects of that type and in guiding the user interface (such as displaying appropriate icons or layout hints).","operationId":"get_type","parameters":[{"description":"The version of the API to use","in":"header","name":"Anytype-Version","required":true,"schema":{"default":"2025-11-08","type":"string"}},{"description":"The ID of the space from which to retrieve the type; must be retrieved from ListSpaces endpoint","in":"path","name":"space_id","required":true,"schema":{"type":"string"}},{"description":"The ID of the type to retrieve; must be retrieved from ListTypes endpoint or obtained from response context","in":"path","name":"type_id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/apimodel.TypeResponse"}}},"description":"The requested type"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/util.UnauthorizedError"}}},"description":"Unauthorized"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/util.NotFoundError"}}},"description":"Resource not found"},"410":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/util.GoneError"}}},"description":"Resource deleted"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/util.ServerError"}}},"description":"Internal server error"}},"security":[{"bearerauth":[]}],"summary":"Get type","tags":["Types"]},"patch":{"description":"This endpoint updates an existing type in the specified space using a JSON payload. The update process is subject to rate limiting. The payload must include the name and properties to be updated. The endpoint then returns the full type data, ready for further interactions.","operationId":"update_type","parameters":[{"description":"The version of the API to use","in":"header","name":"Anytype-Version","required":true,"schema":{"default":"2025-11-08","type":"string"}},{"description":"The ID of the space in which the type exists; must be retrieved from ListSpaces endpoint","in":"path","name":"space_id","required":true,"schema":{"type":"string"}},{"description":"The ID of the type to update; must be retrieved from ListTypes endpoint or obtained from response context","in":"path","name":"type_id","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/apimodel.UpdateTypeRequest"}}},"description":"The type details to update","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/apimodel.TypeResponse"}}},"description":"The updated type"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/util.ValidationError"}}},"description":"Bad request"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/util.UnauthorizedError"}}},"description":"Unauthorized"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/util.NotFoundError"}}},"description":"Resource not found"},"410":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/util.GoneError"}}},"description":"Resource deleted"},"429":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/util.RateLimitError"}}},"description":"Rate limit exceeded"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/util.ServerError"}}},"description":"Internal server error"}},"security":[{"bearerauth":[]}],"summary":"Update type","tags":["Types"]}},"/v1/spaces/{space_id}/types/{type_id}/templates":{"get":{"description":"This endpoint returns a paginated list of templates that are associated with a specific type within a space. Templates provide pre‑configured structures for creating new objects. Each template record contains its identifier, name, and icon, so that clients can offer users a selection of templates when creating objects.\nSupports dynamic filtering via query parameters (e.g., ?name[contains]=invoice). See FilterCondition enum for available conditions.","operationId":"list_templates","parameters":[{"description":"The version of the API to use","in":"header","name":"Anytype-Version","required":true,"schema":{"default":"2025-11-08","type":"string"}},{"description":"The ID of the space to which the type belongs; must be retrieved from ListSpaces endpoint","in":"path","name":"space_id","required":true,"schema":{"type":"string"}},{"description":"The ID of the type to retrieve templates for; must be retrieved from ListTypes endpoint or obtained from response context","in":"path","name":"type_id","required":true,"schema":{"type":"string"}},{"description":"The number of items to skip before starting to collect the result set","in":"query","name":"offset","schema":{"default":0,"type":"integer"}},{"description":"The number of items to return","in":"query","name":"limit","schema":{"default":100,"maximum":1000,"type":"integer"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/pagination.PaginatedResponse-apimodel_Object"}}},"description":"List of templates"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/util.UnauthorizedError"}}},"description":"Unauthorized"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/util.ServerError"}}},"description":"Internal server error"}},"security":[{"bearerauth":[]}],"summary":"List templates","tags":["Templates"]}},"/v1/spaces/{space_id}/types/{type_id}/templates/{template_id}":{"get":{"description":"Fetches full details for one template associated with a particular type in a space. The response provides the template’s identifier, name, icon, and any other relevant metadata. This endpoint is useful when a client needs to preview or apply a template to prefill object creation fields.","operationId":"get_template","parameters":[{"description":"The version of the API to use","in":"header","name":"Anytype-Version","required":true,"schema":{"default":"2025-11-08","type":"string"}},{"description":"The ID of the space to which the template belongs; must be retrieved from ListSpaces endpoint","in":"path","name":"space_id","required":true,"schema":{"type":"string"}},{"description":"The ID of the type to which the template belongs; must be retrieved from ListTypes endpoint or obtained from response context","in":"path","name":"type_id","required":true,"schema":{"type":"string"}},{"description":"The ID of the template to retrieve; must be retrieved from ListTemplates endpoint or obtained from response context","in":"path","name":"template_id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/apimodel.TemplateResponse"}}},"description":"The requested template"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/util.UnauthorizedError"}}},"description":"Unauthorized"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/util.NotFoundError"}}},"description":"Resource not found"},"410":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/util.GoneError"}}},"description":"Resource deleted"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/util.ServerError"}}},"description":"Internal server error"}},"security":[{"bearerauth":[]}],"summary":"Get template","tags":["Templates"]}}}, - "openapi": "3.1.0", - "servers": [ - {"url":"http://127.0.0.1:31009"} - ] -}` - -// SwaggerInfo holds exported Swagger Info so clients can modify it -var SwaggerInfo = &swag.Spec{ - Version: "2025-11-08", - Title: "Anytype API", - Description: "This API enables seamless interaction with Anytype's resources - spaces, objects, properties, types, templates, and beyond.", - InfoInstanceName: "swagger", - SwaggerTemplate: docTemplate, - LeftDelim: "{{", - RightDelim: "}}", -} - -func init() { - swag.Register(SwaggerInfo.InstanceName(), SwaggerInfo) -} diff --git a/core/api/docs/openapi.json b/core/api/docs/v1/openapi.json similarity index 99% rename from core/api/docs/openapi.json rename to core/api/docs/v1/openapi.json index ba486d0425..8135bc6780 100644 --- a/core/api/docs/openapi.json +++ b/core/api/docs/v1/openapi.json @@ -303,8 +303,8 @@ "CreateApiKeyResponse": { "properties": { "api_key": { - "description": "The api key used to authenticate requests", - "example": "zhSG/zQRmgADyilWPtgdnfo1qD60oK02/SVgi1GaFt6=", + "description": "ApiKey is minted in the prefixed+checksummed format\n`anytype__`; match it with the published pattern\n`\\banytype_[0-9A-Za-z]{40,60}_[0-9a-f]{8}\\b` (a length RANGE — never\nassume a fixed length). Keys issued before the format flip are plain\nbase64 and keep authenticating unchanged.", + "example": "anytype_amfbcga7eywtio2cjfifoxtfnrzxvamir6lj3jflwk44br6o2xoa_3fe1d4b7", "type": "string" } }, diff --git a/core/api/docs/openapi.yaml b/core/api/docs/v1/openapi.yaml similarity index 99% rename from core/api/docs/openapi.yaml rename to core/api/docs/v1/openapi.yaml index 4b37091a0a..0bd2042a42 100644 --- a/core/api/docs/openapi.yaml +++ b/core/api/docs/v1/openapi.yaml @@ -220,8 +220,13 @@ components: CreateApiKeyResponse: properties: api_key: - description: The api key used to authenticate requests - example: zhSG/zQRmgADyilWPtgdnfo1qD60oK02/SVgi1GaFt6= + description: |- + ApiKey is minted in the prefixed+checksummed format + `anytype__`; match it with the published pattern + `\banytype_[0-9A-Za-z]{40,60}_[0-9a-f]{8}\b` (a length RANGE — never + assume a fixed length). Keys issued before the format flip are plain + base64 and keep authenticating unchanged. + example: anytype_amfbcga7eywtio2cjfifoxtfnrzxvamir6lj3jflwk44br6o2xoa_3fe1d4b7 type: string type: object CreateChallengeRequest: diff --git a/core/api/docs/v2/openapi.json b/core/api/docs/v2/openapi.json new file mode 100644 index 0000000000..e9f5f44ce7 --- /dev/null +++ b/core/api/docs/v2/openapi.json @@ -0,0 +1,4334 @@ +{ + "components": { + "schemas": { + "ForbiddenError": { + "properties": { + "code": { + "example": "forbidden", + "type": "string" + }, + "message": { + "example": "Forbidden", + "type": "string" + }, + "object": { + "example": "error", + "type": "string" + }, + "status": { + "example": 403, + "type": "integer" + } + }, + "type": "object" + }, + "UnauthorizedError": { + "properties": { + "code": { + "example": "unauthorized", + "type": "string" + }, + "message": { + "example": "Unauthorized", + "type": "string" + }, + "object": { + "example": "error", + "type": "string" + }, + "status": { + "example": 401, + "type": "integer" + } + }, + "type": "object" + }, + "AddChatMessageRequest": { + "properties": { + "attachments": { + "items": { + "type": "string" + }, + "type": "array", + "uniqueItems": false + }, + "reply_to": { + "type": "string" + }, + "text": { + "type": "string" + } + }, + "type": "object" + }, + "ChatAttachment": { + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string" + } + }, + "type": "object" + }, + "ChatMessage": { + "properties": { + "at": { + "type": "string" + }, + "attachments": { + "items": { + "$ref": "#/components/schemas/ChatAttachment" + }, + "type": "array", + "uniqueItems": false + }, + "author": { + "type": "string" + }, + "author_id": { + "type": "string" + }, + "blocks_text": { + "type": "string" + }, + "edited_at": { + "type": "string" + }, + "id": { + "type": "string" + }, + "order": { + "type": "string" + }, + "pinned": { + "type": "boolean" + }, + "reacted_by": { + "additionalProperties": { + "items": { + "type": "string" + }, + "type": "array" + }, + "type": "object" + }, + "reactions": { + "additionalProperties": { + "type": "integer" + }, + "type": "object" + }, + "reply_to": { + "type": "string" + }, + "text": { + "type": "string" + } + }, + "type": "object" + }, + "ChatMessageResult": { + "properties": { + "dry_run": { + "type": "boolean" + }, + "id": { + "type": "string" + }, + "warnings": { + "items": { + "$ref": "#/components/schemas/Issue" + }, + "type": "array", + "uniqueItems": false + } + }, + "type": "object" + }, + "ChatMessagesResponse": { + "properties": { + "has_more": { + "description": "more messages inside the requested bounds, not in the chat as a whole", + "type": "boolean" + }, + "message_count": { + "type": "integer" + }, + "messages": { + "items": { + "$ref": "#/components/schemas/ChatMessage" + }, + "type": "array", + "uniqueItems": false + }, + "next_after": { + "type": "string" + }, + "next_before": { + "type": "string" + }, + "state": { + "$ref": "#/components/schemas/ChatState" + } + }, + "type": "object" + }, + "ChatReactionRequest": { + "properties": { + "emoji": { + "type": "string" + } + }, + "type": "object" + }, + "ChatReactionResult": { + "properties": { + "added": { + "type": "boolean" + }, + "dry_run": { + "type": "boolean" + }, + "warnings": { + "items": { + "$ref": "#/components/schemas/Issue" + }, + "type": "array", + "uniqueItems": false + } + }, + "type": "object" + }, + "ChatReadRequest": { + "properties": { + "last_state_id": { + "type": "string" + }, + "scope": { + "type": "string" + }, + "up_to": { + "type": "string" + } + }, + "type": "object" + }, + "ChatReadResult": { + "properties": { + "dry_run": { + "type": "boolean" + } + }, + "type": "object" + }, + "ChatResult": { + "properties": { + "dry_run": { + "type": "boolean" + }, + "id": { + "type": "string" + }, + "name": { + "type": "string" + } + }, + "type": "object" + }, + "ChatRow": { + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + } + }, + "type": "object" + }, + "ChatState": { + "properties": { + "last_state_id": { + "type": "string" + }, + "oldest_unread_mention_order": { + "type": "string" + }, + "oldest_unread_order": { + "type": "string" + }, + "unread_mentions": { + "type": "integer" + }, + "unread_messages": { + "type": "integer" + }, + "unread_reaction_order": { + "type": "string" + } + }, + "type": "object" + }, + "CreateChatRequest": { + "properties": { + "name": { + "type": "string" + } + }, + "type": "object" + }, + "CreateResult": { + "properties": { + "created": { + "$ref": "#/components/schemas/SideEffects" + }, + "dry_run": { + "type": "boolean" + }, + "etag": { + "description": "etag of the created object", + "type": "string" + }, + "id": { + "type": "string" + }, + "issues": { + "items": { + "$ref": "#/components/schemas/Issue" + }, + "type": "array", + "uniqueItems": false + }, + "key": { + "description": "identity key (types, properties)", + "type": "string" + }, + "type": { + "description": "type key of the created object", + "type": "string" + }, + "warnings": { + "items": { + "$ref": "#/components/schemas/Issue" + }, + "type": "array", + "uniqueItems": false + } + }, + "type": "object" + }, + "CreateSpaceRequest": { + "properties": { + "description": { + "type": "string" + }, + "name": { + "type": "string" + } + }, + "type": "object" + }, + "CreatedOption": { + "properties": { + "name": { + "type": "string" + }, + "property": { + "description": "property key", + "type": "string" + } + }, + "type": "object" + }, + "DiffStats": { + "properties": { + "blocks_added": { + "type": "integer" + }, + "blocks_changed": { + "type": "integer" + }, + "blocks_moved": { + "type": "integer" + }, + "blocks_removed": { + "type": "integer" + }, + "properties_changed": { + "type": "integer" + } + }, + "type": "object" + }, + "EditChatMessageRequest": { + "properties": { + "text": { + "type": "string" + } + }, + "type": "object" + }, + "EditResult": { + "properties": { + "created": { + "$ref": "#/components/schemas/SideEffects" + }, + "created_blocks": { + "additionalProperties": { + "type": "string" + }, + "description": "CreatedBlocks maps each payload position that created a block to the\nid the server minted for it: the top-level run positions\n(\"ops[3].blocks[0]\") and the nested slots alike, such as a table's\nrows and columns (\"ops[3].blocks[0].rows[1]\") and the blocks inside a\ncell run (\"ops[3].value[1]\"). A position that carried an id is\nabsent, because the block it names already existed.", + "type": "object" + }, + "created_views": { + "additionalProperties": { + "type": "string" + }, + "description": "CreatedViews maps each payload position that created a dataview view\nto the view id the server minted: an insert_view op (\"ops[i]\"), or a\nview slot of an update_block set channel (\"ops[i].set.views[2]\").\nView ids are always server-minted, and a view is not a block, so they\nare reported here rather than in CreatedBlocks.", + "type": "object" + }, + "diff_stats": { + "$ref": "#/components/schemas/DiffStats" + }, + "dry_run": { + "type": "boolean" + }, + "etag": { + "type": "string" + }, + "warnings": { + "items": { + "$ref": "#/components/schemas/Issue" + }, + "type": "array", + "uniqueItems": false + } + }, + "type": "object" + }, + "Error": { + "properties": { + "code": { + "type": "string" + }, + "issues": { + "items": { + "$ref": "#/components/schemas/Issue" + }, + "type": "array", + "uniqueItems": false + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "type": "object" + }, + "FileUploadResult": { + "properties": { + "dry_run": { + "type": "boolean" + }, + "id": { + "type": "string" + }, + "mime_type": { + "type": "string" + }, + "name": { + "type": "string" + }, + "size": { + "type": "integer" + } + }, + "type": "object" + }, + "Issue": { + "properties": { + "hint": { + "type": "string" + }, + "message": { + "type": "string" + }, + "path": { + "type": "string" + } + }, + "type": "object" + }, + "ListResponse-ChatRow": { + "properties": { + "data": { + "items": { + "$ref": "#/components/schemas/ChatRow" + }, + "type": "array", + "uniqueItems": false + }, + "has_more": { + "type": "boolean" + }, + "limit": { + "type": "integer" + }, + "message": { + "type": "string" + }, + "offset": { + "type": "integer" + }, + "total": { + "type": "integer" + }, + "warnings": { + "items": { + "$ref": "#/components/schemas/Issue" + }, + "type": "array", + "uniqueItems": false + } + }, + "type": "object" + }, + "ListResponse-MemberRow": { + "properties": { + "data": { + "items": { + "$ref": "#/components/schemas/MemberRow" + }, + "type": "array", + "uniqueItems": false + }, + "has_more": { + "type": "boolean" + }, + "limit": { + "type": "integer" + }, + "message": { + "type": "string" + }, + "offset": { + "type": "integer" + }, + "total": { + "type": "integer" + }, + "warnings": { + "items": { + "$ref": "#/components/schemas/Issue" + }, + "type": "array", + "uniqueItems": false + } + }, + "type": "object" + }, + "ListResponse-ObjectRow": { + "properties": { + "data": { + "items": { + "$ref": "#/components/schemas/ObjectRow" + }, + "type": "array", + "uniqueItems": false + }, + "has_more": { + "type": "boolean" + }, + "limit": { + "type": "integer" + }, + "message": { + "type": "string" + }, + "offset": { + "type": "integer" + }, + "total": { + "type": "integer" + }, + "warnings": { + "items": { + "$ref": "#/components/schemas/Issue" + }, + "type": "array", + "uniqueItems": false + } + }, + "type": "object" + }, + "ListResponse-OptionRow": { + "properties": { + "data": { + "items": { + "$ref": "#/components/schemas/OptionRow" + }, + "type": "array", + "uniqueItems": false + }, + "has_more": { + "type": "boolean" + }, + "limit": { + "type": "integer" + }, + "message": { + "type": "string" + }, + "offset": { + "type": "integer" + }, + "total": { + "type": "integer" + }, + "warnings": { + "items": { + "$ref": "#/components/schemas/Issue" + }, + "type": "array", + "uniqueItems": false + } + }, + "type": "object" + }, + "ListResponse-PropertyRow": { + "properties": { + "data": { + "items": { + "$ref": "#/components/schemas/PropertyRow" + }, + "type": "array", + "uniqueItems": false + }, + "has_more": { + "type": "boolean" + }, + "limit": { + "type": "integer" + }, + "message": { + "type": "string" + }, + "offset": { + "type": "integer" + }, + "total": { + "type": "integer" + }, + "warnings": { + "items": { + "$ref": "#/components/schemas/Issue" + }, + "type": "array", + "uniqueItems": false + } + }, + "type": "object" + }, + "ListResponse-SpaceRow": { + "properties": { + "data": { + "items": { + "$ref": "#/components/schemas/SpaceRow" + }, + "type": "array", + "uniqueItems": false + }, + "has_more": { + "type": "boolean" + }, + "limit": { + "type": "integer" + }, + "message": { + "type": "string" + }, + "offset": { + "type": "integer" + }, + "total": { + "type": "integer" + }, + "warnings": { + "items": { + "$ref": "#/components/schemas/Issue" + }, + "type": "array", + "uniqueItems": false + } + }, + "type": "object" + }, + "ListResponse-TypeRow": { + "properties": { + "data": { + "items": { + "$ref": "#/components/schemas/TypeRow" + }, + "type": "array", + "uniqueItems": false + }, + "has_more": { + "type": "boolean" + }, + "limit": { + "type": "integer" + }, + "message": { + "type": "string" + }, + "offset": { + "type": "integer" + }, + "total": { + "type": "integer" + }, + "warnings": { + "items": { + "$ref": "#/components/schemas/Issue" + }, + "type": "array", + "uniqueItems": false + } + }, + "type": "object" + }, + "ListResponse-ViewObject": { + "properties": { + "data": { + "items": { + "$ref": "#/components/schemas/ViewObject" + }, + "type": "array", + "uniqueItems": false + }, + "has_more": { + "type": "boolean" + }, + "limit": { + "type": "integer" + }, + "message": { + "type": "string" + }, + "offset": { + "type": "integer" + }, + "total": { + "type": "integer" + }, + "warnings": { + "items": { + "$ref": "#/components/schemas/Issue" + }, + "type": "array", + "uniqueItems": false + } + }, + "type": "object" + }, + "MemberRow": { + "properties": { + "id": { + "type": "string" + }, + "identity": { + "type": "string" + }, + "name": { + "type": "string" + }, + "role": { + "type": "string" + } + }, + "type": "object" + }, + "ObjectRow": { + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "properties": { + "additionalProperties": {}, + "type": "object" + }, + "space_id": { + "type": "string" + }, + "type": { + "type": "string" + } + }, + "type": "object" + }, + "OptionRow": { + "properties": { + "color": { + "type": "string" + }, + "name": { + "type": "string" + } + }, + "type": "object" + }, + "PropertyRow": { + "properties": { + "format": { + "type": "string" + }, + "key": { + "type": "string" + }, + "name": { + "type": "string" + } + }, + "type": "object" + }, + "SchemaEntry": { + "properties": { + "endpoint": { + "type": "string" + }, + "example": { + "type": "object" + }, + "grammar": { + "type": "string" + }, + "grammar_examples": { + "items": { + "type": "string" + }, + "type": "array", + "uniqueItems": false + }, + "kind": { + "type": "string" + }, + "schema": { + "type": "object" + } + }, + "type": "object" + }, + "SchemaIndex": { + "properties": { + "kinds": { + "items": { + "$ref": "#/components/schemas/SchemaIndexEntry" + }, + "type": "array", + "uniqueItems": false + }, + "ops": { + "items": { + "$ref": "#/components/schemas/SchemaIndexEntry" + }, + "type": "array", + "uniqueItems": false + } + }, + "type": "object" + }, + "SchemaIndexEntry": { + "properties": { + "endpoint": { + "type": "string" + }, + "kind": { + "type": "string" + }, + "url": { + "type": "string" + } + }, + "type": "object" + }, + "SearchRequestDoc": { + "properties": { + "fields": { + "items": { + "type": "string" + }, + "type": "array", + "uniqueItems": false + }, + "filter": { + "type": "string" + }, + "filters": { + "items": { + "additionalProperties": {}, + "type": "object" + }, + "type": "array", + "uniqueItems": false + }, + "query": { + "type": "string" + }, + "sorts": { + "items": { + "additionalProperties": {}, + "type": "object" + }, + "type": "array", + "uniqueItems": false + }, + "type": { + "type": "string" + } + }, + "type": "object" + }, + "SideEffects": { + "properties": { + "options": { + "items": { + "$ref": "#/components/schemas/CreatedOption" + }, + "type": "array", + "uniqueItems": false + }, + "properties": { + "items": { + "$ref": "#/components/schemas/PropertyRow" + }, + "type": "array", + "uniqueItems": false + } + }, + "type": "object" + }, + "Space": { + "properties": { + "description": { + "type": "string" + }, + "dry_run": { + "type": "boolean" + }, + "id": { + "type": "string" + }, + "name": { + "type": "string" + } + }, + "type": "object" + }, + "SpaceRow": { + "properties": { + "description": { + "type": "string" + }, + "id": { + "description": "Id is the space's short reference: the last six characters of the\nfirst half of its id. It is the full id instead when that tail is\nshared with another visible space, or when the request asked for\n`?ids=full`. Either spelling is accepted back on every route that\ntakes a space.", + "type": "string" + }, + "name": { + "type": "string" + } + }, + "type": "object" + }, + "TypeRow": { + "properties": { + "key": { + "type": "string" + }, + "name": { + "type": "string" + } + }, + "type": "object" + }, + "UpdateSpaceRequest": { + "properties": { + "description": { + "type": "string" + }, + "name": { + "type": "string" + } + }, + "type": "object" + }, + "ValidateResponse": { + "properties": { + "issues": { + "items": { + "$ref": "#/components/schemas/Issue" + }, + "type": "array", + "uniqueItems": false + }, + "warnings": { + "items": { + "$ref": "#/components/schemas/Issue" + }, + "type": "array", + "uniqueItems": false + } + }, + "type": "object" + }, + "ViewObject": { + "additionalProperties": {}, + "type": "object" + }, + "WhoamiApi": { + "properties": { + "version": { + "type": "string" + } + }, + "type": "object" + }, + "WhoamiGrant": { + "properties": { + "permission": { + "description": "the compact form agents string-match on", + "type": "string" + }, + "scoped": { + "type": "boolean" + }, + "spaces": { + "items": { + "$ref": "#/components/schemas/WhoamiGrantSpace" + }, + "type": "array", + "uniqueItems": false + } + }, + "type": "object" + }, + "WhoamiGrantSpace": { + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "permission": { + "type": "string" + } + }, + "type": "object" + }, + "WhoamiKey": { + "properties": { + "created_at": { + "type": "string" + }, + "expires_at": { + "type": "string" + }, + "id": { + "description": "the app link's hash, which is the id the key list shows", + "type": "string" + }, + "name": { + "type": "string" + } + }, + "type": "object" + }, + "WhoamiResponse": { + "properties": { + "api": { + "$ref": "#/components/schemas/WhoamiApi" + }, + "grant": { + "$ref": "#/components/schemas/WhoamiGrant" + }, + "key": { + "$ref": "#/components/schemas/WhoamiKey" + }, + "key_status": { + "description": "\"legacy\" | \"scoped\", always present", + "type": "string" + }, + "notice": { + "description": "the legacy sentence, verbatim printable", + "type": "string" + }, + "scope": { + "description": "\"jsonApi\" | \"full\" | \"limited\"", + "type": "string" + } + }, + "type": "object" + } + }, + "securitySchemes": { + "bearerauth": { + "bearerFormat": "JWT", + "scheme": "bearer", + "type": "http" + } + } + }, + "info": { + "contact": { + "email": "support@anytype.io", + "name": "Anytype Support", + "url": "https://anytype.io/contact" + }, + "description": "The agent-oriented Anytype local API. An object is one AnyBlock JSON document rather than a tree of blocks, so a single GET returns a whole editable document and a single PATCH edits it.\nEverything this API names for itself is snake_case: path and query parameters, request and response fields, and the names of the PATCH ops. Things are addressed by name rather than by id: property keys, option names, and `type` as a type key. Inside an object's `blocks` and `properties` you are reading the AnyBlock format's own vocabulary, which passes through unchanged.\nResponses are compact. A list or search row carries id, name, type and the properties you asked for, and never embeds a type object. Object references are always full and inline. An object read relabels machine-minted block ids to short document-local suffixes; `?ids=full` returns the export shape, with full ids everywhere, which is the shape to store and the shape to clone from.\nWherever a block or a view is addressed by id, a full id or a unique suffix of one is accepted. That is what lets a document read back in the compact shape be edited exactly as it came back. A suffix that matches several elements is refused, and the refusal lists the candidates.\nA read never fails on content it cannot represent. Whatever a representation cannot express is reported in `warnings` beside the result.\nEvery error has one shape: {status, code, message, issues:[{path, message, hint}]}. Each issue is addressed by path and names the values that would have been accepted, so a failed call tells you how to repair it.\nAuthentication is a bearer token in the Authorization header. It is never read from a query or body parameter. An unknown, revoked or expired key is a 401.\nAn object read returns an `etag` in the body and an ETag header. A mutation takes that etag back in `If-Match`, where it is advisory: without the header the last write wins, and a stale one is a 409 carrying the current etag. Chats are the exception. They have no etag, because their order ids and `last_state_id` do that job.\nEvery mutation accepts an `Idempotency-Key` header. The same key with the same body replays the stored response instead of repeating the write. Search is a read carried by POST and takes no key.\nEvery mutation accepts `?dry_run=true`. It validates the request, reports what would have happened and writes nothing, answering 200 where the real call would answer 201. Where a dry run cannot tell the whole truth, the operation says so.\nEvery list is paginated with `?offset=` and `?limit=`, 25 rows by default. The response carries `total`, `has_more` and, when it truncated, a hint for narrowing the request. Chat messages page by order-id cursor instead.\nRequest bodies bind strictly: an unknown field is a 400 naming the field, never a value silently dropped. A document body is capped at 10 MiB, a structured body at 1 MiB.\nDeleting an object, a type or a property archives it: it moves to Bin, and the Anytype app can restore it. Deleting a chat message is not an archive, and neither is the attachment cleanup that can follow it.\nSchemas are discoverable at runtime, and strict enough to decode against: GET /v2/schemas lists the kinds, GET /v2/schemas/{kind} returns one, and GET /v2/schemas/ops/{op} returns the schema of a single edit op.\nA space is served by a short reference: the last six characters of the first half of its id. Every route that takes a space accepts either that short reference or the full `.` id. Resolution tries an exact id first, then a unique suffix, among the spaces the key can see. An ambiguous reference is a 400 listing the candidates, and two spaces whose tails collide are both served in full.\nA short reference is an addressing convenience, not a stable identifier. It is unique only against the spaces the key can currently see, so joining a space whose tail collides retires it. `?ids=full` spells every space id in the response out in full. Use it whenever a reference will be stored outside this API: a config file, a script, a log line, another system.", + "license": { + "name": "Any Source Available License 1.0", + "url": "https://github.com/anyproto/anytype-api/blob/main/LICENSE.md" + }, + "termsOfService": "https://anytype.io/terms_of_use", + "title": "Anytype API v2", + "version": "2025-11-08" + }, + "externalDocs": { + "description": "OpenAPI", + "url": "https://swagger.io/resources/open-api/" + }, + "paths": { + "/v2/auth/whoami": { + "get": { + "description": "This describes the key, not a person; there is one account behind this API. Branch on `grant.scoped`. False is a legacy key with no space restriction, and its `spaces` list is empty rather than absent. True means the key reaches exactly the spaces listed, with the permission listed beside each one.", + "operationId": "auth_whoami", + "parameters": [ + { + "description": "How grant.spaces[].id is spelled: compact (default) is the short space reference; full is the whole . id, and the spelling to store outside this API", + "in": "query", + "name": "ids", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WhoamiResponse" + } + } + }, + "description": "The key's grant, as it is enforced" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + }, + "description": "Missing, unknown, revoked or expired key. This is the shared auth envelope, not this API's error shape." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ForbiddenError" + } + } + }, + "description": "The key's scope does not admit this API. This is the shared scope gate's envelope." + } + }, + "security": [ + { + "bearerauth": [] + } + ], + "summary": "Describe the calling key", + "tags": [ + "Auth" + ] + } + }, + "/v2/schemas": { + "get": { + "operationId": "list_schemas", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SchemaIndex" + } + } + }, + "description": "Schema index" + } + }, + "security": [ + { + "bearerauth": [] + } + ], + "summary": "List the available schemas", + "tags": [ + "Schemas" + ] + } + }, + "/v2/schemas/ops/{op}": { + "get": { + "description": "The example is a single op object, ready to drop into an edit request's `ops` array, not a whole request body.", + "operationId": "get_op_schema", + "parameters": [ + { + "description": "Op name: set_properties, update_block, replace_subtree, insert_blocks, move_block, delete_block, replace_text, set_cell, update_view, insert_view, move_view, delete_view, add_items, remove_items", + "in": "path", + "name": "op", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SchemaEntry" + } + } + }, + "description": "Schema + example" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Unknown op" + } + }, + "security": [ + { + "bearerauth": [] + } + ], + "summary": "Get the schema for one edit op", + "tags": [ + "Schemas" + ] + } + }, + "/v2/schemas/{kind}": { + "get": { + "operationId": "get_schema", + "parameters": [ + { + "description": "Schema kind, as listed by GET /v2/schemas", + "in": "path", + "name": "kind", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SchemaEntry" + } + } + }, + "description": "Schema + example" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Unknown kind" + } + }, + "security": [ + { + "bearerauth": [] + } + ], + "summary": "Get the schema for one kind", + "tags": [ + "Schemas" + ] + } + }, + "/v2/search": { + "post": { + "description": "Type keys and option names are resolved per space. A name that resolves in only some spaces searches those and warns about the rest. `total` is the sum of the per-space counts, and each row carries its `space_id`.", + "operationId": "search_global", + "parameters": [ + { + "description": "Items to skip", + "in": "query", + "name": "offset", + "schema": { + "default": 0, + "type": "integer" + } + }, + { + "description": "Items to return", + "in": "query", + "name": "limit", + "schema": { + "default": 25, + "type": "integer" + } + }, + { + "description": "How each row's space_id is spelled: compact (default) is the short space reference; full is the whole . id, and the spelling to store outside this API", + "in": "query", + "name": "ids", + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SearchRequestDoc" + } + } + }, + "description": "Search request", + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ListResponse-ObjectRow" + } + } + }, + "description": "Minimal object rows with space_id" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Invalid request" + } + }, + "security": [ + { + "bearerauth": [] + } + ], + "summary": "Search every space", + "tags": [ + "Search" + ] + } + }, + "/v2/spaces": { + "get": { + "description": "Only live spaces are listed. A space that is deleted, left, or still joining does not appear.", + "operationId": "list_spaces", + "parameters": [ + { + "description": "compact (default) is the short space reference; full is the whole . id, and the spelling to store outside this API", + "in": "query", + "name": "ids", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ListResponse-SpaceRow" + } + } + }, + "description": "Minimal space rows" + } + }, + "security": [ + { + "bearerauth": [] + } + ], + "summary": "List the account's spaces", + "tags": [ + "Spaces" + ] + }, + "post": { + "description": "A retry the server already handled makes a second space unless it carries the same idempotency key. A dry run validates the body and stops there; creating a space cannot be simulated.", + "operationId": "create_space", + "parameters": [ + { + "description": "Validate the body without creating", + "in": "query", + "name": "dry_run", + "schema": { + "type": "boolean" + } + }, + { + "description": "compact (default) is the short space reference; full is the whole . id of the new space, and the spelling to store outside this API", + "in": "query", + "name": "ids", + "schema": { + "type": "string" + } + }, + { + "description": "Replay guard: the same key with the same body replays the stored response", + "in": "header", + "name": "Idempotency-Key", + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateSpaceRequest" + } + } + }, + "description": "The space to create", + "required": true + }, + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Space" + } + } + }, + "description": "Created space" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Validation failure" + } + }, + "security": [ + { + "bearerauth": [] + } + ], + "summary": "Create a space", + "tags": [ + "Spaces" + ] + } + }, + "/v2/spaces/{space_id}": { + "get": { + "description": "Only live spaces are served. A space that is deleted, left, or still joining is a 404.", + "operationId": "get_space", + "parameters": [ + { + "description": "Space id", + "in": "path", + "name": "space_id", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "compact (default) is the short space reference; full is the whole . id, and the spelling to store outside this API", + "in": "query", + "name": "ids", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Space" + } + } + }, + "description": "The space row" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Space not found" + } + }, + "security": [ + { + "bearerauth": [] + } + ], + "summary": "Get one space", + "tags": [ + "Spaces" + ] + }, + "patch": { + "description": "At least one of the two fields must be present; a field left out keeps its current value.", + "operationId": "update_space", + "parameters": [ + { + "description": "Space id", + "in": "path", + "name": "space_id", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "Validate and report without committing", + "in": "query", + "name": "dry_run", + "schema": { + "type": "boolean" + } + }, + { + "description": "compact (default) is the short space reference; full is the whole . id, and the spelling to store outside this API", + "in": "query", + "name": "ids", + "schema": { + "type": "string" + } + }, + { + "description": "Replay guard: the same key with the same body replays the stored response", + "in": "header", + "name": "Idempotency-Key", + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateSpaceRequest" + } + } + }, + "description": "The fields to change", + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Space" + } + } + }, + "description": "The updated space row" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Validation failure" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "The caller's role cannot change the space info" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Space not found or not live" + } + }, + "security": [ + { + "bearerauth": [] + } + ], + "summary": "Update a space", + "tags": [ + "Spaces" + ] + } + }, + "/v2/spaces/{space_id}/chats": { + "get": { + "description": "A row carries no unread counters. Per-chat unread state comes back with the messages read instead.", + "operationId": "list_chats", + "parameters": [ + { + "description": "Space id", + "in": "path", + "name": "space_id", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "Rows to skip", + "in": "query", + "name": "offset", + "schema": { + "default": 0, + "type": "integer" + } + }, + { + "description": "Rows to return", + "in": "query", + "name": "limit", + "schema": { + "default": 25, + "type": "integer" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ListResponse-ChatRow" + } + } + }, + "description": "Chat rows" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Space not found" + } + }, + "security": [ + { + "bearerauth": [] + } + ], + "summary": "List the chats in a space", + "tags": [ + "Chat" + ] + }, + "post": { + "description": "Messages are not blocks. Add them through the messages route; a document edit cannot reach them.", + "operationId": "create_chat", + "parameters": [ + { + "description": "Space id", + "in": "path", + "name": "space_id", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "Validate and report without committing", + "in": "query", + "name": "dry_run", + "schema": { + "type": "boolean" + } + }, + { + "description": "Replay guard: the same key with the same body replays the stored response", + "in": "header", + "name": "Idempotency-Key", + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateChatRequest" + } + } + }, + "description": "The chat to create", + "required": true + }, + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ChatResult" + } + } + }, + "description": "Created chat row" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Validation failure" + } + }, + "security": [ + { + "bearerauth": [] + } + ], + "summary": "Create a chat", + "tags": [ + "Chat" + ] + } + }, + "/v2/spaces/{space_id}/chats/{chat_id}/messages": { + "get": { + "description": "`after` on its own walks forward, oldest first, continuing from `next_after`. Every other query, including `after` together with `before`, is anchored at the newest end of the range and walks backward from `next_before`. Both bounds are exclusive. `message_count` is the chat's total since it began, not the size of the range. Offset paging does not apply here, and `offset` is refused.", + "operationId": "get_chat_messages", + "parameters": [ + { + "description": "Space id", + "in": "path", + "name": "space_id", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "Chat object id", + "in": "path", + "name": "chat_id", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "Return messages after this order id (exclusive)", + "in": "query", + "name": "after", + "schema": { + "type": "string" + } + }, + { + "description": "Return messages before this order id (exclusive)", + "in": "query", + "name": "before", + "schema": { + "type": "string" + } + }, + { + "description": "Messages to return", + "in": "query", + "name": "limit", + "schema": { + "default": 25, + "type": "integer" + } + }, + { + "description": "counts (default) returns the emoji counts; full adds the participant ids behind each count", + "in": "query", + "name": "reactions", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ChatMessagesResponse" + } + } + }, + "description": "Messages + state + message_count" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Not a chat, or invalid params" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Chat not found" + } + }, + "security": [ + { + "bearerauth": [] + } + ], + "summary": "List chat messages", + "tags": [ + "Chat" + ] + }, + "post": { + "description": "The text is markup source, so `*`, `[` and a mention tag mint real marks; escape a literal one with a backslash. The cap is 8000 UTF-16 code units, where one emoji can cost two or more. Attachments are object ids, at most 32, and each one's kind is taken from the target's layout.", + "operationId": "add_chat_message", + "parameters": [ + { + "description": "Space id", + "in": "path", + "name": "space_id", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "Chat object id", + "in": "path", + "name": "chat_id", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "Validate and report without committing", + "in": "query", + "name": "dry_run", + "schema": { + "type": "boolean" + } + }, + { + "description": "Replay guard: the same key with the same body replays the stored response", + "in": "header", + "name": "Idempotency-Key", + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AddChatMessageRequest" + } + } + }, + "description": "The message to send", + "required": true + }, + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ChatMessageResult" + } + } + }, + "description": "Created message id" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Validation failure" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Chat not found" + } + }, + "security": [ + { + "bearerauth": [] + } + ], + "summary": "Send a chat message", + "tags": [ + "Chat" + ] + } + }, + "/v2/spaces/{space_id}/chats/{chat_id}/messages/{message_id}": { + "delete": { + "description": "An attachment whose only reference was this message is erased for good afterwards, not moved to Bin. The response names those ids in `warnings`, and a dry run reports the same list without deleting anything. A message that does not exist is a 404 on the dry run too.", + "operationId": "delete_chat_message", + "parameters": [ + { + "description": "Space id", + "in": "path", + "name": "space_id", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "Chat object id", + "in": "path", + "name": "chat_id", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "Message id", + "in": "path", + "name": "message_id", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "Report what would be deleted, attachments included, without committing", + "in": "query", + "name": "dry_run", + "schema": { + "type": "boolean" + } + }, + { + "description": "Replay guard: the same key with the same body replays the stored response", + "in": "header", + "name": "Idempotency-Key", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ChatMessageResult" + } + } + }, + "description": "Deleted message id" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Chat or message not found" + } + }, + "security": [ + { + "bearerauth": [] + } + ], + "summary": "Delete a chat message", + "tags": [ + "Chat" + ] + }, + "patch": { + "description": "Every mark is re-derived from the text you send, so a mark the old text carried and the new text does not spell out is lost. Attachments, the reply target and the style survive. Editing another member's message is a 403.", + "operationId": "edit_chat_message", + "parameters": [ + { + "description": "Space id", + "in": "path", + "name": "space_id", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "Chat object id", + "in": "path", + "name": "chat_id", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "Message id", + "in": "path", + "name": "message_id", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "Validate and report without committing", + "in": "query", + "name": "dry_run", + "schema": { + "type": "boolean" + } + }, + { + "description": "Replay guard: the same key with the same body replays the stored response", + "in": "header", + "name": "Idempotency-Key", + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/EditChatMessageRequest" + } + } + }, + "description": "The replacement text", + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ChatMessageResult" + } + } + }, + "description": "Edited message id" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Validation failure" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Chat or message not found" + } + }, + "security": [ + { + "bearerauth": [] + } + ], + "summary": "Replace a chat message's text", + "tags": [ + "Chat" + ] + } + }, + "/v2/spaces/{space_id}/chats/{chat_id}/messages/{message_id}/reactions": { + "post": { + "description": "`added` says which way the toggle went. A dry run predicts it, but when there is no account identity to predict with it omits the field and says so in `warnings`.", + "operationId": "toggle_chat_reaction", + "parameters": [ + { + "description": "Space id", + "in": "path", + "name": "space_id", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "Chat object id", + "in": "path", + "name": "chat_id", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "Message id", + "in": "path", + "name": "message_id", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "Report the would-be outcome without committing", + "in": "query", + "name": "dry_run", + "schema": { + "type": "boolean" + } + }, + { + "description": "Replay guard: the same key with the same body replays the stored response", + "in": "header", + "name": "Idempotency-Key", + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ChatReactionRequest" + } + } + }, + "description": "The emoji to toggle", + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ChatReactionResult" + } + } + }, + "description": "Toggle outcome" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Validation failure" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Chat or message not found" + } + }, + "security": [ + { + "bearerauth": [] + } + ], + "summary": "Toggle a reaction on a chat message", + "tags": [ + "Chat" + ] + } + }, + "/v2/spaces/{space_id}/chats/{chat_id}/read": { + "post": { + "description": "`up_to` is inclusive, and it and `last_state_id` both come from one messages read: the newest message's order, and the state's own id. An empty value for either would silently mark nothing, so it is refused. Messages that arrived after that state stay unread. The reactions scope marks every unread reaction and takes neither field.", + "operationId": "read_chat", + "parameters": [ + { + "description": "Space id", + "in": "path", + "name": "space_id", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "Chat object id", + "in": "path", + "name": "chat_id", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "Validate and report without committing", + "in": "query", + "name": "dry_run", + "schema": { + "type": "boolean" + } + }, + { + "description": "Replay guard: the same key with the same body replays the stored response", + "in": "header", + "name": "Idempotency-Key", + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ChatReadRequest" + } + } + }, + "description": "The watermark move", + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ChatReadResult" + } + } + }, + "description": "Watermark moved" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Validation failure" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Chat not found" + } + }, + "security": [ + { + "bearerauth": [] + } + ], + "summary": "Move a chat's read watermark", + "tags": [ + "Chat" + ] + } + }, + "/v2/spaces/{space_id}/collections": { + "post": { + "description": "Item ids are checked against the space; an id that does not resolve there is refused rather than dropped.", + "operationId": "create_collection", + "parameters": [ + { + "description": "Space id", + "in": "path", + "name": "space_id", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "Validate and report without committing", + "in": "query", + "name": "dry_run", + "schema": { + "type": "boolean" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object" + } + } + } + }, + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateResult" + } + } + }, + "description": "Created collection id" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Validation or reference failure" + }, + "413": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Request body exceeds the 1 MiB cap" + } + }, + "security": [ + { + "bearerauth": [] + } + ], + "summary": "Create a collection", + "tags": [ + "Lists" + ] + } + }, + "/v2/spaces/{space_id}/collections/{collection_id}/objects": { + "get": { + "description": "Members come back in the order the collection stores them, not sorted, unless a view is applied.", + "operationId": "get_collection_objects", + "parameters": [ + { + "description": "Space id", + "in": "path", + "name": "space_id", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "Collection object id", + "in": "path", + "name": "collection_id", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "Stored view id (exact or unique suffix)", + "in": "query", + "name": "view", + "schema": { + "type": "string" + } + }, + { + "description": "Comma-separated property keys to include per row", + "in": "query", + "name": "fields", + "schema": { + "type": "string" + } + }, + { + "description": "Items to skip", + "in": "query", + "name": "offset", + "schema": { + "default": 0, + "type": "integer" + } + }, + { + "description": "Items to return", + "in": "query", + "name": "limit", + "schema": { + "default": 25, + "type": "integer" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ListResponse-ObjectRow" + } + } + }, + "description": "Minimal object rows" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Wrong-layout target or invalid params" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Space, collection or view not found" + } + }, + "security": [ + { + "bearerauth": [] + } + ], + "summary": "List a collection's objects", + "tags": [ + "Lists" + ] + } + }, + "/v2/spaces/{space_id}/collections/{collection_id}/views": { + "get": { + "operationId": "get_collection_views", + "parameters": [ + { + "description": "Space id", + "in": "path", + "name": "space_id", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "Collection object id", + "in": "path", + "name": "collection_id", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "Items to skip", + "in": "query", + "name": "offset", + "schema": { + "default": 0, + "type": "integer" + } + }, + { + "description": "Items to return", + "in": "query", + "name": "limit", + "schema": { + "default": 25, + "type": "integer" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ListResponse-ViewObject" + } + } + }, + "description": "The stored views, with their sorts, filters and columns" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Wrong-layout target" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Space or collection not found" + } + }, + "security": [ + { + "bearerauth": [] + } + ], + "summary": "List a collection's views", + "tags": [ + "Lists" + ] + } + }, + "/v2/spaces/{space_id}/files": { + "post": { + "description": "Send multipart/form-data with a `file` field, or JSON {\"url\": …}. A source that refuses the fetch, or a URL that cannot be fetched, is a 400 naming /url; only a genuine server fault answers 500. The id that comes back is the one file blocks, image blocks and icon_image values reference.", + "operationId": "upload_file", + "parameters": [ + { + "description": "Space id", + "in": "path", + "name": "space_id", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "Validate and report without committing", + "in": "query", + "name": "dry_run", + "schema": { + "type": "boolean" + } + } + ], + "requestBody": { + "content": { + "multipart/form-data": { + "schema": { + "type": "object" + } + } + } + }, + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/FileUploadResult" + } + } + }, + "description": "Created file object id" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Validation failure, or a source URL that did not yield the file" + }, + "413": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "JSON request body exceeds the 1 MiB cap" + } + }, + "security": [ + { + "bearerauth": [] + } + ], + "summary": "Upload a file", + "tags": [ + "Files" + ] + } + }, + "/v2/spaces/{space_id}/members": { + "get": { + "operationId": "list_members", + "parameters": [ + { + "description": "Space id", + "in": "path", + "name": "space_id", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ListResponse-MemberRow" + } + } + }, + "description": "Minimal member rows" + } + }, + "security": [ + { + "bearerauth": [] + } + ], + "summary": "List the members of a space", + "tags": [ + "Members" + ] + } + }, + "/v2/spaces/{space_id}/members/me": { + "get": { + "description": "The identity is taken from the account this API runs against; there is no member id to send.", + "operationId": "get_member_me", + "parameters": [ + { + "description": "Space id", + "in": "path", + "name": "space_id", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MemberRow" + } + } + }, + "description": "The caller's member row" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Space not found, or no account identity" + } + }, + "security": [ + { + "bearerauth": [] + } + ], + "summary": "Get the calling member", + "tags": [ + "Members" + ] + } + }, + "/v2/spaces/{space_id}/objects": { + "get": { + "operationId": "list_objects", + "parameters": [ + { + "description": "Space id", + "in": "path", + "name": "space_id", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "Comma-separated property keys to include per row", + "in": "query", + "name": "fields", + "schema": { + "type": "string" + } + }, + { + "description": "Items to skip", + "in": "query", + "name": "offset", + "schema": { + "default": 0, + "type": "integer" + } + }, + { + "description": "Items to return", + "in": "query", + "name": "limit", + "schema": { + "default": 25, + "type": "integer" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ListResponse-ObjectRow" + } + } + }, + "description": "Minimal object rows" + } + }, + "security": [ + { + "bearerauth": [] + } + ], + "summary": "List the objects in a space", + "tags": [ + "Objects" + ] + }, + "post": { + "description": "A select value naming an option that does not exist creates that option in the space. An unknown type or property key is rejected instead, with the closest matches named. The body is either a full AnyBlock document or the shortcut {type, name, properties, markdown}; `version` or `blocks` picks the document form.", + "operationId": "create_object", + "parameters": [ + { + "description": "Space id", + "in": "path", + "name": "space_id", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "Validate and report without committing", + "in": "query", + "name": "dry_run", + "schema": { + "type": "boolean" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object" + } + } + } + }, + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateResult" + } + } + }, + "description": "Created object id + etag" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Validation or reference failure" + } + }, + "security": [ + { + "bearerauth": [] + } + ], + "summary": "Create an object", + "tags": [ + "Objects" + ] + } + }, + "/v2/spaces/{space_id}/objects/{object_id}": { + "delete": { + "description": "Only objects this key created can be deleted. The creator is recorded at creation time and never added later, so objects made in the app, imported, made by another member, or made before this route shipped are refused for good. System objects are a 403 as well. A dry run reports the verdict without the checks that run at archive time, so a deletable verdict can still meet a 403.", + "operationId": "delete_object", + "parameters": [ + { + "description": "Space id", + "in": "path", + "name": "space_id", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "Object id", + "in": "path", + "name": "object_id", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "Probe deletability without writing", + "in": "query", + "name": "dry_run", + "schema": { + "type": "boolean" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateResult" + } + } + }, + "description": "Archived object, or the dry-run verdict. Deleting again is a 200 carrying a warning." + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "A type or a property: use their own delete routes" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "not_created_by_this_key, naming the recorded creator or its absence" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Object or space not found" + } + }, + "security": [ + { + "bearerauth": [] + } + ], + "summary": "Delete an object this key created", + "tags": [ + "Objects" + ] + }, + "get": { + "description": "A `block` subtree comes back flagged as a subtree, and no write path accepts that partial body. `format=md` is read-only; markdown cannot be sent back.", + "operationId": "get_object", + "parameters": [ + { + "description": "Space id", + "in": "path", + "name": "space_id", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "Object id", + "in": "path", + "name": "object_id", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "Subset of properties,blocks (default both)", + "in": "query", + "name": "include", + "schema": { + "type": "string" + } + }, + { + "description": "Return the block skeleton instead of full blocks", + "in": "query", + "name": "outline", + "schema": { + "type": "boolean" + } + }, + { + "description": "Return only this block's subtree", + "in": "query", + "name": "block", + "schema": { + "type": "string" + } + }, + { + "description": "compact (default) is the edit shape, where minted block ids relabel to short suffixes; full is the export shape, with full ids everywhere, and the shape to send back. Object references are full and inline in both.", + "in": "query", + "name": "ids", + "schema": { + "type": "string" + } + }, + { + "description": "anyblock (default) or md", + "in": "query", + "name": "format", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "additionalProperties": {}, + "type": "object" + } + } + }, + "description": "The flat AnyBlock document + etag" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Illegal parameter combination (ambiguous_input)" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Object or space not found" + } + }, + "security": [ + { + "bearerauth": [] + } + ], + "summary": "Read an object as an AnyBlock document", + "tags": [ + "Objects" + ] + }, + "patch": { + "description": "Ops apply in order as one change set. If one fails, or the result breaks the format's rules, none of them land. `update_block`, `delete_block` and `replace_text` can address a block by its exact text instead of an id; text matching zero or several blocks is refused, not guessed at. A later op sees the earlier ones' edits. Ops that only create take no id; the new ids come back in `created_blocks`.", + "operationId": "patch_object", + "parameters": [ + { + "description": "Space id", + "in": "path", + "name": "space_id", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "Object id", + "in": "path", + "name": "object_id", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "The etag the object must still carry", + "in": "header", + "name": "If-Match", + "schema": { + "type": "string" + } + }, + { + "description": "Validate and report without committing", + "in": "query", + "name": "dry_run", + "schema": { + "type": "boolean" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object" + } + } + } + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/EditResult" + } + } + }, + "description": "New etag + created block ids + diff_stats" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Invalid ops or post-op document" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Object, space, or referenced block not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Stale If-Match (etag_mismatch)" + } + }, + "security": [ + { + "bearerauth": [] + } + ], + "summary": "Edit an object with a batch of ops", + "tags": [ + "Objects" + ] + } + }, + "/v2/spaces/{space_id}/properties": { + "get": { + "operationId": "list_properties", + "parameters": [ + { + "description": "Space id", + "in": "path", + "name": "space_id", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ListResponse-PropertyRow" + } + } + }, + "description": "Property rows" + } + }, + "security": [ + { + "bearerauth": [] + } + ], + "summary": "List the properties in a space", + "tags": [ + "Properties" + ] + }, + "post": { + "operationId": "create_property", + "parameters": [ + { + "description": "Space id", + "in": "path", + "name": "space_id", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "Validate and report without committing", + "in": "query", + "name": "dry_run", + "schema": { + "type": "boolean" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object" + } + } + } + }, + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateResult" + } + } + }, + "description": "Created property id + key" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Validation failure" + }, + "413": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Request body exceeds the 1 MiB cap" + } + }, + "security": [ + { + "bearerauth": [] + } + ], + "summary": "Create a property", + "tags": [ + "Properties" + ] + } + }, + "/v2/spaces/{space_id}/properties/{key}": { + "delete": { + "operationId": "delete_property", + "parameters": [ + { + "description": "Space id", + "in": "path", + "name": "space_id", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "Property key", + "in": "path", + "name": "key", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateResult" + } + } + }, + "description": "Archived property" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "No live property with this key. A property that is already deleted is a 404 too, not a second delete." + } + }, + "security": [ + { + "bearerauth": [] + } + ], + "summary": "Delete a property", + "tags": [ + "Properties" + ] + }, + "patch": { + "description": "Only the display name can change. The key is the property's identity, and its format is fixed once it exists.", + "operationId": "update_property", + "parameters": [ + { + "description": "Space id", + "in": "path", + "name": "space_id", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "Property key", + "in": "path", + "name": "key", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object" + } + } + } + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateResult" + } + } + }, + "description": "Updated property" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Validation failure" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Property not found" + }, + "413": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Request body exceeds the 1 MiB cap" + } + }, + "security": [ + { + "bearerauth": [] + } + ], + "summary": "Update a property", + "tags": [ + "Properties" + ] + } + }, + "/v2/spaces/{space_id}/properties/{key}/options": { + "get": { + "operationId": "list_property_options", + "parameters": [ + { + "description": "Space id", + "in": "path", + "name": "space_id", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "Property key", + "in": "path", + "name": "key", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "Case-insensitive name prefix filter", + "in": "query", + "name": "prefix", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ListResponse-OptionRow" + } + } + }, + "description": "Option rows" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Property not found" + } + }, + "security": [ + { + "bearerauth": [] + } + ], + "summary": "List a property's options", + "tags": [ + "Properties" + ] + } + }, + "/v2/spaces/{space_id}/search": { + "post": { + "description": "`filter` and `filters` are two spellings of the same thing, the compact string and the structured array; sending both is refused. This is a read carried by POST because the query needs a body, so pagination stays in the query string and a `limit` or `offset` in the body is refused.", + "operationId": "search_space", + "parameters": [ + { + "description": "Space id", + "in": "path", + "name": "space_id", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "Items to skip", + "in": "query", + "name": "offset", + "schema": { + "default": 0, + "type": "integer" + } + }, + { + "description": "Items to return", + "in": "query", + "name": "limit", + "schema": { + "default": 25, + "type": "integer" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SearchRequestDoc" + } + } + }, + "description": "Search request", + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ListResponse-ObjectRow" + } + } + }, + "description": "Minimal object rows" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Invalid request (validation_failed / ambiguous_input)" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Space not found" + } + }, + "security": [ + { + "bearerauth": [] + } + ], + "summary": "Search one space", + "tags": [ + "Search" + ] + } + }, + "/v2/spaces/{space_id}/sets": { + "post": { + "description": "Filter and sort property keys are checked against the type the set queries; a key that type does not carry is refused.", + "operationId": "create_set", + "parameters": [ + { + "description": "Space id", + "in": "path", + "name": "space_id", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "Validate and report without committing", + "in": "query", + "name": "dry_run", + "schema": { + "type": "boolean" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object" + } + } + } + }, + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateResult" + } + } + }, + "description": "Created set id" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Validation or reference failure" + }, + "413": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Request body exceeds the 1 MiB cap" + } + }, + "security": [ + { + "bearerauth": [] + } + ], + "summary": "Create a set", + "tags": [ + "Lists" + ] + } + }, + "/v2/spaces/{space_id}/sets/{set_id}/objects": { + "get": { + "description": "A stored view's dynamic placeholders, such as the current date or the calling member, are resolved here. One that cannot be resolved becomes a warning rather than a silently empty result.", + "operationId": "get_set_objects", + "parameters": [ + { + "description": "Space id", + "in": "path", + "name": "space_id", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "Set object id", + "in": "path", + "name": "set_id", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "Stored view id (exact or unique suffix)", + "in": "query", + "name": "view", + "schema": { + "type": "string" + } + }, + { + "description": "Comma-separated property keys to include per row", + "in": "query", + "name": "fields", + "schema": { + "type": "string" + } + }, + { + "description": "Items to skip", + "in": "query", + "name": "offset", + "schema": { + "default": 0, + "type": "integer" + } + }, + { + "description": "Items to return", + "in": "query", + "name": "limit", + "schema": { + "default": 25, + "type": "integer" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ListResponse-ObjectRow" + } + } + }, + "description": "Minimal object rows" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Wrong-layout target or invalid params" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Space, set or view not found" + } + }, + "security": [ + { + "bearerauth": [] + } + ], + "summary": "Run a set's query and list what it matches", + "tags": [ + "Lists" + ] + } + }, + "/v2/spaces/{space_id}/sets/{set_id}/views": { + "get": { + "operationId": "get_set_views", + "parameters": [ + { + "description": "Space id", + "in": "path", + "name": "space_id", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "Set object id", + "in": "path", + "name": "set_id", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "Items to skip", + "in": "query", + "name": "offset", + "schema": { + "default": 0, + "type": "integer" + } + }, + { + "description": "Items to return", + "in": "query", + "name": "limit", + "schema": { + "default": 25, + "type": "integer" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ListResponse-ViewObject" + } + } + }, + "description": "The stored views, with their sorts, filters and columns" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Wrong-layout target" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Space or set not found" + } + }, + "security": [ + { + "bearerauth": [] + } + ], + "summary": "List a set's views", + "tags": [ + "Lists" + ] + } + }, + "/v2/spaces/{space_id}/templates": { + "post": { + "description": "`templateFor` names the type key this template starts an object of.", + "operationId": "create_template", + "parameters": [ + { + "description": "Space id", + "in": "path", + "name": "space_id", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "Validate and report without committing", + "in": "query", + "name": "dry_run", + "schema": { + "type": "boolean" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object" + } + } + } + }, + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateResult" + } + } + }, + "description": "Created template id" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Validation or reference failure" + } + }, + "security": [ + { + "bearerauth": [] + } + ], + "summary": "Create a template", + "tags": [ + "Templates" + ] + } + }, + "/v2/spaces/{space_id}/types": { + "get": { + "operationId": "list_types", + "parameters": [ + { + "description": "Space id", + "in": "path", + "name": "space_id", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ListResponse-TypeRow" + } + } + }, + "description": "Type rows" + } + }, + "security": [ + { + "bearerauth": [] + } + ], + "summary": "List the types in a space", + "tags": [ + "Types" + ] + }, + "post": { + "description": "A `typeProperties` entry naming a property key that does not exist creates that property alongside the type. The body is an AnyBlock document with kind \"objectType\".", + "operationId": "create_type", + "parameters": [ + { + "description": "Space id", + "in": "path", + "name": "space_id", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "Validate and report without committing", + "in": "query", + "name": "dry_run", + "schema": { + "type": "boolean" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object" + } + } + } + }, + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateResult" + } + } + }, + "description": "Created type id + key" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Validation failure" + } + }, + "security": [ + { + "bearerauth": [] + } + ], + "summary": "Create a type", + "tags": [ + "Types" + ] + } + }, + "/v2/spaces/{space_id}/types/{type}": { + "delete": { + "operationId": "delete_type", + "parameters": [ + { + "description": "Space id", + "in": "path", + "name": "space_id", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "Type key", + "in": "path", + "name": "type", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateResult" + } + } + }, + "description": "Archived type" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "No live type with this key. A type that is already deleted is a 404 too, not a second delete." + } + }, + "security": [ + { + "bearerauth": [] + } + ], + "summary": "Delete a type", + "tags": [ + "Types" + ] + }, + "get": { + "operationId": "get_type", + "parameters": [ + { + "description": "Space id", + "in": "path", + "name": "space_id", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "Type key", + "in": "path", + "name": "type", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "compact (default) is the edit shape, with short labels for minted view and block ids; full is the export shape, with full ids", + "in": "query", + "name": "ids", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "additionalProperties": {}, + "type": "object" + } + } + }, + "description": "The kind:objectType AnyBlock document + etag" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Type not found" + } + }, + "security": [ + { + "bearerauth": [] + } + ], + "summary": "Read a type as an AnyBlock document", + "tags": [ + "Types" + ] + }, + "patch": { + "description": "`typeProperties`, when present, replaces the recommended property lists rather than adding to them, and creates any property key that does not exist yet. `properties` changes the type's own fields; only name, description, icon_emoji and recommended_layout can change, and any other key is refused.", + "operationId": "update_type", + "parameters": [ + { + "description": "Space id", + "in": "path", + "name": "space_id", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "Type key", + "in": "path", + "name": "type", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object" + } + } + } + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateResult" + } + } + }, + "description": "Updated type" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Type not found" + } + }, + "security": [ + { + "bearerauth": [] + } + ], + "summary": "Update a type", + "tags": [ + "Types" + ] + } + }, + "/v2/spaces/{space_id}/types/{type}/schema": { + "get": { + "description": "Not implemented. Every request answers 501.", + "operationId": "get_type_schema", + "parameters": [ + { + "description": "Space id", + "in": "path", + "name": "space_id", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "Type key", + "in": "path", + "name": "type", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "501": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Not implemented yet" + } + }, + "security": [ + { + "bearerauth": [] + } + ], + "summary": "Get a JSON Schema for a type", + "tags": [ + "Types" + ] + } + }, + "/v2/validate": { + "post": { + "description": "Structure and format rules only. Nothing is resolved against a space, so option names and a type's property keys are not checked here. Findings come back as data: an invalid document is still a 200, carrying the issues, and a valid one carries empty lists.", + "operationId": "validate", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object" + } + } + } + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ValidateResponse" + } + } + }, + "description": "Issue and warning lists, empty when the document is valid" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + }, + "description": "Missing or invalid key. This is the shared auth envelope, not this API's error shape." + } + }, + "security": [ + { + "bearerauth": [] + } + ], + "summary": "Validate an AnyBlock document", + "tags": [ + "Schemas" + ] + } + } + }, + "openapi": "3.1.0", + "tags": [ + { + "description": "What the calling key may do. Ask this before discovering the limits through 403s.", + "name": "Auth" + }, + { + "description": "The containers everything else lives in. Nearly every other route is scoped to one.", + "name": "Spaces" + }, + { + "description": "Read and write whole AnyBlock documents: one GET returns an editable document, one PATCH edits it.", + "name": "Objects" + }, + { + "description": "Find objects by query, filter and sort, within one space or across all of them.", + "name": "Search" + }, + { + "description": "An object's shape: the properties it recommends and the views it opens with.", + "name": "Types" + }, + { + "description": "The typed key-value fields objects carry, and the option vocabularies select fields draw from.", + "name": "Properties" + }, + { + "description": "Sets (a live query over a type) and collections (a hand-curated list), with their views.", + "name": "Lists" + }, + { + "description": "Messages, reactions and read state. Chats store messages outside blocks, paged by order-id cursors.", + "name": "Chat" + }, + { + "description": "Who is in a space, and which of them you are.", + "name": "Members" + }, + { + "description": "Upload bytes and get the id that file blocks and chat attachments reference.", + "name": "Files" + }, + { + "description": "Starting documents for a type.", + "name": "Templates" + }, + { + "description": "The format itself: what a valid document looks like, what each PATCH op accepts, and a validator to check one against them. Read these before writing.", + "name": "Schemas" + } + ], + "servers": [ + { + "url": "http://127.0.0.1:31009" + } + ] +} diff --git a/core/api/docs/v2/openapi.yaml b/core/api/docs/v2/openapi.yaml new file mode 100644 index 0000000000..b5c53e9d6d --- /dev/null +++ b/core/api/docs/v2/openapi.yaml @@ -0,0 +1,2853 @@ +components: + schemas: + ForbiddenError: + properties: + code: + example: forbidden + type: string + message: + example: Forbidden + type: string + object: + example: error + type: string + status: + example: 403 + type: integer + type: object + UnauthorizedError: + properties: + code: + example: unauthorized + type: string + message: + example: Unauthorized + type: string + object: + example: error + type: string + status: + example: 401 + type: integer + type: object + AddChatMessageRequest: + properties: + attachments: + items: + type: string + type: array + uniqueItems: false + reply_to: + type: string + text: + type: string + type: object + ChatAttachment: + properties: + id: + type: string + type: + type: string + type: object + ChatMessage: + properties: + at: + type: string + attachments: + items: + $ref: '#/components/schemas/ChatAttachment' + type: array + uniqueItems: false + author: + type: string + author_id: + type: string + blocks_text: + type: string + edited_at: + type: string + id: + type: string + order: + type: string + pinned: + type: boolean + reacted_by: + additionalProperties: + items: + type: string + type: array + type: object + reactions: + additionalProperties: + type: integer + type: object + reply_to: + type: string + text: + type: string + type: object + ChatMessageResult: + properties: + dry_run: + type: boolean + id: + type: string + warnings: + items: + $ref: '#/components/schemas/Issue' + type: array + uniqueItems: false + type: object + ChatMessagesResponse: + properties: + has_more: + description: more messages inside the requested bounds, not in the chat + as a whole + type: boolean + message_count: + type: integer + messages: + items: + $ref: '#/components/schemas/ChatMessage' + type: array + uniqueItems: false + next_after: + type: string + next_before: + type: string + state: + $ref: '#/components/schemas/ChatState' + type: object + ChatReactionRequest: + properties: + emoji: + type: string + type: object + ChatReactionResult: + properties: + added: + type: boolean + dry_run: + type: boolean + warnings: + items: + $ref: '#/components/schemas/Issue' + type: array + uniqueItems: false + type: object + ChatReadRequest: + properties: + last_state_id: + type: string + scope: + type: string + up_to: + type: string + type: object + ChatReadResult: + properties: + dry_run: + type: boolean + type: object + ChatResult: + properties: + dry_run: + type: boolean + id: + type: string + name: + type: string + type: object + ChatRow: + properties: + id: + type: string + name: + type: string + type: object + ChatState: + properties: + last_state_id: + type: string + oldest_unread_mention_order: + type: string + oldest_unread_order: + type: string + unread_mentions: + type: integer + unread_messages: + type: integer + unread_reaction_order: + type: string + type: object + CreateChatRequest: + properties: + name: + type: string + type: object + CreateResult: + properties: + created: + $ref: '#/components/schemas/SideEffects' + dry_run: + type: boolean + etag: + description: etag of the created object + type: string + id: + type: string + issues: + items: + $ref: '#/components/schemas/Issue' + type: array + uniqueItems: false + key: + description: identity key (types, properties) + type: string + type: + description: type key of the created object + type: string + warnings: + items: + $ref: '#/components/schemas/Issue' + type: array + uniqueItems: false + type: object + CreateSpaceRequest: + properties: + description: + type: string + name: + type: string + type: object + CreatedOption: + properties: + name: + type: string + property: + description: property key + type: string + type: object + DiffStats: + properties: + blocks_added: + type: integer + blocks_changed: + type: integer + blocks_moved: + type: integer + blocks_removed: + type: integer + properties_changed: + type: integer + type: object + EditChatMessageRequest: + properties: + text: + type: string + type: object + EditResult: + properties: + created: + $ref: '#/components/schemas/SideEffects' + created_blocks: + additionalProperties: + type: string + description: |- + CreatedBlocks maps each payload position that created a block to the + id the server minted for it: the top-level run positions + ("ops[3].blocks[0]") and the nested slots alike, such as a table's + rows and columns ("ops[3].blocks[0].rows[1]") and the blocks inside a + cell run ("ops[3].value[1]"). A position that carried an id is + absent, because the block it names already existed. + type: object + created_views: + additionalProperties: + type: string + description: |- + CreatedViews maps each payload position that created a dataview view + to the view id the server minted: an insert_view op ("ops[i]"), or a + view slot of an update_block set channel ("ops[i].set.views[2]"). + View ids are always server-minted, and a view is not a block, so they + are reported here rather than in CreatedBlocks. + type: object + diff_stats: + $ref: '#/components/schemas/DiffStats' + dry_run: + type: boolean + etag: + type: string + warnings: + items: + $ref: '#/components/schemas/Issue' + type: array + uniqueItems: false + type: object + Error: + properties: + code: + type: string + issues: + items: + $ref: '#/components/schemas/Issue' + type: array + uniqueItems: false + message: + type: string + status: + type: integer + type: object + FileUploadResult: + properties: + dry_run: + type: boolean + id: + type: string + mime_type: + type: string + name: + type: string + size: + type: integer + type: object + Issue: + properties: + hint: + type: string + message: + type: string + path: + type: string + type: object + ListResponse-ChatRow: + properties: + data: + items: + $ref: '#/components/schemas/ChatRow' + type: array + uniqueItems: false + has_more: + type: boolean + limit: + type: integer + message: + type: string + offset: + type: integer + total: + type: integer + warnings: + items: + $ref: '#/components/schemas/Issue' + type: array + uniqueItems: false + type: object + ListResponse-MemberRow: + properties: + data: + items: + $ref: '#/components/schemas/MemberRow' + type: array + uniqueItems: false + has_more: + type: boolean + limit: + type: integer + message: + type: string + offset: + type: integer + total: + type: integer + warnings: + items: + $ref: '#/components/schemas/Issue' + type: array + uniqueItems: false + type: object + ListResponse-ObjectRow: + properties: + data: + items: + $ref: '#/components/schemas/ObjectRow' + type: array + uniqueItems: false + has_more: + type: boolean + limit: + type: integer + message: + type: string + offset: + type: integer + total: + type: integer + warnings: + items: + $ref: '#/components/schemas/Issue' + type: array + uniqueItems: false + type: object + ListResponse-OptionRow: + properties: + data: + items: + $ref: '#/components/schemas/OptionRow' + type: array + uniqueItems: false + has_more: + type: boolean + limit: + type: integer + message: + type: string + offset: + type: integer + total: + type: integer + warnings: + items: + $ref: '#/components/schemas/Issue' + type: array + uniqueItems: false + type: object + ListResponse-PropertyRow: + properties: + data: + items: + $ref: '#/components/schemas/PropertyRow' + type: array + uniqueItems: false + has_more: + type: boolean + limit: + type: integer + message: + type: string + offset: + type: integer + total: + type: integer + warnings: + items: + $ref: '#/components/schemas/Issue' + type: array + uniqueItems: false + type: object + ListResponse-SpaceRow: + properties: + data: + items: + $ref: '#/components/schemas/SpaceRow' + type: array + uniqueItems: false + has_more: + type: boolean + limit: + type: integer + message: + type: string + offset: + type: integer + total: + type: integer + warnings: + items: + $ref: '#/components/schemas/Issue' + type: array + uniqueItems: false + type: object + ListResponse-TypeRow: + properties: + data: + items: + $ref: '#/components/schemas/TypeRow' + type: array + uniqueItems: false + has_more: + type: boolean + limit: + type: integer + message: + type: string + offset: + type: integer + total: + type: integer + warnings: + items: + $ref: '#/components/schemas/Issue' + type: array + uniqueItems: false + type: object + ListResponse-ViewObject: + properties: + data: + items: + $ref: '#/components/schemas/ViewObject' + type: array + uniqueItems: false + has_more: + type: boolean + limit: + type: integer + message: + type: string + offset: + type: integer + total: + type: integer + warnings: + items: + $ref: '#/components/schemas/Issue' + type: array + uniqueItems: false + type: object + MemberRow: + properties: + id: + type: string + identity: + type: string + name: + type: string + role: + type: string + type: object + ObjectRow: + properties: + id: + type: string + name: + type: string + properties: + additionalProperties: {} + type: object + space_id: + type: string + type: + type: string + type: object + OptionRow: + properties: + color: + type: string + name: + type: string + type: object + PropertyRow: + properties: + format: + type: string + key: + type: string + name: + type: string + type: object + SchemaEntry: + properties: + endpoint: + type: string + example: + type: object + grammar: + type: string + grammar_examples: + items: + type: string + type: array + uniqueItems: false + kind: + type: string + schema: + type: object + type: object + SchemaIndex: + properties: + kinds: + items: + $ref: '#/components/schemas/SchemaIndexEntry' + type: array + uniqueItems: false + ops: + items: + $ref: '#/components/schemas/SchemaIndexEntry' + type: array + uniqueItems: false + type: object + SchemaIndexEntry: + properties: + endpoint: + type: string + kind: + type: string + url: + type: string + type: object + SearchRequestDoc: + properties: + fields: + items: + type: string + type: array + uniqueItems: false + filter: + type: string + filters: + items: + additionalProperties: {} + type: object + type: array + uniqueItems: false + query: + type: string + sorts: + items: + additionalProperties: {} + type: object + type: array + uniqueItems: false + type: + type: string + type: object + SideEffects: + properties: + options: + items: + $ref: '#/components/schemas/CreatedOption' + type: array + uniqueItems: false + properties: + items: + $ref: '#/components/schemas/PropertyRow' + type: array + uniqueItems: false + type: object + Space: + properties: + description: + type: string + dry_run: + type: boolean + id: + type: string + name: + type: string + type: object + SpaceRow: + properties: + description: + type: string + id: + description: |- + Id is the space's short reference: the last six characters of the + first half of its id. It is the full id instead when that tail is + shared with another visible space, or when the request asked for + `?ids=full`. Either spelling is accepted back on every route that + takes a space. + type: string + name: + type: string + type: object + TypeRow: + properties: + key: + type: string + name: + type: string + type: object + UpdateSpaceRequest: + properties: + description: + type: string + name: + type: string + type: object + ValidateResponse: + properties: + issues: + items: + $ref: '#/components/schemas/Issue' + type: array + uniqueItems: false + warnings: + items: + $ref: '#/components/schemas/Issue' + type: array + uniqueItems: false + type: object + ViewObject: + additionalProperties: {} + type: object + WhoamiApi: + properties: + version: + type: string + type: object + WhoamiGrant: + properties: + permission: + description: the compact form agents string-match on + type: string + scoped: + type: boolean + spaces: + items: + $ref: '#/components/schemas/WhoamiGrantSpace' + type: array + uniqueItems: false + type: object + WhoamiGrantSpace: + properties: + id: + type: string + name: + type: string + permission: + type: string + type: object + WhoamiKey: + properties: + created_at: + type: string + expires_at: + type: string + id: + description: the app link's hash, which is the id the key list shows + type: string + name: + type: string + type: object + WhoamiResponse: + properties: + api: + $ref: '#/components/schemas/WhoamiApi' + grant: + $ref: '#/components/schemas/WhoamiGrant' + key: + $ref: '#/components/schemas/WhoamiKey' + key_status: + description: '"legacy" | "scoped", always present' + type: string + notice: + description: the legacy sentence, verbatim printable + type: string + scope: + description: '"jsonApi" | "full" | "limited"' + type: string + type: object + securitySchemes: + bearerauth: + bearerFormat: JWT + scheme: bearer + type: http +externalDocs: + description: OpenAPI + url: https://swagger.io/resources/open-api/ +info: + contact: + email: support@anytype.io + name: Anytype Support + url: https://anytype.io/contact + description: |- + The agent-oriented Anytype local API. An object is one AnyBlock JSON document rather than a tree of blocks, so a single GET returns a whole editable document and a single PATCH edits it. + Everything this API names for itself is snake_case: path and query parameters, request and response fields, and the names of the PATCH ops. Things are addressed by name rather than by id: property keys, option names, and `type` as a type key. Inside an object's `blocks` and `properties` you are reading the AnyBlock format's own vocabulary, which passes through unchanged. + Responses are compact. A list or search row carries id, name, type and the properties you asked for, and never embeds a type object. Object references are always full and inline. An object read relabels machine-minted block ids to short document-local suffixes; `?ids=full` returns the export shape, with full ids everywhere, which is the shape to store and the shape to clone from. + Wherever a block or a view is addressed by id, a full id or a unique suffix of one is accepted. That is what lets a document read back in the compact shape be edited exactly as it came back. A suffix that matches several elements is refused, and the refusal lists the candidates. + A read never fails on content it cannot represent. Whatever a representation cannot express is reported in `warnings` beside the result. + Every error has one shape: {status, code, message, issues:[{path, message, hint}]}. Each issue is addressed by path and names the values that would have been accepted, so a failed call tells you how to repair it. + Authentication is a bearer token in the Authorization header. It is never read from a query or body parameter. An unknown, revoked or expired key is a 401. + An object read returns an `etag` in the body and an ETag header. A mutation takes that etag back in `If-Match`, where it is advisory: without the header the last write wins, and a stale one is a 409 carrying the current etag. Chats are the exception. They have no etag, because their order ids and `last_state_id` do that job. + Every mutation accepts an `Idempotency-Key` header. The same key with the same body replays the stored response instead of repeating the write. Search is a read carried by POST and takes no key. + Every mutation accepts `?dry_run=true`. It validates the request, reports what would have happened and writes nothing, answering 200 where the real call would answer 201. Where a dry run cannot tell the whole truth, the operation says so. + Every list is paginated with `?offset=` and `?limit=`, 25 rows by default. The response carries `total`, `has_more` and, when it truncated, a hint for narrowing the request. Chat messages page by order-id cursor instead. + Request bodies bind strictly: an unknown field is a 400 naming the field, never a value silently dropped. A document body is capped at 10 MiB, a structured body at 1 MiB. + Deleting an object, a type or a property archives it: it moves to Bin, and the Anytype app can restore it. Deleting a chat message is not an archive, and neither is the attachment cleanup that can follow it. + Schemas are discoverable at runtime, and strict enough to decode against: GET /v2/schemas lists the kinds, GET /v2/schemas/{kind} returns one, and GET /v2/schemas/ops/{op} returns the schema of a single edit op. + A space is served by a short reference: the last six characters of the first half of its id. Every route that takes a space accepts either that short reference or the full `.` id. Resolution tries an exact id first, then a unique suffix, among the spaces the key can see. An ambiguous reference is a 400 listing the candidates, and two spaces whose tails collide are both served in full. + A short reference is an addressing convenience, not a stable identifier. It is unique only against the spaces the key can currently see, so joining a space whose tail collides retires it. `?ids=full` spells every space id in the response out in full. Use it whenever a reference will be stored outside this API: a config file, a script, a log line, another system. + license: + name: Any Source Available License 1.0 + url: https://github.com/anyproto/anytype-api/blob/main/LICENSE.md + termsOfService: https://anytype.io/terms_of_use + title: Anytype API v2 + version: "2025-11-08" +openapi: 3.1.0 +paths: + /v2/auth/whoami: + get: + description: This describes the key, not a person; there is one account behind + this API. Branch on `grant.scoped`. False is a legacy key with no space restriction, + and its `spaces` list is empty rather than absent. True means the key reaches + exactly the spaces listed, with the permission listed beside each one. + operationId: auth_whoami + parameters: + - description: 'How grant.spaces[].id is spelled: compact (default) is the short + space reference; full is the whole . id, and the spelling + to store outside this API' + in: query + name: ids + schema: + type: string + responses: + "200": + content: + application/json: + schema: + $ref: '#/components/schemas/WhoamiResponse' + description: The key's grant, as it is enforced + "401": + content: + application/json: + schema: + $ref: '#/components/schemas/UnauthorizedError' + description: Missing, unknown, revoked or expired key. This is the shared + auth envelope, not this API's error shape. + "403": + content: + application/json: + schema: + $ref: '#/components/schemas/ForbiddenError' + description: The key's scope does not admit this API. This is the shared + scope gate's envelope. + security: + - bearerauth: [] + summary: Describe the calling key + tags: + - Auth + /v2/schemas: + get: + operationId: list_schemas + responses: + "200": + content: + application/json: + schema: + $ref: '#/components/schemas/SchemaIndex' + description: Schema index + security: + - bearerauth: [] + summary: List the available schemas + tags: + - Schemas + /v2/schemas/{kind}: + get: + operationId: get_schema + parameters: + - description: Schema kind, as listed by GET /v2/schemas + in: path + name: kind + required: true + schema: + type: string + responses: + "200": + content: + application/json: + schema: + $ref: '#/components/schemas/SchemaEntry' + description: Schema + example + "404": + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + description: Unknown kind + security: + - bearerauth: [] + summary: Get the schema for one kind + tags: + - Schemas + /v2/schemas/ops/{op}: + get: + description: The example is a single op object, ready to drop into an edit request's + `ops` array, not a whole request body. + operationId: get_op_schema + parameters: + - description: 'Op name: set_properties, update_block, replace_subtree, insert_blocks, + move_block, delete_block, replace_text, set_cell, update_view, insert_view, + move_view, delete_view, add_items, remove_items' + in: path + name: op + required: true + schema: + type: string + responses: + "200": + content: + application/json: + schema: + $ref: '#/components/schemas/SchemaEntry' + description: Schema + example + "404": + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + description: Unknown op + security: + - bearerauth: [] + summary: Get the schema for one edit op + tags: + - Schemas + /v2/search: + post: + description: Type keys and option names are resolved per space. A name that + resolves in only some spaces searches those and warns about the rest. `total` + is the sum of the per-space counts, and each row carries its `space_id`. + operationId: search_global + parameters: + - description: Items to skip + in: query + name: offset + schema: + default: 0 + type: integer + - description: Items to return + in: query + name: limit + schema: + default: 25 + type: integer + - description: 'How each row''s space_id is spelled: compact (default) is the + short space reference; full is the whole . id, and + the spelling to store outside this API' + in: query + name: ids + schema: + type: string + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/SearchRequestDoc' + description: Search request + required: true + responses: + "200": + content: + application/json: + schema: + $ref: '#/components/schemas/ListResponse-ObjectRow' + description: Minimal object rows with space_id + "400": + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + description: Invalid request + security: + - bearerauth: [] + summary: Search every space + tags: + - Search + /v2/spaces: + get: + description: Only live spaces are listed. A space that is deleted, left, or + still joining does not appear. + operationId: list_spaces + parameters: + - description: compact (default) is the short space reference; full is the whole + . id, and the spelling to store outside this API + in: query + name: ids + schema: + type: string + responses: + "200": + content: + application/json: + schema: + $ref: '#/components/schemas/ListResponse-SpaceRow' + description: Minimal space rows + security: + - bearerauth: [] + summary: List the account's spaces + tags: + - Spaces + post: + description: A retry the server already handled makes a second space unless + it carries the same idempotency key. A dry run validates the body and stops + there; creating a space cannot be simulated. + operationId: create_space + parameters: + - description: Validate the body without creating + in: query + name: dry_run + schema: + type: boolean + - description: compact (default) is the short space reference; full is the whole + . id of the new space, and the spelling to store outside + this API + in: query + name: ids + schema: + type: string + - description: 'Replay guard: the same key with the same body replays the stored + response' + in: header + name: Idempotency-Key + schema: + type: string + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/CreateSpaceRequest' + description: The space to create + required: true + responses: + "201": + content: + application/json: + schema: + $ref: '#/components/schemas/Space' + description: Created space + "400": + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + description: Validation failure + security: + - bearerauth: [] + summary: Create a space + tags: + - Spaces + /v2/spaces/{space_id}: + get: + description: Only live spaces are served. A space that is deleted, left, or + still joining is a 404. + operationId: get_space + parameters: + - description: Space id + in: path + name: space_id + required: true + schema: + type: string + - description: compact (default) is the short space reference; full is the whole + . id, and the spelling to store outside this API + in: query + name: ids + schema: + type: string + responses: + "200": + content: + application/json: + schema: + $ref: '#/components/schemas/Space' + description: The space row + "404": + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + description: Space not found + security: + - bearerauth: [] + summary: Get one space + tags: + - Spaces + patch: + description: At least one of the two fields must be present; a field left out + keeps its current value. + operationId: update_space + parameters: + - description: Space id + in: path + name: space_id + required: true + schema: + type: string + - description: Validate and report without committing + in: query + name: dry_run + schema: + type: boolean + - description: compact (default) is the short space reference; full is the whole + . id, and the spelling to store outside this API + in: query + name: ids + schema: + type: string + - description: 'Replay guard: the same key with the same body replays the stored + response' + in: header + name: Idempotency-Key + schema: + type: string + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/UpdateSpaceRequest' + description: The fields to change + required: true + responses: + "200": + content: + application/json: + schema: + $ref: '#/components/schemas/Space' + description: The updated space row + "400": + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + description: Validation failure + "403": + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + description: The caller's role cannot change the space info + "404": + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + description: Space not found or not live + security: + - bearerauth: [] + summary: Update a space + tags: + - Spaces + /v2/spaces/{space_id}/chats: + get: + description: A row carries no unread counters. Per-chat unread state comes back + with the messages read instead. + operationId: list_chats + parameters: + - description: Space id + in: path + name: space_id + required: true + schema: + type: string + - description: Rows to skip + in: query + name: offset + schema: + default: 0 + type: integer + - description: Rows to return + in: query + name: limit + schema: + default: 25 + type: integer + responses: + "200": + content: + application/json: + schema: + $ref: '#/components/schemas/ListResponse-ChatRow' + description: Chat rows + "404": + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + description: Space not found + security: + - bearerauth: [] + summary: List the chats in a space + tags: + - Chat + post: + description: Messages are not blocks. Add them through the messages route; a + document edit cannot reach them. + operationId: create_chat + parameters: + - description: Space id + in: path + name: space_id + required: true + schema: + type: string + - description: Validate and report without committing + in: query + name: dry_run + schema: + type: boolean + - description: 'Replay guard: the same key with the same body replays the stored + response' + in: header + name: Idempotency-Key + schema: + type: string + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/CreateChatRequest' + description: The chat to create + required: true + responses: + "201": + content: + application/json: + schema: + $ref: '#/components/schemas/ChatResult' + description: Created chat row + "400": + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + description: Validation failure + security: + - bearerauth: [] + summary: Create a chat + tags: + - Chat + /v2/spaces/{space_id}/chats/{chat_id}/messages: + get: + description: '`after` on its own walks forward, oldest first, continuing from + `next_after`. Every other query, including `after` together with `before`, + is anchored at the newest end of the range and walks backward from `next_before`. + Both bounds are exclusive. `message_count` is the chat''s total since it began, + not the size of the range. Offset paging does not apply here, and `offset` + is refused.' + operationId: get_chat_messages + parameters: + - description: Space id + in: path + name: space_id + required: true + schema: + type: string + - description: Chat object id + in: path + name: chat_id + required: true + schema: + type: string + - description: Return messages after this order id (exclusive) + in: query + name: after + schema: + type: string + - description: Return messages before this order id (exclusive) + in: query + name: before + schema: + type: string + - description: Messages to return + in: query + name: limit + schema: + default: 25 + type: integer + - description: counts (default) returns the emoji counts; full adds the participant + ids behind each count + in: query + name: reactions + schema: + type: string + responses: + "200": + content: + application/json: + schema: + $ref: '#/components/schemas/ChatMessagesResponse' + description: Messages + state + message_count + "400": + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + description: Not a chat, or invalid params + "404": + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + description: Chat not found + security: + - bearerauth: [] + summary: List chat messages + tags: + - Chat + post: + description: The text is markup source, so `*`, `[` and a mention tag mint real + marks; escape a literal one with a backslash. The cap is 8000 UTF-16 code + units, where one emoji can cost two or more. Attachments are object ids, at + most 32, and each one's kind is taken from the target's layout. + operationId: add_chat_message + parameters: + - description: Space id + in: path + name: space_id + required: true + schema: + type: string + - description: Chat object id + in: path + name: chat_id + required: true + schema: + type: string + - description: Validate and report without committing + in: query + name: dry_run + schema: + type: boolean + - description: 'Replay guard: the same key with the same body replays the stored + response' + in: header + name: Idempotency-Key + schema: + type: string + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/AddChatMessageRequest' + description: The message to send + required: true + responses: + "201": + content: + application/json: + schema: + $ref: '#/components/schemas/ChatMessageResult' + description: Created message id + "400": + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + description: Validation failure + "404": + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + description: Chat not found + security: + - bearerauth: [] + summary: Send a chat message + tags: + - Chat + /v2/spaces/{space_id}/chats/{chat_id}/messages/{message_id}: + delete: + description: An attachment whose only reference was this message is erased for + good afterwards, not moved to Bin. The response names those ids in `warnings`, + and a dry run reports the same list without deleting anything. A message that + does not exist is a 404 on the dry run too. + operationId: delete_chat_message + parameters: + - description: Space id + in: path + name: space_id + required: true + schema: + type: string + - description: Chat object id + in: path + name: chat_id + required: true + schema: + type: string + - description: Message id + in: path + name: message_id + required: true + schema: + type: string + - description: Report what would be deleted, attachments included, without committing + in: query + name: dry_run + schema: + type: boolean + - description: 'Replay guard: the same key with the same body replays the stored + response' + in: header + name: Idempotency-Key + schema: + type: string + responses: + "200": + content: + application/json: + schema: + $ref: '#/components/schemas/ChatMessageResult' + description: Deleted message id + "404": + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + description: Chat or message not found + security: + - bearerauth: [] + summary: Delete a chat message + tags: + - Chat + patch: + description: Every mark is re-derived from the text you send, so a mark the + old text carried and the new text does not spell out is lost. Attachments, + the reply target and the style survive. Editing another member's message is + a 403. + operationId: edit_chat_message + parameters: + - description: Space id + in: path + name: space_id + required: true + schema: + type: string + - description: Chat object id + in: path + name: chat_id + required: true + schema: + type: string + - description: Message id + in: path + name: message_id + required: true + schema: + type: string + - description: Validate and report without committing + in: query + name: dry_run + schema: + type: boolean + - description: 'Replay guard: the same key with the same body replays the stored + response' + in: header + name: Idempotency-Key + schema: + type: string + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/EditChatMessageRequest' + description: The replacement text + required: true + responses: + "200": + content: + application/json: + schema: + $ref: '#/components/schemas/ChatMessageResult' + description: Edited message id + "400": + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + description: Validation failure + "404": + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + description: Chat or message not found + security: + - bearerauth: [] + summary: Replace a chat message's text + tags: + - Chat + /v2/spaces/{space_id}/chats/{chat_id}/messages/{message_id}/reactions: + post: + description: '`added` says which way the toggle went. A dry run predicts it, + but when there is no account identity to predict with it omits the field and + says so in `warnings`.' + operationId: toggle_chat_reaction + parameters: + - description: Space id + in: path + name: space_id + required: true + schema: + type: string + - description: Chat object id + in: path + name: chat_id + required: true + schema: + type: string + - description: Message id + in: path + name: message_id + required: true + schema: + type: string + - description: Report the would-be outcome without committing + in: query + name: dry_run + schema: + type: boolean + - description: 'Replay guard: the same key with the same body replays the stored + response' + in: header + name: Idempotency-Key + schema: + type: string + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/ChatReactionRequest' + description: The emoji to toggle + required: true + responses: + "200": + content: + application/json: + schema: + $ref: '#/components/schemas/ChatReactionResult' + description: Toggle outcome + "400": + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + description: Validation failure + "404": + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + description: Chat or message not found + security: + - bearerauth: [] + summary: Toggle a reaction on a chat message + tags: + - Chat + /v2/spaces/{space_id}/chats/{chat_id}/read: + post: + description: '`up_to` is inclusive, and it and `last_state_id` both come from + one messages read: the newest message''s order, and the state''s own id. An + empty value for either would silently mark nothing, so it is refused. Messages + that arrived after that state stay unread. The reactions scope marks every + unread reaction and takes neither field.' + operationId: read_chat + parameters: + - description: Space id + in: path + name: space_id + required: true + schema: + type: string + - description: Chat object id + in: path + name: chat_id + required: true + schema: + type: string + - description: Validate and report without committing + in: query + name: dry_run + schema: + type: boolean + - description: 'Replay guard: the same key with the same body replays the stored + response' + in: header + name: Idempotency-Key + schema: + type: string + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/ChatReadRequest' + description: The watermark move + required: true + responses: + "200": + content: + application/json: + schema: + $ref: '#/components/schemas/ChatReadResult' + description: Watermark moved + "400": + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + description: Validation failure + "404": + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + description: Chat not found + security: + - bearerauth: [] + summary: Move a chat's read watermark + tags: + - Chat + /v2/spaces/{space_id}/collections: + post: + description: Item ids are checked against the space; an id that does not resolve + there is refused rather than dropped. + operationId: create_collection + parameters: + - description: Space id + in: path + name: space_id + required: true + schema: + type: string + - description: Validate and report without committing + in: query + name: dry_run + schema: + type: boolean + requestBody: + content: + application/json: + schema: + type: object + responses: + "201": + content: + application/json: + schema: + $ref: '#/components/schemas/CreateResult' + description: Created collection id + "400": + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + description: Validation or reference failure + "413": + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + description: Request body exceeds the 1 MiB cap + security: + - bearerauth: [] + summary: Create a collection + tags: + - Lists + /v2/spaces/{space_id}/collections/{collection_id}/objects: + get: + description: Members come back in the order the collection stores them, not + sorted, unless a view is applied. + operationId: get_collection_objects + parameters: + - description: Space id + in: path + name: space_id + required: true + schema: + type: string + - description: Collection object id + in: path + name: collection_id + required: true + schema: + type: string + - description: Stored view id (exact or unique suffix) + in: query + name: view + schema: + type: string + - description: Comma-separated property keys to include per row + in: query + name: fields + schema: + type: string + - description: Items to skip + in: query + name: offset + schema: + default: 0 + type: integer + - description: Items to return + in: query + name: limit + schema: + default: 25 + type: integer + responses: + "200": + content: + application/json: + schema: + $ref: '#/components/schemas/ListResponse-ObjectRow' + description: Minimal object rows + "400": + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + description: Wrong-layout target or invalid params + "404": + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + description: Space, collection or view not found + security: + - bearerauth: [] + summary: List a collection's objects + tags: + - Lists + /v2/spaces/{space_id}/collections/{collection_id}/views: + get: + operationId: get_collection_views + parameters: + - description: Space id + in: path + name: space_id + required: true + schema: + type: string + - description: Collection object id + in: path + name: collection_id + required: true + schema: + type: string + - description: Items to skip + in: query + name: offset + schema: + default: 0 + type: integer + - description: Items to return + in: query + name: limit + schema: + default: 25 + type: integer + responses: + "200": + content: + application/json: + schema: + $ref: '#/components/schemas/ListResponse-ViewObject' + description: The stored views, with their sorts, filters and columns + "400": + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + description: Wrong-layout target + "404": + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + description: Space or collection not found + security: + - bearerauth: [] + summary: List a collection's views + tags: + - Lists + /v2/spaces/{space_id}/files: + post: + description: 'Send multipart/form-data with a `file` field, or JSON {"url": + …}. A source that refuses the fetch, or a URL that cannot be fetched, is a + 400 naming /url; only a genuine server fault answers 500. The id that comes + back is the one file blocks, image blocks and icon_image values reference.' + operationId: upload_file + parameters: + - description: Space id + in: path + name: space_id + required: true + schema: + type: string + - description: Validate and report without committing + in: query + name: dry_run + schema: + type: boolean + requestBody: + content: + multipart/form-data: + schema: + type: object + responses: + "201": + content: + application/json: + schema: + $ref: '#/components/schemas/FileUploadResult' + description: Created file object id + "400": + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + description: Validation failure, or a source URL that did not yield the + file + "413": + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + description: JSON request body exceeds the 1 MiB cap + security: + - bearerauth: [] + summary: Upload a file + tags: + - Files + /v2/spaces/{space_id}/members: + get: + operationId: list_members + parameters: + - description: Space id + in: path + name: space_id + required: true + schema: + type: string + responses: + "200": + content: + application/json: + schema: + $ref: '#/components/schemas/ListResponse-MemberRow' + description: Minimal member rows + security: + - bearerauth: [] + summary: List the members of a space + tags: + - Members + /v2/spaces/{space_id}/members/me: + get: + description: The identity is taken from the account this API runs against; there + is no member id to send. + operationId: get_member_me + parameters: + - description: Space id + in: path + name: space_id + required: true + schema: + type: string + responses: + "200": + content: + application/json: + schema: + $ref: '#/components/schemas/MemberRow' + description: The caller's member row + "404": + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + description: Space not found, or no account identity + security: + - bearerauth: [] + summary: Get the calling member + tags: + - Members + /v2/spaces/{space_id}/objects: + get: + operationId: list_objects + parameters: + - description: Space id + in: path + name: space_id + required: true + schema: + type: string + - description: Comma-separated property keys to include per row + in: query + name: fields + schema: + type: string + - description: Items to skip + in: query + name: offset + schema: + default: 0 + type: integer + - description: Items to return + in: query + name: limit + schema: + default: 25 + type: integer + responses: + "200": + content: + application/json: + schema: + $ref: '#/components/schemas/ListResponse-ObjectRow' + description: Minimal object rows + security: + - bearerauth: [] + summary: List the objects in a space + tags: + - Objects + post: + description: A select value naming an option that does not exist creates that + option in the space. An unknown type or property key is rejected instead, + with the closest matches named. The body is either a full AnyBlock document + or the shortcut {type, name, properties, markdown}; `version` or `blocks` + picks the document form. + operationId: create_object + parameters: + - description: Space id + in: path + name: space_id + required: true + schema: + type: string + - description: Validate and report without committing + in: query + name: dry_run + schema: + type: boolean + requestBody: + content: + application/json: + schema: + type: object + responses: + "201": + content: + application/json: + schema: + $ref: '#/components/schemas/CreateResult' + description: Created object id + etag + "400": + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + description: Validation or reference failure + security: + - bearerauth: [] + summary: Create an object + tags: + - Objects + /v2/spaces/{space_id}/objects/{object_id}: + delete: + description: Only objects this key created can be deleted. The creator is recorded + at creation time and never added later, so objects made in the app, imported, + made by another member, or made before this route shipped are refused for + good. System objects are a 403 as well. A dry run reports the verdict without + the checks that run at archive time, so a deletable verdict can still meet + a 403. + operationId: delete_object + parameters: + - description: Space id + in: path + name: space_id + required: true + schema: + type: string + - description: Object id + in: path + name: object_id + required: true + schema: + type: string + - description: Probe deletability without writing + in: query + name: dry_run + schema: + type: boolean + responses: + "200": + content: + application/json: + schema: + $ref: '#/components/schemas/CreateResult' + description: Archived object, or the dry-run verdict. Deleting again is + a 200 carrying a warning. + "400": + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + description: 'A type or a property: use their own delete routes' + "403": + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + description: not_created_by_this_key, naming the recorded creator or its + absence + "404": + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + description: Object or space not found + security: + - bearerauth: [] + summary: Delete an object this key created + tags: + - Objects + get: + description: A `block` subtree comes back flagged as a subtree, and no write + path accepts that partial body. `format=md` is read-only; markdown cannot + be sent back. + operationId: get_object + parameters: + - description: Space id + in: path + name: space_id + required: true + schema: + type: string + - description: Object id + in: path + name: object_id + required: true + schema: + type: string + - description: Subset of properties,blocks (default both) + in: query + name: include + schema: + type: string + - description: Return the block skeleton instead of full blocks + in: query + name: outline + schema: + type: boolean + - description: Return only this block's subtree + in: query + name: block + schema: + type: string + - description: compact (default) is the edit shape, where minted block ids relabel + to short suffixes; full is the export shape, with full ids everywhere, and + the shape to send back. Object references are full and inline in both. + in: query + name: ids + schema: + type: string + - description: anyblock (default) or md + in: query + name: format + schema: + type: string + responses: + "200": + content: + application/json: + schema: + additionalProperties: {} + type: object + description: The flat AnyBlock document + etag + "400": + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + description: Illegal parameter combination (ambiguous_input) + "404": + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + description: Object or space not found + security: + - bearerauth: [] + summary: Read an object as an AnyBlock document + tags: + - Objects + patch: + description: Ops apply in order as one change set. If one fails, or the result + breaks the format's rules, none of them land. `update_block`, `delete_block` + and `replace_text` can address a block by its exact text instead of an id; + text matching zero or several blocks is refused, not guessed at. A later op + sees the earlier ones' edits. Ops that only create take no id; the new ids + come back in `created_blocks`. + operationId: patch_object + parameters: + - description: Space id + in: path + name: space_id + required: true + schema: + type: string + - description: Object id + in: path + name: object_id + required: true + schema: + type: string + - description: The etag the object must still carry + in: header + name: If-Match + schema: + type: string + - description: Validate and report without committing + in: query + name: dry_run + schema: + type: boolean + requestBody: + content: + application/json: + schema: + type: object + responses: + "200": + content: + application/json: + schema: + $ref: '#/components/schemas/EditResult' + description: New etag + created block ids + diff_stats + "400": + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + description: Invalid ops or post-op document + "404": + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + description: Object, space, or referenced block not found + "409": + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + description: Stale If-Match (etag_mismatch) + security: + - bearerauth: [] + summary: Edit an object with a batch of ops + tags: + - Objects + /v2/spaces/{space_id}/properties: + get: + operationId: list_properties + parameters: + - description: Space id + in: path + name: space_id + required: true + schema: + type: string + responses: + "200": + content: + application/json: + schema: + $ref: '#/components/schemas/ListResponse-PropertyRow' + description: Property rows + security: + - bearerauth: [] + summary: List the properties in a space + tags: + - Properties + post: + operationId: create_property + parameters: + - description: Space id + in: path + name: space_id + required: true + schema: + type: string + - description: Validate and report without committing + in: query + name: dry_run + schema: + type: boolean + requestBody: + content: + application/json: + schema: + type: object + responses: + "201": + content: + application/json: + schema: + $ref: '#/components/schemas/CreateResult' + description: Created property id + key + "400": + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + description: Validation failure + "413": + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + description: Request body exceeds the 1 MiB cap + security: + - bearerauth: [] + summary: Create a property + tags: + - Properties + /v2/spaces/{space_id}/properties/{key}: + delete: + operationId: delete_property + parameters: + - description: Space id + in: path + name: space_id + required: true + schema: + type: string + - description: Property key + in: path + name: key + required: true + schema: + type: string + responses: + "200": + content: + application/json: + schema: + $ref: '#/components/schemas/CreateResult' + description: Archived property + "404": + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + description: No live property with this key. A property that is already + deleted is a 404 too, not a second delete. + security: + - bearerauth: [] + summary: Delete a property + tags: + - Properties + patch: + description: Only the display name can change. The key is the property's identity, + and its format is fixed once it exists. + operationId: update_property + parameters: + - description: Space id + in: path + name: space_id + required: true + schema: + type: string + - description: Property key + in: path + name: key + required: true + schema: + type: string + requestBody: + content: + application/json: + schema: + type: object + responses: + "200": + content: + application/json: + schema: + $ref: '#/components/schemas/CreateResult' + description: Updated property + "400": + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + description: Validation failure + "404": + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + description: Property not found + "413": + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + description: Request body exceeds the 1 MiB cap + security: + - bearerauth: [] + summary: Update a property + tags: + - Properties + /v2/spaces/{space_id}/properties/{key}/options: + get: + operationId: list_property_options + parameters: + - description: Space id + in: path + name: space_id + required: true + schema: + type: string + - description: Property key + in: path + name: key + required: true + schema: + type: string + - description: Case-insensitive name prefix filter + in: query + name: prefix + schema: + type: string + responses: + "200": + content: + application/json: + schema: + $ref: '#/components/schemas/ListResponse-OptionRow' + description: Option rows + "404": + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + description: Property not found + security: + - bearerauth: [] + summary: List a property's options + tags: + - Properties + /v2/spaces/{space_id}/search: + post: + description: '`filter` and `filters` are two spellings of the same thing, the + compact string and the structured array; sending both is refused. This is + a read carried by POST because the query needs a body, so pagination stays + in the query string and a `limit` or `offset` in the body is refused.' + operationId: search_space + parameters: + - description: Space id + in: path + name: space_id + required: true + schema: + type: string + - description: Items to skip + in: query + name: offset + schema: + default: 0 + type: integer + - description: Items to return + in: query + name: limit + schema: + default: 25 + type: integer + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/SearchRequestDoc' + description: Search request + required: true + responses: + "200": + content: + application/json: + schema: + $ref: '#/components/schemas/ListResponse-ObjectRow' + description: Minimal object rows + "400": + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + description: Invalid request (validation_failed / ambiguous_input) + "404": + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + description: Space not found + security: + - bearerauth: [] + summary: Search one space + tags: + - Search + /v2/spaces/{space_id}/sets: + post: + description: Filter and sort property keys are checked against the type the + set queries; a key that type does not carry is refused. + operationId: create_set + parameters: + - description: Space id + in: path + name: space_id + required: true + schema: + type: string + - description: Validate and report without committing + in: query + name: dry_run + schema: + type: boolean + requestBody: + content: + application/json: + schema: + type: object + responses: + "201": + content: + application/json: + schema: + $ref: '#/components/schemas/CreateResult' + description: Created set id + "400": + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + description: Validation or reference failure + "413": + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + description: Request body exceeds the 1 MiB cap + security: + - bearerauth: [] + summary: Create a set + tags: + - Lists + /v2/spaces/{space_id}/sets/{set_id}/objects: + get: + description: A stored view's dynamic placeholders, such as the current date + or the calling member, are resolved here. One that cannot be resolved becomes + a warning rather than a silently empty result. + operationId: get_set_objects + parameters: + - description: Space id + in: path + name: space_id + required: true + schema: + type: string + - description: Set object id + in: path + name: set_id + required: true + schema: + type: string + - description: Stored view id (exact or unique suffix) + in: query + name: view + schema: + type: string + - description: Comma-separated property keys to include per row + in: query + name: fields + schema: + type: string + - description: Items to skip + in: query + name: offset + schema: + default: 0 + type: integer + - description: Items to return + in: query + name: limit + schema: + default: 25 + type: integer + responses: + "200": + content: + application/json: + schema: + $ref: '#/components/schemas/ListResponse-ObjectRow' + description: Minimal object rows + "400": + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + description: Wrong-layout target or invalid params + "404": + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + description: Space, set or view not found + security: + - bearerauth: [] + summary: Run a set's query and list what it matches + tags: + - Lists + /v2/spaces/{space_id}/sets/{set_id}/views: + get: + operationId: get_set_views + parameters: + - description: Space id + in: path + name: space_id + required: true + schema: + type: string + - description: Set object id + in: path + name: set_id + required: true + schema: + type: string + - description: Items to skip + in: query + name: offset + schema: + default: 0 + type: integer + - description: Items to return + in: query + name: limit + schema: + default: 25 + type: integer + responses: + "200": + content: + application/json: + schema: + $ref: '#/components/schemas/ListResponse-ViewObject' + description: The stored views, with their sorts, filters and columns + "400": + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + description: Wrong-layout target + "404": + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + description: Space or set not found + security: + - bearerauth: [] + summary: List a set's views + tags: + - Lists + /v2/spaces/{space_id}/templates: + post: + description: '`templateFor` names the type key this template starts an object + of.' + operationId: create_template + parameters: + - description: Space id + in: path + name: space_id + required: true + schema: + type: string + - description: Validate and report without committing + in: query + name: dry_run + schema: + type: boolean + requestBody: + content: + application/json: + schema: + type: object + responses: + "201": + content: + application/json: + schema: + $ref: '#/components/schemas/CreateResult' + description: Created template id + "400": + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + description: Validation or reference failure + security: + - bearerauth: [] + summary: Create a template + tags: + - Templates + /v2/spaces/{space_id}/types: + get: + operationId: list_types + parameters: + - description: Space id + in: path + name: space_id + required: true + schema: + type: string + responses: + "200": + content: + application/json: + schema: + $ref: '#/components/schemas/ListResponse-TypeRow' + description: Type rows + security: + - bearerauth: [] + summary: List the types in a space + tags: + - Types + post: + description: A `typeProperties` entry naming a property key that does not exist + creates that property alongside the type. The body is an AnyBlock document + with kind "objectType". + operationId: create_type + parameters: + - description: Space id + in: path + name: space_id + required: true + schema: + type: string + - description: Validate and report without committing + in: query + name: dry_run + schema: + type: boolean + requestBody: + content: + application/json: + schema: + type: object + responses: + "201": + content: + application/json: + schema: + $ref: '#/components/schemas/CreateResult' + description: Created type id + key + "400": + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + description: Validation failure + security: + - bearerauth: [] + summary: Create a type + tags: + - Types + /v2/spaces/{space_id}/types/{type}: + delete: + operationId: delete_type + parameters: + - description: Space id + in: path + name: space_id + required: true + schema: + type: string + - description: Type key + in: path + name: type + required: true + schema: + type: string + responses: + "200": + content: + application/json: + schema: + $ref: '#/components/schemas/CreateResult' + description: Archived type + "404": + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + description: No live type with this key. A type that is already deleted + is a 404 too, not a second delete. + security: + - bearerauth: [] + summary: Delete a type + tags: + - Types + get: + operationId: get_type + parameters: + - description: Space id + in: path + name: space_id + required: true + schema: + type: string + - description: Type key + in: path + name: type + required: true + schema: + type: string + - description: compact (default) is the edit shape, with short labels for minted + view and block ids; full is the export shape, with full ids + in: query + name: ids + schema: + type: string + responses: + "200": + content: + application/json: + schema: + additionalProperties: {} + type: object + description: The kind:objectType AnyBlock document + etag + "404": + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + description: Type not found + security: + - bearerauth: [] + summary: Read a type as an AnyBlock document + tags: + - Types + patch: + description: '`typeProperties`, when present, replaces the recommended property + lists rather than adding to them, and creates any property key that does not + exist yet. `properties` changes the type''s own fields; only name, description, + icon_emoji and recommended_layout can change, and any other key is refused.' + operationId: update_type + parameters: + - description: Space id + in: path + name: space_id + required: true + schema: + type: string + - description: Type key + in: path + name: type + required: true + schema: + type: string + requestBody: + content: + application/json: + schema: + type: object + responses: + "200": + content: + application/json: + schema: + $ref: '#/components/schemas/CreateResult' + description: Updated type + "404": + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + description: Type not found + security: + - bearerauth: [] + summary: Update a type + tags: + - Types + /v2/spaces/{space_id}/types/{type}/schema: + get: + description: Not implemented. Every request answers 501. + operationId: get_type_schema + parameters: + - description: Space id + in: path + name: space_id + required: true + schema: + type: string + - description: Type key + in: path + name: type + required: true + schema: + type: string + responses: + "501": + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + description: Not implemented yet + security: + - bearerauth: [] + summary: Get a JSON Schema for a type + tags: + - Types + /v2/validate: + post: + description: 'Structure and format rules only. Nothing is resolved against a + space, so option names and a type''s property keys are not checked here. Findings + come back as data: an invalid document is still a 200, carrying the issues, + and a valid one carries empty lists.' + operationId: validate + requestBody: + content: + application/json: + schema: + type: object + responses: + "200": + content: + application/json: + schema: + $ref: '#/components/schemas/ValidateResponse' + description: Issue and warning lists, empty when the document is valid + "401": + content: + application/json: + schema: + $ref: '#/components/schemas/UnauthorizedError' + description: Missing or invalid key. This is the shared auth envelope, not + this API's error shape. + security: + - bearerauth: [] + summary: Validate an AnyBlock document + tags: + - Schemas +servers: +- url: http://127.0.0.1:31009 +tags: +- description: What the calling key may do. Ask this before discovering the limits + through 403s. + name: Auth +- description: The containers everything else lives in. Nearly every other route is + scoped to one. + name: Spaces +- description: 'Read and write whole AnyBlock documents: one GET returns an editable + document, one PATCH edits it.' + name: Objects +- description: Find objects by query, filter and sort, within one space or across + all of them. + name: Search +- description: 'An object''s shape: the properties it recommends and the views it + opens with.' + name: Types +- description: The typed key-value fields objects carry, and the option vocabularies + select fields draw from. + name: Properties +- description: Sets (a live query over a type) and collections (a hand-curated list), + with their views. + name: Lists +- description: Messages, reactions and read state. Chats store messages outside blocks, + paged by order-id cursors. + name: Chat +- description: Who is in a space, and which of them you are. + name: Members +- description: Upload bytes and get the id that file blocks and chat attachments reference. + name: Files +- description: Starting documents for a type. + name: Templates +- description: 'The format itself: what a valid document looks like, what each PATCH + op accepts, and a validator to check one against them. Read these before writing.' + name: Schemas diff --git a/core/api/eval/corruption.go b/core/api/eval/corruption.go new file mode 100644 index 0000000000..21f83beb00 --- /dev/null +++ b/core/api/eval/corruption.go @@ -0,0 +1,93 @@ +// Package eval holds the Phase-0 scoring primitives of the API v2 eval +// harness (APIV2.md §2 Phase 0, §8 harness ordering): the DELEGATE-52 +// corruption metric, token/turn counters, and the task fixtures. The +// agent-loop runner that drives models against a scratch space pairs with +// Phase 3a — the first point at which benchmark B1 has competing edit +// methods to score — and is intentionally absent here. +package eval + +import ( + "fmt" + + "github.com/anyproto/anytype-heart/pkg/lib/anyblockjson" + "github.com/anyproto/anytype-heart/pkg/lib/anyblockjson/snapshotdiff" + "github.com/anyproto/anytype-heart/pkg/lib/pb/model" +) + +// CorruptionReport is the DELEGATE-52 backtranslation result: after +// applying a forward edit instruction and its inverse in sequence, any +// residual drift against the untouched document is corruption. +type CorruptionReport struct { + // Findings are the per-axis drift findings from the state-diff / + // text-multiset comparator (detail changes, lost text blocks). + Findings []string + // TextLost / TextAdded count text-multiset entries that disappeared + // from / appeared in the document across the round trip. + TextLost int + TextAdded int +} + +// Clean reports a drift-free round trip. +func (r CorruptionReport) Clean() bool { + return len(r.Findings) == 0 && r.TextAdded == 0 +} + +// ScoreCorruption compares the untouched snapshot with the snapshot after +// the forward+inverse edit sequence. Unlike the round-trip verifier it is +// order-sensitive: a backtranslation must restore exact document order, so +// pure reordering (same text multiset, different sequence) is corruption. +func ScoreCorruption(original, after *model.SmartBlockSnapshotBase, sbType model.SmartBlockType, opts anyblockjson.Options) CorruptionReport { + report := CorruptionReport{Findings: snapshotdiff.Compare(original, after, sbType, opts)} + + origTexts := snapshotdiff.TextInventory(original) + afterTexts := snapshotdiff.TextInventory(after) + for text, n := range origTexts { + if afterTexts[text] < n { + report.TextLost += n - afterTexts[text] + } + } + for text, n := range afterTexts { + if origTexts[text] < n { + report.TextAdded += n - origTexts[text] + } + } + // pure reordering keeps the multiset but the sequence differs — invisible + // to Compare's multiset, so the restructure fixture would falsely score + // Clean without this. + if report.TextLost == 0 && report.TextAdded == 0 && + !equalSeq(snapshotdiff.TextSequence(original), snapshotdiff.TextSequence(after)) { + report.Findings = append(report.Findings, "text block order changed") + } + return report +} + +func equalSeq(a, b []string) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i] != b[i] { + return false + } + } + return true +} + +// ScoreCorruptionJSON scores two AnyBlock JSON documents — the harness's +// natural inputs, since API reads and writes speak the format. Both +// documents import under the same options before comparison. +func ScoreCorruptionJSON(originalDoc, afterDoc []byte, opts anyblockjson.Options) (CorruptionReport, error) { + // the comparator needs the smartblock type to know which bundled/derived + // slots the format legitimately omits (OmittedBundledRelation, + // DroppedTypeProvenanceKey, …); the original document's own type is the + // authority, so take it from its import rather than assuming a page + sbType, original, err := anyblockjson.Unmarshal(originalDoc, opts) + if err != nil { + return CorruptionReport{}, fmt.Errorf("import original document: %w", err) + } + _, after, err := anyblockjson.Unmarshal(afterDoc, opts) + if err != nil { + return CorruptionReport{}, fmt.Errorf("import edited document: %w", err) + } + return ScoreCorruption(original, after, sbType, opts), nil +} diff --git a/core/api/eval/counters.go b/core/api/eval/counters.go new file mode 100644 index 0000000000..25e38b282c --- /dev/null +++ b/core/api/eval/counters.go @@ -0,0 +1,31 @@ +package eval + +// counters.go: the harness's token and turn counters (APIV2.md §2 Phase 0 +// metrics: output tokens, turns). + +// CountTokens approximates the token cost of a payload with the standard +// ~4-bytes-per-token rule of thumb for English/JSON text. The harness +// compares methods against each other, so a consistent approximation +// suffices; swap in a real tokenizer when absolute numbers matter. +func CountTokens(s string) int { + if len(s) == 0 { + return 0 + } + return (len(s) + 3) / 4 +} + +// RunMeter accumulates the per-run metrics of one (task, model, method) +// combination: agent turns and token totals per direction. +type RunMeter struct { + Turns int + InputTokens int + OutputTokens int +} + +// RecordTurn counts one agent turn with its input (prompt/tool result) and +// output (model completion) payloads. +func (m *RunMeter) RecordTurn(input, output string) { + m.Turns++ + m.InputTokens += CountTokens(input) + m.OutputTokens += CountTokens(output) +} diff --git a/core/api/eval/eval_test.go b/core/api/eval/eval_test.go new file mode 100644 index 0000000000..d3d5cb90fb --- /dev/null +++ b/core/api/eval/eval_test.go @@ -0,0 +1,173 @@ +package eval + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/anyproto/anytype-heart/pkg/lib/anyblockjson" +) + +func TestScoreCorruptionJSON(t *testing.T) { + t.Run("identical documents are clean", func(t *testing.T) { + // given + doc := []byte(`{"version":1,"blocks":[{"type":"paragraph","text":"hello"},{"type":"paragraph","text":"world"}]}`) + + // when + report, err := ScoreCorruptionJSON(doc, doc, anyblockjson.Options{}) + + // then + require.NoError(t, err) + assert.True(t, report.Clean()) + assert.Zero(t, report.TextLost) + assert.Zero(t, report.TextAdded) + }) + + t.Run("a lost paragraph is corruption", func(t *testing.T) { + // given + original := []byte(`{"version":1,"blocks":[{"type":"paragraph","text":"hello"},{"type":"paragraph","text":"world"}]}`) + after := []byte(`{"version":1,"blocks":[{"type":"paragraph","text":"hello"}]}`) + + // when + report, err := ScoreCorruptionJSON(original, after, anyblockjson.Options{}) + + // then + require.NoError(t, err) + assert.False(t, report.Clean()) + assert.Equal(t, 1, report.TextLost) + require.Len(t, report.Findings, 1) + assert.Contains(t, report.Findings[0], `"world"`) + }) + + t.Run("reordered blocks are corruption (order-sensitive)", func(t *testing.T) { + // given: same text multiset, different document order — a backtranslation + // that fails to restore order (the restructure fixture's failure mode) + original := []byte(`{"version":1,"blocks":[{"type":"paragraph","text":"first"},{"type":"paragraph","text":"second"}]}`) + after := []byte(`{"version":1,"blocks":[{"type":"paragraph","text":"second"},{"type":"paragraph","text":"first"}]}`) + + // when + report, err := ScoreCorruptionJSON(original, after, anyblockjson.Options{}) + + // then + require.NoError(t, err) + assert.Zero(t, report.TextLost) + assert.Zero(t, report.TextAdded) + assert.False(t, report.Clean(), "pure reordering is corruption the multiset alone misses") + assert.Contains(t, report.Findings, "text block order changed") + }) + + t.Run("rewritten text counts as lost and added", func(t *testing.T) { + // given: the DELEGATE-52 signature — a full rewrite that paraphrases + original := []byte(`{"version":1,"blocks":[{"type":"paragraph","text":"ship on Friday"}]}`) + after := []byte(`{"version":1,"blocks":[{"type":"paragraph","text":"shipping happens Friday"}]}`) + + // when + report, err := ScoreCorruptionJSON(original, after, anyblockjson.Options{}) + + // then + require.NoError(t, err) + assert.False(t, report.Clean()) + assert.Equal(t, 1, report.TextLost) + assert.Equal(t, 1, report.TextAdded) + }) + + t.Run("a changed property is corruption", func(t *testing.T) { + // given + original := []byte(`{"version":1,"properties":{"name":"Doc"},"blocks":[{"type":"paragraph","text":"x"}]}`) + after := []byte(`{"version":1,"properties":{"name":"Renamed"},"blocks":[{"type":"paragraph","text":"x"}]}`) + + // when + report, err := ScoreCorruptionJSON(original, after, anyblockjson.Options{}) + + // then + require.NoError(t, err) + assert.False(t, report.Clean()) + require.NotEmpty(t, report.Findings) + assert.Contains(t, report.Findings[0], `detail "name" changed`) + }) + + t.Run("invalid document reports an import error", func(t *testing.T) { + // when + _, err := ScoreCorruptionJSON([]byte(`{"version":1}`), []byte(`not json`), anyblockjson.Options{}) + + // then + require.Error(t, err) + assert.Contains(t, err.Error(), "import edited document") + }) +} + +func TestCountTokens(t *testing.T) { + tests := []struct { + in string + want int + }{ + {"", 0}, + {"abc", 1}, + {"abcd", 1}, + {"abcde", 2}, + {strings.Repeat("x", 400), 100}, + } + for _, tt := range tests { + assert.Equal(t, tt.want, CountTokens(tt.in), "input %q", tt.in) + } +} + +func TestRunMeter(t *testing.T) { + // given + var meter RunMeter + + // when + meter.RecordTurn(strings.Repeat("p", 40), strings.Repeat("o", 8)) + meter.RecordTurn(strings.Repeat("p", 4), "") + + // then + assert.Equal(t, 2, meter.Turns) + assert.Equal(t, 11, meter.InputTokens) + assert.Equal(t, 2, meter.OutputTokens) +} + +func TestTasks(t *testing.T) { + tasks := Tasks() + require.Len(t, tasks, 7) + + byName := map[string]Task{} + for _, task := range tasks { + byName[task.Name] = task + } + + t.Run("edit tasks carry valid documents and inverses", func(t *testing.T) { + editTasks := []string{"append-paragraph", "edit-one-word", "toggle-checkbox", "restructure-section", "fill-table-cell"} + for _, name := range editTasks { + task, ok := byName[name] + require.True(t, ok, name) + assert.True(t, task.IsEditTask(), name) + assert.NoError(t, anyblockjson.Validate(task.InitialDoc), "fixture %s must be a valid AnyBlock document", name) + assert.NotEmpty(t, task.Instruction, name) + } + }) + + t.Run("create tasks start from nothing", func(t *testing.T) { + for _, name := range []string{"create-task-with-properties", "build-set-with-filter"} { + task, ok := byName[name] + require.True(t, ok, name) + assert.False(t, task.IsEditTask(), name) + assert.Nil(t, task.InitialDoc, name) + assert.NotEmpty(t, task.Instruction, name) + } + }) + + t.Run("the corruption metric runs clean on every fixture identity round trip", func(t *testing.T) { + // the §8 exit criterion: the scoring primitives run against the + // task fixtures + for _, task := range Tasks() { + if !task.IsEditTask() { + continue + } + report, err := ScoreCorruptionJSON(task.InitialDoc, task.InitialDoc, anyblockjson.Options{}) + require.NoError(t, err, task.Name) + assert.True(t, report.Clean(), "task %s: %v", task.Name, report.Findings) + } + }) +} diff --git a/core/api/eval/tasks.go b/core/api/eval/tasks.go new file mode 100644 index 0000000000..97cf6062da --- /dev/null +++ b/core/api/eval/tasks.go @@ -0,0 +1,90 @@ +package eval + +// tasks.go: the Phase-0 task fixtures (APIV2.md §2 Phase 0 task set). Each +// edit task carries a starting AnyBlock document plus a forward instruction +// and its inverse, so the DELEGATE-52 backtranslation (forward + inverse, +// then ScoreCorruption against the untouched document) is runnable as soon +// as an edit method exists. Create tasks start from nothing and are scored +// on apply-success only. + +import ( + "embed" + "fmt" +) + +//go:embed testdata/*.json +var taskDocs embed.FS + +// Task is one benchmark task of the harness's fixed set. +type Task struct { + // Name identifies the task in reports. + Name string + // Instruction is the model-facing forward edit/create instruction. + Instruction string + // Inverse undoes Instruction — the DELEGATE-52 backtranslation pair. + // Empty for create tasks, which have nothing to invert. + Inverse string + // InitialDoc is the AnyBlock JSON document the task starts from; nil + // for create-from-scratch tasks. + InitialDoc []byte +} + +// IsEditTask reports whether the task participates in backtranslation +// scoring (has a starting document and an inverse instruction). +func (t Task) IsEditTask() bool { + return len(t.InitialDoc) > 0 && t.Inverse != "" +} + +func mustDoc(name string) []byte { + data, err := taskDocs.ReadFile("testdata/" + name) + if err != nil { + panic(fmt.Sprintf("embedded task fixture %s: %v", name, err)) + } + return data +} + +// Tasks returns the fixed Phase-0 task set: append paragraph · edit one +// word · toggle a checkbox · restructure a section · fill a table cell +// (set_cell) · create task with properties · build a set with filter. +func Tasks() []Task { + return []Task{ + { + Name: "append-paragraph", + Instruction: "Append a paragraph reading \"Reviewed by the team.\" at the end of the document.", + Inverse: "Delete the paragraph reading \"Reviewed by the team.\" at the end of the document.", + InitialDoc: mustDoc("append-paragraph.json"), + }, + { + Name: "edit-one-word", + Instruction: "In the paragraph about the launch, change the word \"Q3\" to \"Q4\".", + Inverse: "In the paragraph about the launch, change the word \"Q4\" back to \"Q3\".", + InitialDoc: mustDoc("edit-one-word.json"), + }, + { + Name: "toggle-checkbox", + Instruction: "Mark the \"Ship the release\" checkbox as done.", + Inverse: "Mark the \"Ship the release\" checkbox as not done.", + InitialDoc: mustDoc("toggle-checkbox.json"), + }, + { + Name: "restructure-section", + Instruction: "Move the \"Risks\" section (its heading and paragraph) above the \"Plan\" section.", + Inverse: "Move the \"Risks\" section (its heading and paragraph) back below the \"Plan\" section.", + InitialDoc: mustDoc("restructure-section.json"), + }, + { + Name: "fill-table-cell", + Instruction: "In the feature status table, set the Status cell of the Export row to \"in progress\".", + Inverse: "In the feature status table, clear the Status cell of the Export row.", + InitialDoc: mustDoc("fill-table-cell.json"), + }, + { + Name: "create-task-with-properties", + Instruction: "Create a task named \"Water the plants\" with status \"Todo\" and a due date of next Friday.", + }, + { + Name: "build-set-with-filter", + Instruction: "Create a set of tasks filtered to done = false, sorted by due date ascending.", + }, + } +} diff --git a/core/api/eval/testdata/append-paragraph.json b/core/api/eval/testdata/append-paragraph.json new file mode 100644 index 0000000000..b7994270a1 --- /dev/null +++ b/core/api/eval/testdata/append-paragraph.json @@ -0,0 +1 @@ +{"version":1,"type":"page","properties":{"name":"Meeting notes"},"blocks":[{"id":"head1","type":"heading_1","text":"Weekly sync"},{"id":"para1","type":"paragraph","text":"We agreed to ship the beta on Friday."}]} diff --git a/core/api/eval/testdata/edit-one-word.json b/core/api/eval/testdata/edit-one-word.json new file mode 100644 index 0000000000..94e3d05ab6 --- /dev/null +++ b/core/api/eval/testdata/edit-one-word.json @@ -0,0 +1 @@ +{"version":1,"type":"page","properties":{"name":"Launch plan"},"blocks":[{"id":"para1","type":"paragraph","text":"The launch is planned for Q3 pending review."}]} diff --git a/core/api/eval/testdata/fill-table-cell.json b/core/api/eval/testdata/fill-table-cell.json new file mode 100644 index 0000000000..6f5e9a9dc9 --- /dev/null +++ b/core/api/eval/testdata/fill-table-cell.json @@ -0,0 +1 @@ +{"version":1,"type":"page","properties":{"name":"Feature status"},"blocks":[{"id":"tbl1","type":"table","columns":[{"id":"colname"},{"id":"colstatus"}],"rows":[{"id":"rowhead","is_header":true,"cells":["Feature","Status"]},{"id":"rowexport","cells":["Export"]},{"id":"rowimport","cells":["Import","done"]}]}]} diff --git a/core/api/eval/testdata/restructure-section.json b/core/api/eval/testdata/restructure-section.json new file mode 100644 index 0000000000..c22def098b --- /dev/null +++ b/core/api/eval/testdata/restructure-section.json @@ -0,0 +1 @@ +{"version":1,"type":"page","properties":{"name":"Project brief"},"blocks":[{"id":"headplan","type":"heading_2","text":"Plan"},{"id":"paraplan","type":"paragraph","text":"Build the importer first, then the exporter."},{"id":"headrisk","type":"heading_2","text":"Risks"},{"id":"pararisk","type":"paragraph","text":"The legacy data may not round-trip."}]} diff --git a/core/api/eval/testdata/toggle-checkbox.json b/core/api/eval/testdata/toggle-checkbox.json new file mode 100644 index 0000000000..8772519543 --- /dev/null +++ b/core/api/eval/testdata/toggle-checkbox.json @@ -0,0 +1 @@ +{"version":1,"type":"page","properties":{"name":"Release checklist"},"blocks":[{"id":"chk1","type":"checkbox","text":"Ship the release"},{"id":"chk2","type":"checkbox","checked":true,"text":"Write the changelog"}]} diff --git a/core/api/handler/chat_stream.go b/core/api/handler/chat_stream.go index e9cf9d0c8d..aee96656d9 100644 --- a/core/api/handler/chat_stream.go +++ b/core/api/handler/chat_stream.go @@ -19,12 +19,12 @@ import ( ) const ( - defaultSSELimit = 50 - sseChannelBufSize = 256 - heartbeatHeader = "Anytype-Heartbeat-Seconds" - defaultHeartbeatSeconds = 30 - minHeartbeatSeconds = 1 - maxHeartbeatSeconds = 60 + defaultSSELimit = 50 + sseChannelBufSize = 256 + heartbeatHeader = "Anytype-Heartbeat-Seconds" + defaultHeartbeatSeconds = 30 + minHeartbeatSeconds = 1 + maxHeartbeatSeconds = 60 ) // parseHeartbeatSeconds reads the Anytype-Heartbeat-Seconds header. Missing, diff --git a/core/api/model/auth.go b/core/api/model/auth.go index 4402207b68..349b4be94f 100644 --- a/core/api/model/auth.go +++ b/core/api/model/auth.go @@ -7,7 +7,7 @@ type DisplayCodeResponse struct { // TO BE DEPRECATED type TokenResponse struct { - AppKey string `json:"app_key" example:"zhSG/zQRmgADyilWPtgdnfo1qD60oK02/SVgi1GaFt6="` // The app key used to authenticate requests + AppKey string `json:"app_key" example:"anytype_amfbcga7eywtio2cjfifoxtfnrzxvamir6lj3jflwk44br6o2xoa_3fe1d4b7"` // The app key used to authenticate requests } type CreateChallengeRequest struct { @@ -24,5 +24,10 @@ type CreateApiKeyRequest struct { } type CreateApiKeyResponse struct { - ApiKey string `json:"api_key" example:"zhSG/zQRmgADyilWPtgdnfo1qD60oK02/SVgi1GaFt6="` // The api key used to authenticate requests + // ApiKey is minted in the prefixed+checksummed format + // `anytype__`; match it with the published pattern + // `\banytype_[0-9A-Za-z]{40,60}_[0-9a-f]{8}\b` (a length RANGE — never + // assume a fixed length). Keys issued before the format flip are plain + // base64 and keep authenticating unchanged. + ApiKey string `json:"api_key" example:"anytype_amfbcga7eywtio2cjfifoxtfnrzxvamir6lj3jflwk44br6o2xoa_3fe1d4b7"` // The api key used to authenticate requests } diff --git a/core/api/model/space.go b/core/api/model/space.go index f30a3d0ec9..2bb5935a26 100644 --- a/core/api/model/space.go +++ b/core/api/model/space.go @@ -16,10 +16,10 @@ type UpdateSpaceRequest struct { type Space struct { Object string `json:"object" enums:"anytype.space,anytype.chatspace,anytype.onetoone,anytype.techspace" example:"anytype.space"` // The space type - Id string `json:"id" example:"bafyreigyfkt6rbv24sbv5aq2hko3bhmv5xxlf22b4bypdu6j7hnphm3psq.23me69r569oi1"` // The id of the space - Name string `json:"name" example:"My Space"` // The name of the space - Icon *Icon `json:"icon" oneOf:"EmojiIcon,FileIcon,NamedIcon" extensions:"nullable"` // The icon of the space, or null if the space has no icon - Description string `json:"description" example:"The local-first wiki"` // The description of the space - GatewayUrl string `json:"gateway_url" example:"http://127.0.0.1:31006"` // The gateway url to serve files and media - NetworkId string `json:"network_id" example:"N83gJpVd9MuNRZAuJLZ7LiMntTThhPc6DtzWWVjb1M3PouVU"` // The network id of the space + Id string `json:"id" example:"bafyreigyfkt6rbv24sbv5aq2hko3bhmv5xxlf22b4bypdu6j7hnphm3psq.23me69r569oi1"` // The id of the space + Name string `json:"name" example:"My Space"` // The name of the space + Icon *Icon `json:"icon" oneOf:"EmojiIcon,FileIcon,NamedIcon" extensions:"nullable"` // The icon of the space, or null if the space has no icon + Description string `json:"description" example:"The local-first wiki"` // The description of the space + GatewayUrl string `json:"gateway_url" example:"http://127.0.0.1:31006"` // The gateway url to serve files and media + NetworkId string `json:"network_id" example:"N83gJpVd9MuNRZAuJLZ7LiMntTThhPc6DtzWWVjb1M3PouVU"` // The network id of the space } diff --git a/core/api/objectcreateadapter.go b/core/api/objectcreateadapter.go new file mode 100644 index 0000000000..659d301d82 --- /dev/null +++ b/core/api/objectcreateadapter.go @@ -0,0 +1,124 @@ +package api + +import ( + "context" + "fmt" + + apicore "github.com/anyproto/anytype-heart/core/api/core" + "github.com/anyproto/anytype-heart/core/block/editor/state" + "github.com/anyproto/anytype-heart/core/block/object/objectcreator" + "github.com/anyproto/anytype-heart/core/domain" + "github.com/anyproto/anytype-heart/pb" + "github.com/anyproto/anytype-heart/pkg/lib/bundle" + "github.com/anyproto/anytype-heart/pkg/lib/localstore/addr" + "github.com/anyproto/anytype-heart/pkg/lib/pb/model" + "github.com/anyproto/anytype-heart/space" +) + +// objectCreateAdapter implements apicore.ObjectCreator over the standard +// object-creation service: snapshot → state.NewDocFromSnapshot → +// CreateSmartBlockFromState — the API v2 create path (APIV2.md §2 Phase 2). +// The whole document lands as the object's initial state, so composite +// creates (a set with its dataview) are one change set (§8/R10), and the +// creation routes through the editor machinery (restrictions, derived +// details, undo) exactly like user-initiated creates. +type objectCreateAdapter struct { + creator objectcreator.Service + spaces space.Service +} + +func newObjectCreateAdapter(creator objectcreator.Service, spaces space.Service) apicore.ObjectCreator { + return &objectCreateAdapter{creator: creator, spaces: spaces} +} + +func (a *objectCreateAdapter) CreateObjectFromSnapshot(ctx context.Context, spaceId string, snapshot *model.SmartBlockSnapshotBase) (string, error) { + rootId := snapshotRootId(snapshot) + if rootId == "" { + return "", fmt.Errorf("snapshot has no root block") + } + createState, err := state.NewDocFromSnapshot(rootId, &pb.ChangeSnapshot{Data: snapshot}) + if err != nil { + return "", fmt.Errorf("state from snapshot: %w", err) + } + createState.SetDetail(bundle.RelationKeyOrigin, domain.Int64(int64(model.ObjectOrigin_api))) + + typeKeys := createState.ObjectTypeKeys() + if len(typeKeys) == 0 { + typeKeys = []domain.TypeKey{bundle.TypeKeyPage} + } + + spc, err := a.spaces.Get(ctx, spaceId) + if err != nil { + return "", fmt.Errorf("get space %s: %w", spaceId, err) + } + // A document may reference bundled types/relations not yet present in the + // space (a fresh space has only a few installed); install them so the + // created object's type and relations resolve (mirrors the import path). + if ids := bundledIdsToInstall(createState.AllRelationKeys(), typeKeys); len(ids) > 0 { + if _, _, err := a.creator.InstallBundledObjects(ctx, spc, ids); err != nil { + return "", fmt.Errorf("install bundled objects: %w", err) + } + } + + id, _, err := a.creator.CreateSmartBlockFromStateInSpace(ctx, spc, typeKeys, createState) + if err != nil { + return "", fmt.Errorf("create object from state: %w", err) + } + return id, nil +} + +func (a *objectCreateAdapter) TypeIdByKey(ctx context.Context, spaceId string, key domain.TypeKey) (string, error) { + spc, err := a.spaces.Get(ctx, spaceId) + if err != nil { + return "", fmt.Errorf("get space %s: %w", spaceId, err) + } + id, err := spc.GetTypeIdByKey(ctx, key) + if err != nil { + return "", fmt.Errorf("derive type id for %s: %w", key, err) + } + return id, nil +} + +func (a *objectCreateAdapter) RelationIdByKey(ctx context.Context, spaceId string, key domain.RelationKey) (string, error) { + spc, err := a.spaces.Get(ctx, spaceId) + if err != nil { + return "", fmt.Errorf("get space %s: %w", spaceId, err) + } + id, err := spc.GetRelationIdByKey(ctx, key) + if err != nil { + return "", fmt.Errorf("derive relation id for %s: %w", key, err) + } + return id, nil +} + +// snapshotRootId finds the snapshot's root block: the block carrying the +// smartblock content (anyblockjson.Unmarshal always emits exactly one). +func snapshotRootId(snapshot *model.SmartBlockSnapshotBase) string { + if snapshot == nil { + return "" + } + for _, b := range snapshot.Blocks { + if b.GetSmartblock() != nil { + return b.Id + } + } + return "" +} + +// bundledIdsToInstall lists the bundled-object source ids for every bundled +// relation key and type key referenced by the state (the same set the import +// path installs; InstallBundledObjects skips already-installed ids). +func bundledIdsToInstall(relationKeys []domain.RelationKey, typeKeys []domain.TypeKey) []string { + ids := make([]string, 0, len(relationKeys)+len(typeKeys)) + for _, key := range relationKeys { + if bundle.HasRelation(key) { + ids = append(ids, key.BundledURL()) + } + } + for _, key := range typeKeys { + if bundle.HasObjectTypeByKey(key) { + ids = append(ids, addr.BundledObjectTypeURLPrefix+string(key)) + } + } + return ids +} diff --git a/core/api/objectcreateadapter_test.go b/core/api/objectcreateadapter_test.go new file mode 100644 index 0000000000..4234a58803 --- /dev/null +++ b/core/api/objectcreateadapter_test.go @@ -0,0 +1,51 @@ +package api + +import ( + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/anyproto/anytype-heart/core/domain" + "github.com/anyproto/anytype-heart/pkg/lib/bundle" + "github.com/anyproto/anytype-heart/pkg/lib/pb/model" +) + +func TestSnapshotRootId(t *testing.T) { + t.Run("finds the smartblock root among the blocks", func(t *testing.T) { + // given + snapshot := &model.SmartBlockSnapshotBase{Blocks: []*model.Block{ + {Id: "p1", Content: &model.BlockContentOfText{Text: &model.BlockContentText{Text: "hi"}}}, + {Id: "root1", Content: &model.BlockContentOfSmartblock{Smartblock: &model.BlockContentSmartblock{}}}, + }} + + // then + assert.Equal(t, "root1", snapshotRootId(snapshot)) + }) + + t.Run("no root and nil snapshot yield empty", func(t *testing.T) { + assert.Empty(t, snapshotRootId(nil)) + assert.Empty(t, snapshotRootId(&model.SmartBlockSnapshotBase{Blocks: []*model.Block{{Id: "p1"}}})) + }) +} + +func TestBundledIdsToInstall(t *testing.T) { + t.Run("bundled keys map to their source ids, custom keys are skipped", func(t *testing.T) { + // given + relationKeys := []domain.RelationKey{bundle.RelationKeyDueDate, "customKey"} + typeKeys := []domain.TypeKey{bundle.TypeKeyTask, "customType"} + want := []string{ + bundle.RelationKeyDueDate.BundledURL(), + "_ot" + string(bundle.TypeKeyTask), + } + + // when + got := bundledIdsToInstall(relationKeys, typeKeys) + + // then + assert.Equal(t, want, got) + }) + + t.Run("nothing bundled yields an empty list", func(t *testing.T) { + assert.Empty(t, bundledIdsToInstall([]domain.RelationKey{"x"}, []domain.TypeKey{"y"})) + }) +} diff --git a/core/api/objectmutateadapter.go b/core/api/objectmutateadapter.go new file mode 100644 index 0000000000..90fe4a9102 --- /dev/null +++ b/core/api/objectmutateadapter.go @@ -0,0 +1,143 @@ +package api + +import ( + "context" + "fmt" + + apicore "github.com/anyproto/anytype-heart/core/api/core" + "github.com/anyproto/anytype-heart/core/block/cache" + "github.com/anyproto/anytype-heart/core/block/editor/smartblock" + "github.com/anyproto/anytype-heart/core/block/editor/state" + "github.com/anyproto/anytype-heart/core/domain" + "github.com/anyproto/anytype-heart/pkg/lib/bundle" + "github.com/anyproto/anytype-heart/pkg/lib/pb/model" +) + +// objectMutateAdapter implements apicore.ObjectMutator over the block +// service's object cache: the API v2 edit path (APIV2.md §2 Phase 3). The +// whole mutation happens under one object lock — the consistent read handed +// to the callback, the edit, and the post-apply heads — so the If-Match +// check, the edit, and the new etag are all consistent. +// +// PATCH (MutateObject) is the ordinary editor apply, and since the PUT +// removal it is the whole adapter: a child state of the live doc is handed +// to the op applier and committed with ONE plain sb.Apply — exactly what +// the Block* RPC handlers do. Apply gives per-block restriction checks, +// undo recording, hooks/events, and the minimal id-matched change diff; +// nothing the state already owns (relation links, structural blocks, +// resolvedLayout, extra object types) is ever dropped, because a child +// state inherits it all (review findings A1–A3/A5 fixed by construction). +// That inheritance is why the reset path's preserveEditorOwnedState repair +// left with it — the ops path never needed it (APIV2.md §8.27). +type objectMutateAdapter struct { + getter cache.ObjectGetter +} + +func newObjectMutateAdapter(getter cache.ObjectGetter) apicore.ObjectMutator { + return &objectMutateAdapter{getter: getter} +} + +// MutateObject is the PATCH path: one lock, one child state, one ordinary +// Apply. +func (a *objectMutateAdapter) MutateObject(ctx context.Context, spaceId string, objectId string, needs apicore.EditNeeds, apply func(edit apicore.ObjectEdit) error) ([]string, error) { + var heads []string + err := cache.DoContextFullID(a.getter, ctx, domain.FullID{SpaceID: spaceId, ObjectID: objectId}, func(sb smartblock.SmartBlock) error { + // object-level Blocks/Details restrictions are a service-layer concern + // (Apply checks per-block restrictions, not these). Only the axes the + // batch actually touches are demanded — see checkObjectEditable. + if err := checkObjectEditable(sb, needs); err != nil { + return err + } + st := sb.NewState() + edit := apicore.ObjectEdit{ + SbType: sb.Type().ToProto(), + Heads: append([]string(nil), sb.GetDocInfo().Heads...), + State: st, + } + if err := apply(edit); err != nil { + return err + } + // the bundled-revision guard still applies: an op that downgrades an + // installed object's revision must be refused, exactly like the import + // mirror. Untouched revision/sourceObject are inherited by the child + // state, so the guard is a no-op on ordinary PATCHes. + if err := guardBundledRevision(sb, st); err != nil { + return err + } + if err := sb.Apply(st); err != nil { + return fmt.Errorf("apply edit state: %w", err) + } + heads = append([]string(nil), sb.GetDocInfo().Heads...) + return nil + }) + if err != nil { + return nil, err + } + return heads, nil +} + +// checkRestriction returns the object's verdict on ONE restriction axis, or +// nil when that axis is editable. The message names the axis in the API's own +// vocabulary, and the error wraps restriction.ErrRestricted so the service +// layer can classify it (403, not 500). +func checkRestriction(sb smartblock.SmartBlock, r model.RestrictionsObjectRestriction) error { + if err := sb.Restrictions().Object.Check(r); err != nil { + switch r { + case model.Restrictions_Blocks: + return fmt.Errorf("%w: this object's blocks cannot be edited through the API", err) + case model.Restrictions_Details: + return fmt.Errorf("%w: this object's properties cannot be edited through the API", err) + } + return err + } + return nil +} + +// checkObjectEditable enforces the object's own restrictions on the API edit +// path (A4): object-level Blocks/Details restrictions are not what Apply +// checks, so without this an agent could rewrite objects the editor itself +// refuses to edit. +// +// The check is per-axis (surface review M1): a set and a collection carry +// Restrictions_Blocks but NOT Restrictions_Details, so demanding both of +// every edit made renaming a set — and every add_items/remove_items, the only +// v2 route into an existing collection — permanently refuse. needs comes +// from the ops the batch actually contains. +func checkObjectEditable(sb smartblock.SmartBlock, needs apicore.EditNeeds) error { + if needs.Blocks { + if err := checkRestriction(sb, model.Restrictions_Blocks); err != nil { + return err + } + } + if needs.Details { + if err := checkRestriction(sb, model.Restrictions_Details); err != nil { + return err + } + } + return nil +} + +// guardBundledRevision mirrors the import path's revision guard (objectcreator +// resetState / preserveBundledIdentity): an object derived from a bundled +// definition must never be reset to an older revision, and an incoming state +// that carries neither sourceObject nor revision keeps the live object's — +// resetting drops details the new state omits, and these two tie an installed +// object back to its bundled definition. +func guardBundledRevision(sb smartblock.SmartBlock, st *state.State) error { + incomingDerived := st.Details().GetInt64(bundle.RelationKeyRevision) > 0 || + st.Details().GetString(bundle.RelationKeySourceObject) != "" + if incomingDerived { + current := sb.Details().GetInt64(bundle.RelationKeyRevision) + incoming := st.Details().GetInt64(bundle.RelationKeyRevision) + if current > incoming { + return fmt.Errorf("the live object carries revision %d, newer than the document's %d — bundled definitions are never downgraded", current, incoming) + } + return nil + } + for _, key := range []domain.RelationKey{bundle.RelationKeySourceObject, bundle.RelationKeyRevision} { + if value := sb.Details().Get(key); value.Ok() { + st.SetDetail(key, value) + } + } + return nil +} diff --git a/core/api/objectmutateadapter_test.go b/core/api/objectmutateadapter_test.go new file mode 100644 index 0000000000..bfc1af04f0 --- /dev/null +++ b/core/api/objectmutateadapter_test.go @@ -0,0 +1,214 @@ +package api + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + apicore "github.com/anyproto/anytype-heart/core/api/core" + "github.com/anyproto/anytype-heart/core/block/editor/smartblock" + "github.com/anyproto/anytype-heart/core/block/editor/smartblock/smarttest" + "github.com/anyproto/anytype-heart/core/block/editor/state" + "github.com/anyproto/anytype-heart/core/block/restriction" + "github.com/anyproto/anytype-heart/core/block/simple" + "github.com/anyproto/anytype-heart/core/domain" + "github.com/anyproto/anytype-heart/pkg/lib/bundle" + coresb "github.com/anyproto/anytype-heart/pkg/lib/core/smartblock" + "github.com/anyproto/anytype-heart/pkg/lib/pb/model" +) + +// allAxes is what the PATCH tests below use unless they are specifically +// about the per-axis gate. +var allAxes = apicore.EditNeeds{Blocks: true, Details: true} + +// layoutHolder is the minimum restriction.RestrictionHolder needed to ask the +// real table what a layout restricts. +type layoutHolder struct{ layout model.ObjectTypeLayout } + +func (h layoutHolder) Type() coresb.SmartBlockType { return coresb.SmartBlockTypePage } +func (h layoutHolder) Layout() (model.ObjectTypeLayout, bool) { return h.layout, true } +func (h layoutHolder) UniqueKey() domain.UniqueKey { return nil } +func (h layoutHolder) LocalDetails() *domain.Details { return domain.NewDetails() } + +// TestCheckObjectEditable covers A4: the apply runs with NoRestrictions, so +// the adapter must enforce the object's own restrictions itself — and M1: +// only the axes the batch actually touches. +func TestCheckObjectEditable(t *testing.T) { + blockRestricted := func() *smarttest.SmartTest { + sb := smarttest.New("obj1") + sb.TestRestrictions = restriction.Restrictions{ + Object: restriction.ObjectRestrictions{model.Restrictions_Blocks: {}}, + } + return sb + } + detailsRestricted := func() *smarttest.SmartTest { + sb := smarttest.New("obj1") + sb.TestRestrictions = restriction.Restrictions{ + Object: restriction.ObjectRestrictions{model.Restrictions_Details: {}}, + } + return sb + } + + t.Run("an unrestricted object is editable on every axis", func(t *testing.T) { + require.NoError(t, checkObjectEditable(smarttest.New("obj1"), allAxes)) + }) + + t.Run("block-restricted objects refuse a block edit", func(t *testing.T) { + err := checkObjectEditable(blockRestricted(), apicore.EditNeeds{Blocks: true}) + require.Error(t, err) + assert.Contains(t, err.Error(), "blocks cannot be edited") + }) + + t.Run("details-restricted objects refuse a property edit", func(t *testing.T) { + err := checkObjectEditable(detailsRestricted(), apicore.EditNeeds{Details: true}) + require.Error(t, err) + assert.Contains(t, err.Error(), "properties cannot be edited") + }) + + // M1: a set and a collection restrict Blocks but NOT Details. Demanding + // both of every edit made renaming a set — and every add_items, the only + // v2 route into an existing collection — permanently refuse. + t.Run("M1: a block-restricted object still accepts a property edit", func(t *testing.T) { + require.NoError(t, checkObjectEditable(blockRestricted(), apicore.EditNeeds{Details: true})) + }) + + t.Run("M1: a block-restricted object still accepts an item edit", func(t *testing.T) { + // add_items/remove_items mutate the collection store, which no object + // restriction governs — so they need neither axis. + require.NoError(t, checkObjectEditable(blockRestricted(), apicore.EditNeeds{})) + }) + + t.Run("a details-restricted object still accepts a block edit", func(t *testing.T) { + require.NoError(t, checkObjectEditable(detailsRestricted(), apicore.EditNeeds{Blocks: true})) + }) + + t.Run("the real set and collection restrictions allow properties and items", func(t *testing.T) { + // pinned against the LIVE restriction table, not a hand-built one: + // M1 exists because sets/collections restrict Blocks and not Details. + // If objRestrictEdit ever gains Details this fails loudly, because the + // per-axis gate above would then start refusing renames for real. + for _, layout := range []model.ObjectTypeLayout{model.ObjectType_set, model.ObjectType_collection} { + r := restriction.GetRestrictions(layoutHolder{layout: layout}).Object + assert.Error(t, r.Check(model.Restrictions_Blocks), "layout %v should restrict blocks", layout) + assert.NoError(t, r.Check(model.Restrictions_Details), "layout %v must NOT restrict details", layout) + } + }) + + t.Run("a custom object type restricts blocks — the fact update_view's classification rests on", func(t *testing.T) { + // pinned against the LIVE table (getRestrictionsForUniqueKey): a + // custom type object carries Restrictions_Blocks (like sets and + // collections) and not Details. The update_view op is classified as + // needing NEITHER axis (v2OpEditNeeds) precisely because all three + // dataview-bearing object classes refuse the Blocks axis while the + // native dataview view surface (v1's BlockDataviewView* RPCs) is + // ungated — if this pin fails, that classification needs re-deriving. + uk, err := domain.NewUniqueKey(coresb.SmartBlockTypeObjectType, "plant") + require.NoError(t, err) + r := restriction.GetRestrictions(ukHolder{uk: uk}).Object + assert.Error(t, r.Check(model.Restrictions_Blocks), "a custom type object should restrict blocks") + assert.NoError(t, r.Check(model.Restrictions_Details), "a custom type object must NOT restrict details") + }) +} + +// ukHolder is the minimum RestrictionHolder for the unique-key restriction +// path (type objects, relations). +type ukHolder struct{ uk domain.UniqueKey } + +func (h ukHolder) Type() coresb.SmartBlockType { return h.uk.SmartblockType() } +func (h ukHolder) Layout() (model.ObjectTypeLayout, bool) { return 0, false } +func (h ukHolder) UniqueKey() domain.UniqueKey { return h.uk } +func (h ukHolder) LocalDetails() *domain.Details { return domain.NewDetails() } + +// fakeGetter serves one smartblock to the adapter's DoContextFullID. +type fakeGetter struct { + sb smartblock.SmartBlock +} + +func (g fakeGetter) GetObject(_ context.Context, _ string) (smartblock.SmartBlock, error) { + return g.sb, nil +} + +func (g fakeGetter) GetObjectByFullID(_ context.Context, _ domain.FullID) (smartblock.SmartBlock, error) { + return g.sb, nil +} + +// TestMutateObject covers the PATCH commit path: apply receives a child +// state of the live doc, and a nil return commits it with one ordinary +// Apply. +func TestMutateObject(t *testing.T) { + ctx := context.Background() + + newSb := func() *smarttest.SmartTest { + sb := smarttest.New("obj1") + sb.AddBlock(simple.New(&model.Block{Id: "obj1", ChildrenIds: []string{"p1"}})) + sb.AddBlock(simple.New(&model.Block{Id: "p1", + Content: &model.BlockContentOfText{Text: &model.BlockContentText{Text: "body"}}})) + return sb + } + + t.Run("apply on the child state commits", func(t *testing.T) { + // given + sb := newSb() + adapter := newObjectMutateAdapter(fakeGetter{sb: sb}) + + // when + heads, err := adapter.MutateObject(ctx, "space1", "obj1", allAxes, func(edit apicore.ObjectEdit) error { + b := edit.State.Get("p1") + require.NotNil(t, b) + b.Model().GetText().Text = "edited" + return nil + }) + + // then + require.NoError(t, err) + assert.NotEmpty(t, heads) + assert.Equal(t, "edited", sb.Doc.(*state.State).Pick("p1").Model().GetText().Text, + "the child state landed on the live doc") + }) + + t.Run("an apply error commits nothing", func(t *testing.T) { + sb := newSb() + adapter := newObjectMutateAdapter(fakeGetter{sb: sb}) + + _, err := adapter.MutateObject(ctx, "space1", "obj1", allAxes, func(edit apicore.ObjectEdit) error { + edit.State.Get("p1").Model().GetText().Text = "edited" + return assert.AnError + }) + + require.Error(t, err) + assert.Equal(t, "body", sb.Doc.(*state.State).Pick("p1").Model().GetText().Text) + }) + + t.Run("object restrictions refuse the edit before apply runs", func(t *testing.T) { + sb := newSb() + sb.TestRestrictions = restriction.Restrictions{ + Object: restriction.ObjectRestrictions{model.Restrictions_Blocks: {}}, + } + adapter := newObjectMutateAdapter(fakeGetter{sb: sb}) + + called := false + _, err := adapter.MutateObject(ctx, "space1", "obj1", allAxes, func(apicore.ObjectEdit) error { + called = true + return nil + }) + + require.Error(t, err) + assert.False(t, called) + }) + + t.Run("a revision downgrade is refused", func(t *testing.T) { + sb := newSb() + sb.Doc.(*state.State).SetDetail(bundle.RelationKeyRevision, domain.Int64(3)) + adapter := newObjectMutateAdapter(fakeGetter{sb: sb}) + + _, err := adapter.MutateObject(ctx, "space1", "obj1", allAxes, func(edit apicore.ObjectEdit) error { + edit.State.SetDetail(bundle.RelationKeyRevision, domain.Int64(1)) + return nil + }) + + require.Error(t, err) + assert.Contains(t, err.Error(), "never downgraded") + }) +} diff --git a/core/api/objectprovenanceadapter.go b/core/api/objectprovenanceadapter.go new file mode 100644 index 0000000000..1efc80f248 --- /dev/null +++ b/core/api/objectprovenanceadapter.go @@ -0,0 +1,126 @@ +package api + +import ( + "context" + "fmt" + + "github.com/anyproto/any-sync/commonspace/object/tree/objecttree" + "github.com/anyproto/any-sync/commonspace/objecttreebuilder" + + apicore "github.com/anyproto/anytype-heart/core/api/core" + "github.com/anyproto/anytype-heart/core/block/source/sourceimpl" + "github.com/anyproto/anytype-heart/pb" + "github.com/anyproto/anytype-heart/space" +) + +// objectProvenanceAdapter implements apicore.ObjectProvenance — the DELETE +// enforcement read (APIV2_OBJECT_DELETE.md §10). It reads provenance from +// cryptographically validated change storage, never from details: +// +// 1. the tree is built from local storage the way version history is +// (BuildHistoryTree over the full history) — the in-memory live tree +// may be snapshot-reduced and MUST NOT be used, because the creating +// change can predate its base snapshot; +// 2. the root clause: the signed root header's identity must be this +// account (the §2-grade guarantee that this account created the +// object). NOT every derived tree fails here: BuildDerivedRoot emits +// unsigned roots, but derivePersonalPayload (objectcache/payload.go) +// signs derived roots with the ACCOUNT identity — taken whenever +// personalSpaceId == space.Id() or UseAccountSignature, and +// objectcreator sets UseAccountSignature for every FileObject — so +// accountMatch=true means "this account signed the root", never "this +// is created-tree user content". The DELETE route's sbType allowlist +// owns that exclusion; +// 3. the key clause: the FIRST non-root change, in tree order, must carry +// the same identity, and its pb.Change.IntegrationName is the recorded +// provenance (the raw app name, served verbatim — the caller compares). +// +// Cost: one full-history read of one tree from local storage — the same +// class as opening that object's version history, on a human-scale, +// write-rate-limited DELETE endpoint. +type objectProvenanceAdapter struct { + spaces space.Service + account accountIdProvider +} + +// accountIdProvider is the slice of account.Service this adapter needs: the +// account identity string, the same value space as a signed root header's +// Identity.Account() (both derive from the account sign key). +type accountIdProvider interface { + AccountID() string +} + +func newObjectProvenanceAdapter(spaces space.Service, account accountIdProvider) apicore.ObjectProvenance { + return &objectProvenanceAdapter{spaces: spaces, account: account} +} + +func (a *objectProvenanceAdapter) CreatorProvenance(ctx context.Context, spaceId string, objectId string) (accountMatch bool, integrationName string, err error) { + spc, err := a.spaces.Get(ctx, spaceId) + if err != nil { + return false, "", fmt.Errorf("get space %s: %w", spaceId, err) + } + treeBuilder := spc.TreeBuilder() + if treeBuilder == nil { + return false, "", fmt.Errorf("space %s has no tree builder", spaceId) + } + // empty opts = the full history: root and first change guaranteed present + ht, err := treeBuilder.BuildHistoryTree(ctx, objectId, objecttreebuilder.HistoryTreeOpts{}) + if err != nil { + return false, "", fmt.Errorf("build history tree for %s: %w", objectId, err) + } + return creatorProvenanceFromTree(ht, a.account.AccountID()) +} + +// provenanceTree is the slice of objecttree.ReadableObjectTree the +// provenance read consumes — narrowed so the clause logic is testable +// against fixture trees (§15). +type provenanceTree interface { + Id() string + UnmarshalledHeader() *objecttree.Change + IterateRoot(convert objecttree.ChangeConvertFunc, iterate objecttree.ChangeIterateFunc) error +} + +// creatorProvenanceFromTree evaluates the §10 clauses on a built tree. +// Every ambiguity resolves toward "no provenance" — the caller refuses on +// anything short of a full match, so the safe direction here is empty, not +// guessed. +func creatorProvenanceFromTree(tree provenanceTree, ownAccount string) (accountMatch bool, integrationName string, err error) { + root := tree.UnmarshalledHeader() + if root == nil || root.Identity == nil || ownAccount == "" || root.Identity.Account() != ownAccount { + // other members' objects and UNSIGNED derived roots: the root clause + // fails, the key clause is not consulted. Account-signed derived + // shapes (personal-space derives, FileObjects everywhere) pass this + // clause — see the header note; the caller's allowlist excludes the + // system ones. + return false, "", nil + } + + // take the FIRST non-root change in tree order; the same convert version + // history's state build uses decrypts and unmarshals it + var first *objecttree.Change + iterErr := tree.IterateRoot(sourceimpl.NewUnmarshalTreeChange(), func(change *objecttree.Change) bool { + if change.Id == tree.Id() { + return true // the root itself — not a content change + } + first = change + return false + }) + if iterErr != nil { + return false, "", fmt.Errorf("iterate history tree %s: %w", tree.Id(), iterErr) + } + if first == nil { + // no content change yet (the §10 creation-race window): the account + // owns the tree but nothing records a key — fail-closed as "no stamp" + return true, "", nil + } + if first.Identity == nil || first.Identity.Account() != ownAccount { + // first content change signed by someone else (same-account other + // device cannot happen — but fail closed rather than reason about it) + return true, "", nil + } + model, ok := first.Model.(*pb.Change) + if !ok || model == nil { + return true, "", nil + } + return true, model.IntegrationName, nil +} diff --git a/core/api/objectprovenanceadapter_test.go b/core/api/objectprovenanceadapter_test.go new file mode 100644 index 0000000000..2d23062206 --- /dev/null +++ b/core/api/objectprovenanceadapter_test.go @@ -0,0 +1,173 @@ +package api + +// Fixture-tree tests for the §10 enforcement read (APIV2_OBJECT_DELETE.md +// §15). Each fixture is a tree whose ROOT IDENTITY and FIRST CONTENT CHANGE +// are independently controlled, so every clause can fail separately: a +// fixture whose root doesn't match cannot pass by accident, and a recorded +// name is only served when BOTH clauses hold. The first change's bytes go +// through the real MarshalChange → UnmarshalTreeChange convert path, so a +// wire-level regression (the field dropped on read) fails here too. + +import ( + "errors" + "testing" + + "github.com/anyproto/any-sync/commonspace/object/tree/objecttree" + anycrypto "github.com/anyproto/any-sync/util/crypto" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/anyproto/anytype-heart/core/block/source/sourceimpl" + "github.com/anyproto/anytype-heart/pb" +) + +// fakeProvenanceTree serves changes the way objecttree.IterateFrom does: +// the root is handed over un-converted (skipped by id), every later change +// with raw Data runs through the convert func and lands in Model. +type fakeProvenanceTree struct { + id string + header *objecttree.Change + changes []*objecttree.Change + iterErr error +} + +func (f *fakeProvenanceTree) Id() string { return f.id } +func (f *fakeProvenanceTree) UnmarshalledHeader() *objecttree.Change { return f.header } + +func (f *fakeProvenanceTree) IterateRoot(convert objecttree.ChangeConvertFunc, iterate objecttree.ChangeIterateFunc) error { + if f.iterErr != nil { + return f.iterErr + } + for _, c := range f.changes { + if c.Model == nil && c.Id != f.id && convert != nil { + model, err := convert(c, c.Data) + if err != nil { + return err + } + c.Model = model + } + if !iterate(c) { + break + } + } + return nil +} + +// contentChange builds a marshaled, snapshot-less pb.Change carrying the +// given integration name, signed-by proxy via the identity on the tree change. +func contentChange(t *testing.T, id string, identity anycrypto.PubKey, integrationName string) *objecttree.Change { + c := &pb.Change{ + Content: []*pb.ChangeContent{{Value: &pb.ChangeContentValueOfBlockRemove{ + BlockRemove: &pb.ChangeBlockRemove{Ids: []string{"b1"}}, + }}}, + IntegrationName: integrationName, + } + data, dataType, err := sourceimpl.MarshalChange(c) + require.NoError(t, err) + return &objecttree.Change{Id: id, Identity: identity, Data: data, DataType: dataType} +} + +func TestCreatorProvenanceFromTree(t *testing.T) { + _, ownKey, err := anycrypto.GenerateRandomEd25519KeyPair() + require.NoError(t, err) + _, otherKey, err := anycrypto.GenerateRandomEd25519KeyPair() + require.NoError(t, err) + ownAccount := ownKey.Account() + + tree := func(rootIdentity anycrypto.PubKey, changes ...*objecttree.Change) *fakeProvenanceTree { + root := &objecttree.Change{Id: "root", Identity: rootIdentity} + return &fakeProvenanceTree{ + id: "root", + header: root, + changes: append([]*objecttree.Change{root}, changes...), + } + } + + t.Run("own root and stamped first change → the recorded name", func(t *testing.T) { + // the ALLOW fixture — the only shape that may serve a name. It can + // fail three independent ways: root clause broken, first-change pick + // broken, or the wire field dropped in the convert path. + match, key, err := creatorProvenanceFromTree( + tree(ownKey, contentChange(t, "c1", ownKey, "Claude Desktop")), ownAccount) + require.NoError(t, err) + assert.True(t, match) + assert.Equal(t, "Claude Desktop", key) + }) + + t.Run("own root, unstamped first change (legacy shape) → no name", func(t *testing.T) { + // the fixture HAS a full history with a first change — so a refusal + // for key=="" is distinguishable from 'the check is broken': the same + // fixture with a stamp (above) serves it + match, key, err := creatorProvenanceFromTree( + tree(ownKey, contentChange(t, "c1", ownKey, "")), ownAccount) + require.NoError(t, err) + assert.True(t, match) + assert.Equal(t, "", key) + }) + + t.Run("a name recorded under ANOTHER key's name is served verbatim", func(t *testing.T) { + // the service compares; the read must not — a read that 'helpfully' + // blanked foreign names would break the §9.5 message naming them + match, key, err := creatorProvenanceFromTree( + tree(ownKey, contentChange(t, "c1", ownKey, "Linear")), ownAccount) + require.NoError(t, err) + assert.True(t, match) + assert.Equal(t, "Linear", key) + }) + + t.Run("derived root (no identity) → no account match", func(t *testing.T) { + // types/properties/chats: BuildDerivedRoot emits no identity; the + // stamp on their creating change must NOT rescue them here + match, key, err := creatorProvenanceFromTree( + tree(nil, contentChange(t, "c1", ownKey, "Claude Desktop")), ownAccount) + require.NoError(t, err) + assert.False(t, match) + assert.Equal(t, "", key) + }) + + t.Run("another member's root → no account match, name not served", func(t *testing.T) { + // their tree can carry any name string (§6: it is just a string) — + // clause 1 is what makes that unusable + match, key, err := creatorProvenanceFromTree( + tree(otherKey, contentChange(t, "c1", otherKey, "Claude Desktop")), ownAccount) + require.NoError(t, err) + assert.False(t, match) + assert.Equal(t, "", key) + }) + + t.Run("first change signed by a different identity → no name", func(t *testing.T) { + // §10-3: the key clause requires the first change's identity to equal + // the root's; a foreign-signed first change contributes nothing + match, key, err := creatorProvenanceFromTree( + tree(ownKey, contentChange(t, "c1", otherKey, "Claude Desktop")), ownAccount) + require.NoError(t, err) + assert.True(t, match) + assert.Equal(t, "", key) + }) + + t.Run("root only, no content change yet → no name", func(t *testing.T) { + // the §10 creation-race edge: owned, but nothing records a key + match, key, err := creatorProvenanceFromTree(tree(ownKey), ownAccount) + require.NoError(t, err) + assert.True(t, match) + assert.Equal(t, "", key) + }) + + t.Run("empty own account → no match ever", func(t *testing.T) { + match, key, err := creatorProvenanceFromTree( + tree(ownKey, contentChange(t, "c1", ownKey, "Claude Desktop")), "") + require.NoError(t, err) + assert.False(t, match) + assert.Equal(t, "", key) + }) + + t.Run("iteration failure → error out, never a verdict", func(t *testing.T) { + // fail-closed in the error direction: the caller must see err and + // refuse; a (false, "", nil) here would be indistinguishable from a + // legitimate 'not yours' + broken := tree(ownKey) + broken.iterErr = errors.New("storage failure") + _, _, err := creatorProvenanceFromTree(broken, ownAccount) + require.Error(t, err) + }) +} diff --git a/core/api/objectreadadapter.go b/core/api/objectreadadapter.go new file mode 100644 index 0000000000..09a2376f41 --- /dev/null +++ b/core/api/objectreadadapter.go @@ -0,0 +1,66 @@ +package api + +import ( + "context" + "fmt" + + apicore "github.com/anyproto/anytype-heart/core/api/core" + "github.com/anyproto/anytype-heart/core/block/cache" + "github.com/anyproto/anytype-heart/core/block/editor/smartblock" + "github.com/anyproto/anytype-heart/core/domain" + "github.com/anyproto/anytype-heart/pkg/lib/pb/model" + "github.com/anyproto/anytype-heart/util/pbtypes" +) + +// objectReadAdapter implements apicore.ObjectReader over the block service's +// object cache: the API v2 read path (APIV2.md §8) — live smartblock state → +// snapshot, with the tree heads captured under the same lock so the etag and +// the content are consistent. +type objectReadAdapter struct { + getter cache.ObjectGetter +} + +func newObjectReadAdapter(getter cache.ObjectGetter) apicore.ObjectReader { + return &objectReadAdapter{getter: getter} +} + +func (a *objectReadAdapter) ReadObject(ctx context.Context, spaceId string, objectId string) (apicore.ObjectRead, error) { + var read apicore.ObjectRead + err := cache.DoContextFullID(a.getter, ctx, domain.FullID{SpaceID: spaceId, ObjectID: objectId}, func(sb smartblock.SmartBlock) error { + read = readLiveState(sb) + return nil + }) + if err != nil { + return apicore.ObjectRead{}, fmt.Errorf("read live object state: %w", err) + } + return read, nil +} + +// readLiveState captures one consistent read of a locked smartblock: +// snapshot and tree heads under the same lock, so the derived etag and the +// content always agree. Shared by the read and mutate adapters. +func readLiveState(sb smartblock.SmartBlock) apicore.ObjectRead { + st := sb.NewState() + return apicore.ObjectRead{ + SbType: sb.Type().ToProto(), + Snapshot: &model.SmartBlockSnapshotBase{ + Blocks: st.BlocksToSave(), + Details: st.CombinedDetails().ToProto(), + ObjectTypes: domain.MarshalTypeKeys(st.ObjectTypeKeys()), + // C1: st.Store() returns the LIVE shared *types.Struct (no copy, + // unlike BlocksToSave/CombinedDetails above). It is marshaled after + // this lock releases, so a concurrent editor mutating the store + // (e.g. adding/removing a collection item) would race the marshal — + // an uncatchable "concurrent map read and map write" fatal. Copy it + // under the lock. Must stay a copy. + Collections: pbtypes.CopyStruct(st.Store(), true), + Key: st.UniqueKeyInternal(), + FileInfo: st.GetFileInfo().ToModel(), + }, + Heads: append([]string(nil), sb.GetDocInfo().Heads...), + // captured under the same lock so a dry run sees the same verdict the + // real edit will (review C′3) + BlocksRefused: checkRestriction(sb, model.Restrictions_Blocks), + DetailsRefused: checkRestriction(sb, model.Restrictions_Details), + } +} diff --git a/core/api/objectreadadapter_test.go b/core/api/objectreadadapter_test.go new file mode 100644 index 0000000000..f21fde0044 --- /dev/null +++ b/core/api/objectreadadapter_test.go @@ -0,0 +1,53 @@ +package api + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/anyproto/anytype-heart/core/block/editor/smartblock" + "github.com/anyproto/anytype-heart/core/block/editor/smartblock/smarttest" + "github.com/anyproto/anytype-heart/core/block/simple" + "github.com/anyproto/anytype-heart/core/domain" + "github.com/anyproto/anytype-heart/pkg/lib/pb/model" +) + +// fakeObjectGetter hands the adapter a prepared smartblock. +type fakeObjectGetter struct{ sb smartblock.SmartBlock } + +func (f fakeObjectGetter) GetObject(context.Context, string) (smartblock.SmartBlock, error) { + return f.sb, nil +} + +func (f fakeObjectGetter) GetObjectByFullID(context.Context, domain.FullID) (smartblock.SmartBlock, error) { + return f.sb, nil +} + +// TestObjectReadAdapter covers the load-bearing C7/§8 invariant: the snapshot +// and the heads (from which the etag derives) are captured from the same +// smartblock under one lock, so the returned etag and content are consistent. +func TestObjectReadAdapter(t *testing.T) { + sb := smarttest.New("obj1") + sb.AddBlock(simple.New(&model.Block{Id: "obj1", ChildrenIds: []string{"p1"}})) + sb.AddBlock(simple.New(&model.Block{Id: "p1", + Content: &model.BlockContentOfText{Text: &model.BlockContentText{Text: "hello"}}})) + + adapter := newObjectReadAdapter(fakeObjectGetter{sb: sb}) + read, err := adapter.ReadObject(context.Background(), "space1", "obj1") + require.NoError(t, err) + + // the heads returned are exactly the smartblock's heads at read time + assert.Equal(t, sb.GetDocInfo().Heads, read.Heads) + require.NotNil(t, read.Snapshot) + + // the snapshot carries the state's content (proves it was read, not empty) + var texts []string + for _, b := range read.Snapshot.Blocks { + if txt := b.GetText(); txt != nil { + texts = append(texts, txt.Text) + } + } + assert.Contains(t, texts, "hello") +} diff --git a/core/api/openapiprose_test.go b/core/api/openapiprose_test.go new file mode 100644 index 0000000000..88414cb158 --- /dev/null +++ b/core/api/openapiprose_test.go @@ -0,0 +1,196 @@ +package api + +// openapiprose_test.go guards the prose of the v2 OpenAPI document: the +// summaries and descriptions a reader outside this repository actually sees. +// +// It reads the generated document (core/api/docs/v2/openapi.json, embedded +// above as openapiV2JSON and served verbatim at /v2/docs/openapi.json) rather +// than the annotations in core/api/v2/handler/*.go. The generated document is +// what reaches a reader: it catches whatever swag rewrites on the way, and it +// catches prose that arrives from a model comment rather than from a handler. +// +// v1 is deliberately not covered. Its document is already published at +// developers.anytype.io; guarding it here would be a rewrite request for a +// document this package does not otherwise touch. + +import ( + "encoding/json" + "regexp" + "sort" + "strconv" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// maxProseDescription bounds every description in the document except the +// API-level one. A description carries only what is specific to its endpoint; +// anything longer is boilerplate that belongs in the API description, which +// states the shared behaviour (auth, idempotency keys, dry runs, preconditions, +// pagination, the error shape, warnings) once for all 45 operations. +const maxProseDescription = 400 + +// apiDescription is the one description exempt from maxProseDescription: it is +// where the shared behaviour is stated, so it is long by design. The pattern +// rules below still apply to it. +const apiDescription = "/info/description" + +// proseRules reject references that mean nothing outside this repository, and +// the punctuation the v2 prose does not use. +var proseRules = []struct { + name string + pattern *regexp.Regexp + fix string +}{ + { + name: "numbered constraint", + pattern: regexp.MustCompile(`C\d+`), + fix: "state the rule in plain words; a constraint number names nothing a reader can look up", + }, + { + name: "section mark", + pattern: regexp.MustCompile(`§`), + fix: "state the rule in plain words; the sections are internal documents", + }, + { + name: "phase name", + pattern: regexp.MustCompile(`(?i)phase \d`), + fix: "phase names describe how this API was built, not how it behaves", + }, + { + name: "internal filename", + pattern: regexp.MustCompile(`[\w/]+\.md`), + fix: "a reader cannot open a file in this repository; say what the file says", + }, + { + name: "em dash", + pattern: regexp.MustCompile("—"), + fix: "use a full stop; two short sentences beat one qualified sentence", + }, +} + +// allCapsRun finds runs of three or more capitals: emphasis by shouting. +var allCapsRun = regexp.MustCompile(`\b[A-Z]{3,}\b`) + +// allowedAllCaps are the all-caps tokens that are spellings rather than +// emphasis. Keeping the set this small is the point: it leaves no room for an +// EVERY or an ONLY to come back in. +var allowedAllCaps = map[string]bool{ + "GET": true, "POST": true, "PATCH": true, "PUT": true, "DELETE": true, + "JSON": true, "URL": true, "UTF": true, "API": true, +} + +// proseEntry is one piece of prose in the document, addressed by JSON pointer +// so a failure names the annotation to edit. +type proseEntry struct { + pointer string + kind string // "summary" or "description" + text string +} + +// collectProse walks the document and returns every summary and description. +// Values under example, examples and default are skipped: those are data a +// caller sends, not prose a caller reads. +func collectProse(t *testing.T, doc []byte) []proseEntry { + t.Helper() + + var root any + require.NoError(t, json.Unmarshal(doc, &root)) + + var entries []proseEntry + var walk func(node any, pointer string) + walk = func(node any, pointer string) { + switch n := node.(type) { + case map[string]any: + keys := make([]string, 0, len(n)) + for k := range n { + keys = append(keys, k) + } + sort.Strings(keys) + for _, k := range keys { + if k == "example" || k == "examples" || k == "default" { + continue + } + child := pointer + "/" + k + if text, ok := n[k].(string); ok && (k == "summary" || k == "description") { + entries = append(entries, proseEntry{pointer: child, kind: k, text: text}) + continue + } + walk(n[k], child) + } + case []any: + for i, v := range n { + walk(v, pointer+"/"+strconv.Itoa(i)) + } + } + } + walk(root, "") + + require.NotEmpty(t, entries, "the embedded v2 document carries no prose at all") + return entries +} + +func TestV2DocumentProse(t *testing.T) { + // given: the generated v2 document, exactly as it is served + entries := collectProse(t, openapiV2JSON) + + t.Run("no reference a reader outside this repository cannot follow", func(t *testing.T) { + for _, entry := range entries { + for _, rule := range proseRules { + // then + assert.NotRegexp(t, rule.pattern, entry.text, + "%s carries a %s (%s): %s", entry.pointer, rule.name, rule.fix, entry.text) + } + } + }) + + t.Run("no emphasis by shouting", func(t *testing.T) { + for _, entry := range entries { + for _, run := range allCapsRun.FindAllString(entry.text, -1) { + // then + assert.True(t, allowedAllCaps[run], + "%s shouts %q: say the dangerous thing first instead of capitalising it — %s", + entry.pointer, run, entry.text) + } + } + }) + + t.Run("a description carries only what is specific to its endpoint", func(t *testing.T) { + for _, entry := range entries { + if entry.kind != "description" || entry.pointer == apiDescription { + continue + } + // then + assert.LessOrEqual(t, len(entry.text), maxProseDescription, + "%s is %d characters: shared behaviour belongs in the API description in core/api/v2/doc.go, and an empty description is a correct outcome", + entry.pointer, len(entry.text)) + } + }) + + t.Run("every operation has a one-line summary", func(t *testing.T) { + var doc struct { + Paths map[string]map[string]struct { + Summary string `json:"summary"` + } `json:"paths"` + } + require.NoError(t, json.Unmarshal(openapiV2JSON, &doc)) + + methods := map[string]bool{"get": true, "post": true, "put": true, "patch": true, "delete": true} + operations := 0 + for path, item := range doc.Paths { + for method, op := range item { + if !methods[method] { + continue + } + operations++ + // then + require.NotEmpty(t, op.Summary, "%s %s has no summary", strings.ToUpper(method), path) + assert.NotContains(t, op.Summary, "\n", + "%s %s has a multi-line summary: %s", strings.ToUpper(method), path, op.Summary) + } + } + assert.NotZero(t, operations) + }) +} diff --git a/core/api/server/docs_test.go b/core/api/server/docs_test.go new file mode 100644 index 0000000000..a597ec03b6 --- /dev/null +++ b/core/api/server/docs_test.go @@ -0,0 +1,73 @@ +package server + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + + "github.com/anyproto/anytype-heart/core/api/core/mock_apicore" + "github.com/anyproto/anytype-heart/core/subscription" + "github.com/anyproto/anytype-heart/pkg/lib/pb/model" +) + +// newDocsFixture builds a server carrying distinguishable bytes for each of +// the four generated documents, so a route wired to the wrong document fails +// on the body rather than on the status code. +func newDocsFixture(t *testing.T) *Server { + mwMock := mock_apicore.NewMockClientCommands(t) + accountMock := mock_apicore.NewMockAccountService(t) + eventMock := mock_apicore.NewMockEventService(t) + crossSpaceSubService := mock_apicore.NewMockCrossSpaceSubscriptionService(t) + chatSubService := mock_apicore.NewMockChatSubscriptionService(t) + fileObjectMock := mock_apicore.NewMockFileObjectService(t) + + crossSpaceSubService.On("Subscribe", mock.Anything, mock.Anything).Return(&subscription.SubscribeResponse{}, nil).Maybe() + accountMock.On("GetInfo", mock.Anything).Return(&model.AccountInfo{TechSpaceId: mockedTechSpaceId}, nil).Once() + + return NewServer(mwMock, accountMock, eventMock, crossSpaceSubService, chatSubService, fileObjectMock, + V2Deps{}, mockedListenAddr, OpenApiDocs{ + V1YAML: []byte("v1-yaml"), + V1JSON: []byte(`{"doc":"v1"}`), + V2YAML: []byte("v2-yaml"), + V2JSON: []byte(`{"doc":"v2"}`), + }) +} + +func TestDocumentationRoutes(t *testing.T) { + // One document per API version (core/api/docs/v1, core/api/docs/v2), and + // the unversioned /docs/* alias still answers with v1 — it is the path + // developers.anytype.io and existing integrations use, so repointing it at + // v2 would break them silently. + for _, tc := range []struct { + path string + contentType string + body string + }{ + {"/docs/openapi.yaml", "application/x-yaml", "v1-yaml"}, + {"/docs/openapi.json", "application/json", `{"doc":"v1"}`}, + {"/v1/docs/openapi.yaml", "application/x-yaml", "v1-yaml"}, + {"/v1/docs/openapi.json", "application/json", `{"doc":"v1"}`}, + {"/v2/docs/openapi.yaml", "application/x-yaml", "v2-yaml"}, + {"/v2/docs/openapi.json", "application/json", `{"doc":"v2"}`}, + } { + t.Run(tc.path, func(t *testing.T) { + // given: no Authorization header — the documents are served ahead + // of the authenticated /v1 and /v2 groups, as /docs/* always was + srv := newDocsFixture(t) + req := httptest.NewRequest(http.MethodGet, tc.path, nil) + req.Host = localApiHost + w := httptest.NewRecorder() + + // when + srv.Engine().ServeHTTP(w, req) + + // then + require.Equal(t, http.StatusOK, w.Code) + require.Equal(t, tc.body, w.Body.String()) + require.Contains(t, w.Header().Get("Content-Type"), tc.contentType) + }) + } +} diff --git a/core/api/server/grant_gate_test.go b/core/api/server/grant_gate_test.go new file mode 100644 index 0000000000..5b1957758b --- /dev/null +++ b/core/api/server/grant_gate_test.go @@ -0,0 +1,545 @@ +package server + +// grant_gate_test.go pins the P1 space-grant enforcement end to end through +// the real engine: the /v2 gate (apiv2.ensureSpaceGrant), the /v1 rejection +// of granted keys, the service-layer fan-out constraint, and the +// route-classification conformance that keeps all of it from rotting. + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + + "github.com/anyproto/anytype-heart/core/api/util" + apiv2 "github.com/anyproto/anytype-heart/core/api/v2" + "github.com/anyproto/anytype-heart/core/domain" + "github.com/anyproto/anytype-heart/pb" + "github.com/anyproto/anytype-heart/pkg/lib/bundle" + "github.com/anyproto/anytype-heart/pkg/lib/localstore/objectstore" + "github.com/anyproto/anytype-heart/pkg/lib/pb/model" +) + +// registerGrantTestSpace adds a spaceView for spaceId to BOTH tech-space +// indexes the fixture wires differently: the store's own tech space +// (objectstore.TestTechSpaceId — what GetSpaceViewDetails/ensureSpace +// resolve against) and the v2 service's configured one (mockedTechSpaceId — +// what ListSpaces/spaceRefs enumerate). +func registerGrantTestSpace(t *testing.T, fx *fixture, spaceId, name string) { + for _, techSpace := range []string{objectstore.TestTechSpaceId, mockedTechSpaceId} { + fx.objectStore.AddObjects(t, techSpace, []objectstore.TestObject{{ + bundle.RelationKeyId: domain.String("spaceView_" + spaceId + "_" + techSpace), + bundle.RelationKeyResolvedLayout: domain.Int64(int64(model.ObjectType_spaceView)), + bundle.RelationKeyTargetSpaceId: domain.String(spaceId), + bundle.RelationKeyName: domain.String(name), + }}) + } +} + +// grantedSession caches a JsonAPI session entry carrying the given grant. +func grantedSession(fx *fixture, key string, grant *util.ApiGrant) { + fx.KeyToToken = map[string]ApiSessionEntry{ + key: {Token: "tok", AppName: "agent", Scope: model.AccountAuth_JsonAPI, Grant: grant}, + } +} + +func serveWithKey(fx *fixture, method, path, key string) *httptest.ResponseRecorder { + return serveWithKeyBody(fx, method, path, key, `{}`) +} + +func serveWithKeyBody(fx *fixture, method, path, key, body string) *httptest.ResponseRecorder { + w := httptest.NewRecorder() + req := httptest.NewRequest(method, path, strings.NewReader(body)) + req.Host = localApiHost + req.Header.Set("Authorization", "Bearer "+key) + fx.Engine().ServeHTTP(w, req) + return w +} + +// knownRouteParams is the closed set of path-param names the /v2 surface +// may use. The gate resolves the addressed space from exactly +// apiv2.SpaceParam, so a space-addressing route under any OTHER name +// (`:workspace_id`, `:spaceId`) would present an empty space id and the +// walk below would then force it into a global class — where the +// natural-looking choices pass the gate with no space check at all. An +// unknown param name is therefore a conformance FAILURE, making the new +// name a review-visible decision instead of a silent reclassification. +var knownRouteParams = map[string]bool{ + apiv2.SpaceParam: true, + "object_id": true, + "type": true, + "key": true, + "kind": true, + "op": true, + "set_id": true, + "collection_id": true, + "chat_id": true, + "message_id": true, +} + +// substituteRouteParams replaces every :param / *param segment so gin +// routes a probe to the registered handler. +func substituteRouteParams(path string) string { + segments := strings.Split(path, "/") + for i, segment := range segments { + if strings.HasPrefix(segment, ":") || strings.HasPrefix(segment, "*") { + segments[i] = "x" + } + } + return strings.Join(segments, "/") +} + +func TestV2RouteAuthzConformance(t *testing.T) { + // The structural guarantee that P1b's coverage does not rot: every + // registered /v2 route either carries :space_id or appears in the + // global-route registry, and EVERY route appears in the read/write + // classification. A new route that skips classification fails here, in + // CI, instead of shipping as a silent authorization hole. + fx := newV2ServerFixture(t) + // The walk covers only what the fixture registers: every conditional + // route group MUST be enabled here, or its routes would be invisible to + // both directions of the check. A future conditionally-registered group + // must add its enablement (and a flag assertion) to this fixture. + require.False(t, fx.v2CreateDisabled, "the conformance fixture must register the create routes") + require.False(t, fx.v2EditDisabled, "the conformance fixture must register the edit routes") + authz := apiv2.RouteAuthzTable() + + registered := map[string]bool{} + v2Routes := 0 + for _, route := range fx.Engine().Routes() { + if !strings.HasPrefix(route.Path, "/v2/") && route.Path != "/v2" { + continue + } + v2Routes++ + key := route.Method + " " + route.Path + registered[key] = true + + for _, segment := range strings.Split(route.Path, "/") { + if !strings.HasPrefix(segment, ":") && !strings.HasPrefix(segment, "*") { + continue + } + param := strings.TrimPrefix(strings.TrimPrefix(segment, ":"), "*") + require.True(t, knownRouteParams[param], + "%s uses the unknown route param %q — if it addresses a space it MUST be named %q (the gate reads exactly that name); otherwise add it to knownRouteParams as a deliberate decision", key, param, apiv2.SpaceParam) + } + + entry, classified := authz[key] + require.True(t, classified, + "%s is not classified in apiv2's v2RouteAuthz — every /v2 route MUST carry an explicit read/write classification (and, without :space_id, a global class); the space-grant gate refuses what it does not know, so an unclassified route is broken for every scoped key", key) + + if strings.Contains(route.Path, ":"+apiv2.SpaceParam) { + require.Empty(t, entry.Global, + "%s carries :space_id and must not be classified global", key) + } else { + require.NotEmpty(t, entry.Global, + "%s has no :space_id and must carry an explicit global-route class (auth-exempt, data-free-allow, service-filtered, or scoped-denied)", key) + } + + // The auth-exempt class states a precondition — "served OUTSIDE the + // authenticated group" — and this probe is what makes it true: an + // auth-exempt route must answer a credential-less request, every + // other /v2 route must 401. Without it, the class is asserted in a + // registry nothing checks, and a future authenticated route (a + // /v2/auth/* surface, say) could be talked into carrying it — which + // would ship the route with neither auth nor the grant gate. + w := httptest.NewRecorder() + req := httptest.NewRequest(route.Method, substituteRouteParams(route.Path), strings.NewReader(`{}`)) + req.Host = localApiHost + fx.Engine().ServeHTTP(w, req) + if entry.Global == apiv2.GlobalAuthExempt { + require.NotEqual(t, http.StatusUnauthorized, w.Code, + "%s is classified auth-exempt but demands credentials — the class is only for routes served outside the authenticated group", key) + } else { + require.Equal(t, http.StatusUnauthorized, w.Code, + "%s answered %d to a credential-less request — every non-exempt /v2 route must sit behind ensureAuthenticated (is it registered on the gated v2 group?)", key, w.Code) + } + } + require.Equal(t, len(authz), v2Routes, + "the engine and the registry must be a bijection — a count mismatch means a route group the fixture failed to register (or duplicate registration)") + + // the reverse direction: a stale registry entry (a renamed or removed + // route) is rot too — the registry must mirror the engine exactly + for key := range authz { + require.True(t, registered[key], + "%s is classified in v2RouteAuthz but not registered on the engine — remove or rename the stale entry", key) + } +} + +func TestV2SpaceGrantGate(t *testing.T) { + readWrite := func(spaces ...string) *util.ApiGrant { + return &util.ApiGrant{Spaces: spaces, Perms: util.GrantPermsReadWrite} + } + readOnly := func(spaces ...string) *util.ApiGrant { + return &util.ApiGrant{Spaces: spaces, Perms: util.GrantPermsRead} + } + + t.Run("granted space passes, non-granted space is 403 space_not_granted", func(t *testing.T) { + // given + fx := newV2ServerFixture(t) + registerGrantTestSpace(t, fx, "spaceA", "Work") + registerGrantTestSpace(t, fx, "spaceB", "Personal") + grantedSession(fx, "scopedKey", readWrite("spaceA")) + fx.eventMock.On("Broadcast", mock.Anything).Return(nil).Maybe() + + // when + granted := serveWithKey(fx, "GET", "/v2/spaces/spaceA", "scopedKey") + denied := serveWithKey(fx, "GET", "/v2/spaces/spaceB", "scopedKey") + + // then + require.Equal(t, http.StatusOK, granted.Code) + require.Contains(t, granted.Body.String(), `"Work"`) + + require.Equal(t, http.StatusForbidden, denied.Code) + body := denied.Body.String() + require.Contains(t, body, `"space_not_granted"`) + require.Contains(t, body, `key not granted space \"spaceB\"`) + require.Contains(t, body, "spaces [spaceA] with readwrite access") + require.Equal(t, `Bearer error="insufficient_scope", scope="space:spaceB:read"`, + denied.Header().Get("WWW-Authenticate")) + }) + + t.Run("the tech space is denied unless explicitly granted", func(t *testing.T) { + // the gate runs BEFORE the v2 service's ensureSpace, which + // deliberately admits the tech space as an ordinary space id — so + // the deny has to happen at the gate, and an explicit grant of the + // tech space id opens it like any other space + fx := newV2ServerFixture(t) + grantedSession(fx, "scopedKey", readWrite("spaceA")) + + denied := serveWithKey(fx, "GET", "/v2/spaces/"+mockedTechSpaceId+"/types", "scopedKey") + require.Equal(t, http.StatusForbidden, denied.Code) + require.Contains(t, denied.Body.String(), `"space_not_granted"`) + + fx.eventMock.On("Broadcast", mock.Anything).Return(nil).Maybe() + grantedSession(fx, "scopedKey", readWrite("spaceA", mockedTechSpaceId)) + granted := serveWithKey(fx, "GET", "/v2/spaces/"+mockedTechSpaceId+"/types", "scopedKey") + require.Equal(t, http.StatusOK, granted.Code) + }) + + t.Run("a read-only grant is refused on EVERY write-classified route", func(t *testing.T) { + // the walk is driven by the same classification table the gate + // enforces, and the conformance test pins that table against the + // engine — so this covers every write route, present and future + fx := newV2ServerFixture(t) + grantedSession(fx, "readKey", readOnly("spaceA")) + registerGrantTestSpace(t, fx, "spaceA", "Work") + + writes := 0 + for key, authz := range apiv2.RouteAuthzTable() { + if authz.Verb != apiv2.RouteVerbWrite || authz.Global == apiv2.GlobalAuthExempt { + continue + } + writes++ + method, path, _ := strings.Cut(key, " ") + probe := path + probe = strings.ReplaceAll(probe, ":space_id", "spaceA") + for _, param := range []string{":object_id", ":type", ":key", ":chat_id", ":message_id"} { + probe = strings.ReplaceAll(probe, param, "x") + } + + w := serveWithKey(fx, method, probe, "readKey") + require.Equal(t, http.StatusForbidden, w.Code, "%s must refuse a read-only grant", key) + if authz.Global == apiv2.GlobalScopedDenied { + // POST /v2/spaces is refused as a global route every granted + // key is denied, before the verb gate is reached + require.Contains(t, w.Body.String(), `"space_not_granted"`, key) + } else { + require.Contains(t, w.Body.String(), `"write_not_granted"`, key) + } + } + require.GreaterOrEqual(t, writes, 20, "the write walk must cover the mutation surface") + }) + + t.Run("a read-only grant passes on representative reads", func(t *testing.T) { + fx := newV2ServerFixture(t) + registerGrantTestSpace(t, fx, "spaceA", "Work") + grantedSession(fx, "readKey", readOnly("spaceA")) + fx.eventMock.On("Broadcast", mock.Anything).Return(nil).Maybe() + + for _, probe := range []struct{ method, path string }{ + {"GET", "/v2/spaces/spaceA"}, + {"GET", "/v2/spaces/spaceA/types"}, + // POST search is classified READ: POST only because it needs a body + {"POST", "/v2/spaces/spaceA/search"}, + {"POST", "/v2/search"}, + {"GET", "/v2/spaces"}, + {"GET", "/v2/schemas"}, + {"POST", "/v2/validate"}, + } { + w := httptest.NewRecorder() + body := `{}` + if probe.path == "/v2/validate" { + body = `{"version":1,"blocks":[]}` + } + req := httptest.NewRequest(probe.method, probe.path, strings.NewReader(body)) + req.Host = localApiHost + req.Header.Set("Authorization", "Bearer readKey") + fx.Engine().ServeHTTP(w, req) + require.Equal(t, http.StatusOK, w.Code, "%s %s", probe.method, probe.path) + } + }) + + t.Run("a legacy nil-grant key is unaffected on /v2", func(t *testing.T) { + fx := newV2ServerFixture(t) + registerGrantTestSpace(t, fx, "spaceA", "Work") + fx.KeyToToken = map[string]ApiSessionEntry{ + "legacyKey": {Token: "tok", AppName: "legacy", Scope: model.AccountAuth_JsonAPI}, + } + fx.eventMock.On("Broadcast", mock.Anything).Return(nil).Maybe() + + w := serveWithKey(fx, "GET", "/v2/spaces/spaceA", "legacyKey") + require.Equal(t, http.StatusOK, w.Code) + }) + + t.Run("GET /v2/spaces lists only granted spaces", func(t *testing.T) { + // given: three live spaces, grant covers one + fx := newV2ServerFixture(t) + registerGrantTestSpace(t, fx, "spaceA", "Work") + registerGrantTestSpace(t, fx, "spaceB", "Personal") + registerGrantTestSpace(t, fx, "spaceC", "Diary") + grantedSession(fx, "scopedKey", readOnly("spaceA")) + fx.eventMock.On("Broadcast", mock.Anything).Return(nil).Maybe() + + // when + w := serveWithKey(fx, "GET", "/v2/spaces", "scopedKey") + + // then: the non-granted spaces must not appear ANYWHERE in the + // response — not as rows, not as names + require.Equal(t, http.StatusOK, w.Code) + body := w.Body.String() + require.Contains(t, body, `"spaceA"`) + require.NotContains(t, body, "spaceB") + require.NotContains(t, body, "spaceC") + require.NotContains(t, body, "Personal") + require.NotContains(t, body, "Diary") + require.Contains(t, body, `"total":1`) + }) + + t.Run("global search returns results ONLY from granted spaces and does not warn about others", func(t *testing.T) { + // given: an object in each of two spaces; the grant covers spaceA. + // The intersection happens on the INPUT space set (spaceRefs), so + // the non-granted space contributes no rows, no totals, and — the + // disclosure channel — no per-space warnings. + fx := newV2ServerFixture(t) + registerGrantTestSpace(t, fx, "spaceA", "Work") + registerGrantTestSpace(t, fx, "spaceB", "Personal") + for spaceId, objectId := range map[string]string{"spaceA": "docA", "spaceB": "docB"} { + fx.objectStore.AddObjects(t, spaceId, []objectstore.TestObject{{ + bundle.RelationKeyId: domain.String(objectId), + bundle.RelationKeyName: domain.String("Doc in " + spaceId), + bundle.RelationKeyResolvedLayout: domain.Int64(int64(model.ObjectType_basic)), + bundle.RelationKeyLastModifiedDate: domain.Int64(1000), + }}) + } + grantedSession(fx, "scopedKey", readOnly("spaceA")) + fx.eventMock.On("Broadcast", mock.Anything).Return(nil).Maybe() + + // when + w := serveWithKey(fx, "POST", "/v2/search", "scopedKey") + + // then + require.Equal(t, http.StatusOK, w.Code) + body := w.Body.String() + require.Contains(t, body, `"docA"`) + require.Contains(t, body, `"total":1`) + require.NotContains(t, body, "docB") + require.NotContains(t, body, "spaceB") + require.NotContains(t, body, "Personal") + require.NotContains(t, body, "warnings") + }) + + t.Run("a non-granted space's skip warning never reaches the wire", func(t *testing.T) { + // given: a type that resolves ONLY in the granted space — the exact + // probe that makes a non-intersected space emit a skip warning + // naming it ("Personal") into the response body. The empty-search + // subtest above cannot catch that channel: with no type to resolve, + // neither space warns whether or not the intersection runs. + fx := newV2ServerFixture(t) + registerGrantTestSpace(t, fx, "spaceA", "Work") + registerGrantTestSpace(t, fx, "spaceB", "Personal") + fx.objectStore.AddObjects(t, "spaceA", []objectstore.TestObject{ + { + bundle.RelationKeyId: domain.String("type-chore"), + bundle.RelationKeyName: domain.String("Chore"), + bundle.RelationKeyUniqueKey: domain.String("ot-chore"), + bundle.RelationKeyResolvedLayout: domain.Int64(int64(model.ObjectType_objectType)), + }, + { + bundle.RelationKeyId: domain.String("choreA"), + bundle.RelationKeyName: domain.String("A chore"), + bundle.RelationKeyType: domain.String("type-chore"), + bundle.RelationKeyResolvedLayout: domain.Int64(int64(model.ObjectType_basic)), + bundle.RelationKeyLastModifiedDate: domain.Int64(2000), + }, + }) + grantedSession(fx, "scopedKey", readOnly("spaceA")) + fx.eventMock.On("Broadcast", mock.Anything).Return(nil).Maybe() + + // when + w := serveWithKeyBody(fx, "POST", "/v2/search", "scopedKey", `{"type":"chore"}`) + + // then + require.Equal(t, http.StatusOK, w.Code) + body := w.Body.String() + require.Contains(t, body, `"choreA"`) + require.NotContains(t, body, "warnings") + require.NotContains(t, body, "Personal") + require.NotContains(t, body, "spaceB") + }) +} + +func TestV1RejectsGrantedKeys(t *testing.T) { + t.Run("a granted key is refused on /v1 with a pointer to /v2", func(t *testing.T) { + // the intended asymmetry: a GRANTED key is refused on /v1 (its + // grant cannot be honored there); a LEGACY key is served on /v1 + // exactly as today. Grant presence decides, never key format. + fx := newFixture(t) + fx.KeyToToken = map[string]ApiSessionEntry{ + "scopedKey": {Token: "tok", AppName: "agent", Scope: model.AccountAuth_JsonAPI, + Grant: &util.ApiGrant{Spaces: []string{"spaceA"}, Perms: util.GrantPermsReadWrite}}, + } + + w := serveWithKey(fx, "GET", "/v1/spaces", "scopedKey") + + require.Equal(t, http.StatusForbidden, w.Code) + body := w.Body.String() + require.Contains(t, body, `"v1_not_available_for_scoped_keys"`) + require.Contains(t, body, "/v2") + require.Contains(t, body, "spaces [spaceA] with readwrite access") + require.Equal(t, `Bearer error="insufficient_scope"`, w.Header().Get("WWW-Authenticate")) + }) + + t.Run("a legacy nil-grant key keeps working on /v1", func(t *testing.T) { + fx := newFixture(t) + fx.KeyToToken = map[string]ApiSessionEntry{ + "legacyKey": {Token: "tok", AppName: "legacy", Scope: model.AccountAuth_JsonAPI}, + } + fx.mwMock.On("ObjectSearch", mock.Anything, mock.Anything). + Return(&pb.RpcObjectSearchResponse{ + Error: &pb.RpcObjectSearchResponseError{Code: pb.RpcObjectSearchResponseError_NULL}, + }, nil).Once() + fx.eventMock.On("Broadcast", mock.Anything).Return(nil).Maybe() + + w := serveWithKey(fx, "GET", "/v1/spaces", "legacyKey") + + require.Equal(t, http.StatusOK, w.Code) + }) +} + +func TestGrantEditTakesEffectOnNextRequest(t *testing.T) { + // The safety-critical coupling from P1a: LinkLocalUpdateApp evicts the + // key's cached HTTP entries via RevokeToken, so an in-place grant + // NARROWING is enforced on the very next request — a stale cached grant + // would be a silent authorization bypass. + fx := newV2ServerFixture(t) + registerGrantTestSpace(t, fx, "spaceA", "Work") + registerGrantTestSpace(t, fx, "spaceB", "Personal") + grantedSession(fx, "editedKey", &util.ApiGrant{ + Spaces: []string{"spaceA", "spaceB"}, Perms: util.GrantPermsReadWrite}) + fx.eventMock.On("Broadcast", mock.Anything).Return(nil).Maybe() + + // the wide grant serves spaceB + before := serveWithKey(fx, "GET", "/v2/spaces/spaceB", "editedKey") + require.Equal(t, http.StatusOK, before.Code) + + // the grant is narrowed to spaceA in place: LinkLocalUpdateApp persists + // the new grant and calls RevokeToken with the key's session token — + // this test performs exactly that eviction, then serves the re-mint + // with the NARROWED grant the wallet now holds + fx.RevokeToken("tok") + fx.mwMock. + On("WalletCreateSession", mock.Anything, &pb.RpcWalletCreateSessionRequest{ + Auth: &pb.RpcWalletCreateSessionRequestAuthOfAppKey{AppKey: "editedKey"}, + }). + Return(&pb.RpcWalletCreateSessionResponse{ + Token: "tok2", + AccountScope: model.AccountAuth_JsonAPI, + AppName: "agent", + Grant: &model.AccountAuthAppGrant{ + SpaceIds: []string{"spaceA"}, + Perm: model.AccountAuthAppGrant_ReadWrite, + }, + Error: &pb.RpcWalletCreateSessionResponseError{ + Code: pb.RpcWalletCreateSessionResponseError_NULL, + }, + }, nil).Once() + + // the VERY NEXT request enforces the new grant, not the cached old one + after := serveWithKey(fx, "GET", "/v2/spaces/spaceB", "editedKey") + require.Equal(t, http.StatusForbidden, after.Code) + require.Contains(t, after.Body.String(), `"space_not_granted"`) + require.Contains(t, after.Body.String(), "spaces [spaceA] with readwrite access") + + // the still-granted space keeps working through the re-minted session + granted := serveWithKey(fx, "GET", "/v2/spaces/spaceA", "editedKey") + require.Equal(t, http.StatusOK, granted.Code) +} + +func TestGrantEditDuringMintIsNotLost(t *testing.T) { + // The eviction sweep can only evict entries that EXIST: a RevokeToken + // landing while the very first mint for a key is in flight sweeps + // nothing, and without the eviction-generation check the mint would then + // cache the pre-edit WIDE grant permanently — every later request is a + // cache hit, and a cached entry is re-validated only against ExpireAt. + // The mock's Run hook makes the interleaving deterministic: the sweep + // fires INSIDE WalletCreateSession, between the cache read and the cache + // write. + fx := newV2ServerFixture(t) + registerGrantTestSpace(t, fx, "spaceA", "Work") + registerGrantTestSpace(t, fx, "spaceB", "Personal") + fx.eventMock.On("Broadcast", mock.Anything).Return(nil).Maybe() + + mintRequest := &pb.RpcWalletCreateSessionRequest{ + Auth: &pb.RpcWalletCreateSessionRequestAuthOfAppKey{AppKey: "raceKey"}, + } + // the first mint returns the WIDE grant, and the narrowing lands + // MID-MINT: LinkLocalUpdateApp persists the narrow grant and calls + // RevokeToken while the mint is still in flight + fx.mwMock.On("WalletCreateSession", mock.Anything, mintRequest). + Run(func(args mock.Arguments) { fx.RevokeToken("raceTok") }). + Return(&pb.RpcWalletCreateSessionResponse{ + Token: "raceTok", + AccountScope: model.AccountAuth_JsonAPI, + AppName: "agent", + Grant: &model.AccountAuthAppGrant{ + SpaceIds: []string{"spaceA", "spaceB"}, + Perm: model.AccountAuthAppGrant_ReadWrite, + }, + Error: &pb.RpcWalletCreateSessionResponseError{ + Code: pb.RpcWalletCreateSessionResponseError_NULL, + }, + }, nil).Once() + + // the racing request itself may still see the wide grant — it began + // before the edit landed; what it must NOT do is cache it + first := serveWithKey(fx, "GET", "/v2/spaces/spaceB", "raceKey") + require.Equal(t, http.StatusOK, first.Code) + + // the NEXT request must be a cache MISS (nothing was cached), re-mint, + // and be enforced against the grant the wallet holds NOW + fx.mwMock.On("WalletCreateSession", mock.Anything, mintRequest). + Return(&pb.RpcWalletCreateSessionResponse{ + Token: "raceTok2", + AccountScope: model.AccountAuth_JsonAPI, + AppName: "agent", + Grant: &model.AccountAuthAppGrant{ + SpaceIds: []string{"spaceA"}, + Perm: model.AccountAuthAppGrant_ReadWrite, + }, + Error: &pb.RpcWalletCreateSessionResponseError{ + Code: pb.RpcWalletCreateSessionResponseError_NULL, + }, + }, nil).Once() + + after := serveWithKey(fx, "GET", "/v2/spaces/spaceB", "raceKey") + require.Equal(t, http.StatusForbidden, after.Code) + require.Contains(t, after.Body.String(), `"space_not_granted"`) + require.Contains(t, after.Body.String(), "spaces [spaceA] with readwrite access") + + // the narrowed entry (an ordinary mint, no eviction racing it) IS cached + granted := serveWithKey(fx, "GET", "/v2/spaces/spaceA", "raceKey") + require.Equal(t, http.StatusOK, granted.Code) +} diff --git a/core/api/server/middleware.go b/core/api/server/middleware.go index f2a3cbd78c..42a00e661b 100644 --- a/core/api/server/middleware.go +++ b/core/api/server/middleware.go @@ -3,8 +3,10 @@ package server import ( "context" "errors" + "fmt" "net/http" "strings" + "time" "github.com/didip/tollbooth/v8" "github.com/didip/tollbooth/v8/limiter" @@ -13,13 +15,18 @@ import ( apicore "github.com/anyproto/anytype-heart/core/api/core" "github.com/anyproto/anytype-heart/core/api/filter" "github.com/anyproto/anytype-heart/core/api/util" + v2model "github.com/anyproto/anytype-heart/core/api/v2/model" + "github.com/anyproto/anytype-heart/core/domain" "github.com/anyproto/anytype-heart/core/event" "github.com/anyproto/anytype-heart/pb" "github.com/anyproto/anytype-heart/pkg/lib/logging" + "github.com/anyproto/anytype-heart/pkg/lib/pb/model" "github.com/anyproto/anytype-heart/util/localorigin" ) -const ApiVersion = "2025-11-08" +// ApiVersion is shared with the whoami body (util.ApiVersion): the header +// and the introspection mirror must report the same version. +const ApiVersion = util.ApiVersion var log = logging.Logger("api-server") @@ -27,6 +34,8 @@ var ( ErrMissingAuthorizationHeader = errors.New("missing authorization header") ErrInvalidAuthorizationHeader = errors.New("invalid authorization header format") ErrInvalidApiKey = errors.New("invalid api key") + ErrApiKeyExpired = errors.New("api key expired") + ErrInsufficientKeyScope = errors.New("api key scope does not allow json api access") ErrForbiddenOrigin = errors.New("request origin is not allowed") ) @@ -57,54 +66,268 @@ func ensureTrustedOrigin(policy *localorigin.Policy) gin.HandlerFunc { } } -// ensureAuthenticated is a middleware that ensures the request is authenticated. +// apiSessionContextKey is the gin-context key under which ensureAuthenticated +// stores the resolved ApiSessionEntry for downstream authorization middleware. +const apiSessionContextKey = "apiSession" + +// ensureAuthenticated is a middleware that ensures the request is +// authenticated: bearer parse, key→session exchange and cache, and per-request +// expiry. It serves both route groups and decides only WHO is calling; whether +// the key's scope admits the JSON API is ensureJsonApiScope's concern, and +// that gate is /v2-only. func (srv *Server) ensureAuthenticated(mw apicore.ClientCommands) gin.HandlerFunc { return func(c *gin.Context) { authHeader := c.GetHeader("Authorization") if authHeader == "" { + // RFC 6750 §3: a request with no credentials gets the bare + // challenge, no error attribute. MCP clients are required to + // parse WWW-Authenticate (spec rev 2025-06-18). + c.Header(util.WwwAuthenticateHeader, util.BearerChallenge()) apiErr := util.CodeToApiError(http.StatusUnauthorized, ErrMissingAuthorizationHeader.Error()) c.AbortWithStatusJSON(http.StatusUnauthorized, apiErr) return } if !strings.HasPrefix(authHeader, "Bearer ") { + c.Header(util.WwwAuthenticateHeader, util.BearerChallengeInvalidToken()) apiErr := util.CodeToApiError(http.StatusUnauthorized, ErrInvalidAuthorizationHeader.Error()) c.AbortWithStatusJSON(http.StatusUnauthorized, apiErr) return } key := strings.TrimPrefix(authHeader, "Bearer ") + // An empty bearer value must never reach the session mint: + // CreateSession treats an empty AppKey as "no app key" and falls + // through to its mnemonic branch, whose success return is Full scope. + if key == "" { + c.Header(util.WwwAuthenticateHeader, util.BearerChallengeInvalidToken()) + apiErr := util.CodeToApiError(http.StatusUnauthorized, ErrInvalidAuthorizationHeader.Error()) + c.AbortWithStatusJSON(http.StatusUnauthorized, apiErr) + return + } // Validate the key - if the key exists in the KeyToToken map, it is considered valid. // Otherwise, attempt to create a new session using the key and add it to the map upon successful validation. + // The eviction generation is snapshotted in the SAME critical section + // as the cache read: the cache write below is conditional on it. srv.mu.Lock() apiSession, exists := srv.KeyToToken[key] + mintGen := srv.evictGen srv.mu.Unlock() if !exists { response := mw.WalletCreateSession(context.Background(), &pb.RpcWalletCreateSessionRequest{Auth: &pb.RpcWalletCreateSessionRequestAuthOfAppKey{AppKey: key}}) if response.Error.Code != pb.RpcWalletCreateSessionResponseError_NULL { - apiErr := util.CodeToApiError(http.StatusUnauthorized, ErrInvalidApiKey.Error()) + // An expired key gets a distinct 401 so the client knows to + // re-issue it instead of retrying the same key (H5: ExpireAt + // must actually be enforced). + message := ErrInvalidApiKey.Error() + if response.Error.Code == pb.RpcWalletCreateSessionResponseError_APP_TOKEN_EXPIRED { + message = ErrApiKeyExpired.Error() + } + c.Header(util.WwwAuthenticateHeader, util.BearerChallengeInvalidToken()) + apiErr := util.CodeToApiError(http.StatusUnauthorized, message) c.AbortWithStatusJSON(http.StatusUnauthorized, apiErr) return } apiSession = ApiSessionEntry{ - Token: response.Token, - // TODO: enable once app name is returned - // AppName: response.AppName, + Token: response.Token, + AppName: response.AppName, + Scope: response.AccountScope, + ExpireAt: response.AppExpireAt, + Grant: util.ApiGrantFromProto(response.Grant), + KeyId: response.AppHash, + CreatedAt: response.AppCreatedAt, + } + + // Cache only if no eviction swept while the mint was in flight. A + // RevokeToken in that window (LinkLocalUpdateApp persists the new + // grant FIRST, then sweeps) found no entry for this key, so the + // entry just minted may carry the pre-edit grant. Serving THIS + // request from it is equivalent to the request having completed + // before the edit; CACHING it would make the stale grant permanent + // — so on a generation mismatch the entry is dropped and the next + // request re-mints against what the wallet holds then. + srv.mu.Lock() + if srv.evictGen == mintGen { + srv.KeyToToken[key] = apiSession } + srv.mu.Unlock() + } + // Expiry is enforced on every request, not only at session mint, so a + // key that expires while cached stops working without a restart (H5). + if apiSession.ExpireAt > 0 && time.Now().Unix() > apiSession.ExpireAt { + // An eviction is an eviction: the generation bump keeps the rule + // uniform (ANY eviction invalidates concurrent mints), so no + // future eviction site can be the one that forgot it. srv.mu.Lock() - srv.KeyToToken[key] = apiSession + delete(srv.KeyToToken, key) + srv.evictGen++ srv.mu.Unlock() + c.Header(util.WwwAuthenticateHeader, util.BearerChallengeInvalidToken()) + apiErr := util.CodeToApiError(http.StatusUnauthorized, ErrApiKeyExpired.Error()) + c.AbortWithStatusJSON(http.StatusUnauthorized, apiErr) + return } // Add token to request context for downstream services (subscriptions, events, etc.) c.Set("token", apiSession.Token) c.Set("apiAppName", apiSession.AppName) + // The full resolved session rides the gin context so per-group + // authorization middleware (the /v2-only scope gate) can read the + // key's scope without a second lookup. + c.Set(apiSessionContextKey, apiSession) + // The gin context and the request context are separate carriers: the + // analytics middleware reads only c.Request.Context(), so the app name + // must ride there too — and so must the grant, because the v2 + // service layer (the fan-out constraint and the ensureSpace + // backstop) reads it from the ctx its methods receive. The credential + // description rides beside the grant for the same reason: whoami + // derives its answer from these exact carriers. + ctx := util.CtxWithApiAppName(c.Request.Context(), apiSession.AppName) + ctx = util.CtxWithApiGrant(ctx, apiSession.Grant) + ctx = util.CtxWithApiKeyInfo(ctx, util.ApiKeyInfo{ + Id: apiSession.KeyId, + Name: apiSession.AppName, + CreatedAt: apiSession.CreatedAt, + ExpiresAt: apiSession.ExpireAt, + Scope: apiSession.Scope, + }) + // The integration-name carrier (APIV2_OBJECT_DELETE.md §11.3): the + // session's RAW app name rides the request ctx into the + // object-creation pipeline, which stamps it on the creating change. + // Installed by this SHARED middleware, so v1 and v2 creations are + // stamped by the same line (§8a — the "free" case is the actual + // case). The name is deliberately NOT normalized (§5: normalization + // is many-to-one and lossy; the DELETE rule compares exactly) — the + // same AppName the ApiKeyInfo carrier serves, one input, no derived + // copy to drift. Empty AppName ⇒ no carrier ⇒ no stamp. + ctx = domain.CtxWithIntegrationName(ctx, apiSession.AppName) + c.Request = c.Request.WithContext(ctx) + srv.emitKeyStatusSignals(c, apiSession) c.Next() } } +// emitKeyStatusSignals stamps the credential-status signal on every +// authenticated response. Anytype-Key-Status is ALWAYS present (legacy for +// nil-grant keys, scoped otherwise) so a client never reads absence as +// meaning anything; the notice sentence, the rel="deprecation" Link and the +// rate-limited log line accompany only the legacy value. Deliberately NOT +// RFC 9745 Deprecation/Sunset — see util.KeyStatusHeader. +func (srv *Server) emitKeyStatusSignals(c *gin.Context, apiSession ApiSessionEntry) { + c.Header(util.KeyStatusHeader, util.KeyStatus(apiSession.Grant)) + if apiSession.Grant != nil { + return + } + // The remedial signal addresses JSON-API keys only: a grant is only ever + // valid on a JsonAPI-scope key (wallet.ValidateAppLinkGrant), so a + // Limited (clipper) or Full credential cannot follow the "re-issue as a + // scoped key" advice — and counting those keys would inflate the + // legacy-usage metric the log line exists to feed before any sunset + // decision. + if apiSession.Scope != model.AccountAuth_JsonAPI { + return + } + c.Header(util.NoticeHeader, util.LegacyKeyNotice) + // Add, not Set: Link is a list-valued header and this signal must not + // clobber a future pagination or policy link. + c.Writer.Header().Add("Link", util.KeyDeprecationLink) + // Info, not warn: nothing is wrong — legacy keys are grandfathered. The + // line exists so we can tell whether anyone still presents them before a + // sunset is ever contemplated. + if srv.shouldLogLegacyKeyUse(apiSession.KeyId, time.Now()) { + log.Infof("legacy unscoped api key in use: id %q, app %q", apiSession.KeyId, apiSession.AppName) + } +} + +// apiSessionFromContext resolves the session entry ensureAuthenticated +// stored, failing CLOSED on a miss: it writes the 401 challenge and aborts, +// then reports false. Authorization gates MUST go through it rather than +// reading the gin context directly — a gate that forgot the miss branch +// would decide on a zero-value ApiSessionEntry, whose Scope is Limited and +// whose Grant is nil, and a nil grant reads as "legacy key, pass". +func apiSessionFromContext(c *gin.Context) (ApiSessionEntry, bool) { + value, _ := c.Get(apiSessionContextKey) + apiSession, ok := value.(ApiSessionEntry) + if !ok { + c.Header(util.WwwAuthenticateHeader, util.BearerChallenge()) + apiErr := util.CodeToApiError(http.StatusUnauthorized, ErrMissingAuthorizationHeader.Error()) + c.AbortWithStatusJSON(http.StatusUnauthorized, apiErr) + return ApiSessionEntry{}, false + } + return apiSession, true +} + +// ensureJsonApiScope refuses keys whose scope does not admit the JSON API: +// only JsonAPI and Full pass, any other scope (e.g. the web clipper's +// Limited) gets 403, distinct from the 401 invalid-key path (H2: the gate +// must not be scope-blind). +// +// The gate is installed on the /v2 group only. Keys minted without a scope +// carry Limited (anytype-cli's CreateApp historically sent none), so gating +// /v1 would break those keys with no repair path but re-issuing — they are +// grandfathered on /v1, while /v2 has no shipped clients to break. +// +// Must run after ensureAuthenticated: it reads the resolved session entry +// from the gin context. Without one the request never authenticated, and the +// gate fails closed with the auth middleware's 401 rather than deciding +// authorization on nothing. +func ensureJsonApiScope() gin.HandlerFunc { + return func(c *gin.Context) { + apiSession, ok := apiSessionFromContext(c) + if !ok { + return + } + + if apiSession.Scope != model.AccountAuth_JsonAPI && apiSession.Scope != model.AccountAuth_Full { + c.Header(util.WwwAuthenticateHeader, util.BearerChallengeInsufficientScope("")) + apiErr := util.CodeToApiError(http.StatusForbidden, insufficientScopeMessage(apiSession)) + c.AbortWithStatusJSON(http.StatusForbidden, apiErr) + return + } + c.Next() + } +} + +// ensureUngrantedKey refuses GRANTED keys on the /v1 group: a space grant +// can only be honored by /v2's gate, so serving the key on /v1 would give +// it unrestricted account-wide access the user explicitly narrowed away. +// The asymmetry is intended: a granted key is refused here while a legacy +// (nil-grant) key is served on /v1 exactly as today — grant PRESENCE +// decides, never key format (a legacy-format key can be granted in place +// and a new-format key can be unscoped). +// +// The 403 uses the v2 C6 envelope: the response's whole job is to steer the +// caller to /v2, so it speaks /v2's error language. +func ensureUngrantedKey() gin.HandlerFunc { + return func(c *gin.Context) { + apiSession, ok := apiSessionFromContext(c) + if !ok { + return + } + if apiSession.Grant == nil { + c.Next() + return + } + c.Header(util.WwwAuthenticateHeader, util.BearerChallengeInsufficientScope("")) + v2Err := v2model.V1NotAvailableForScopedKeys(fmt.Sprintf( + "key %q carries a space grant (%s), which /v1 cannot honor — call the same route on /v2, or issue an unscoped key for /v1", + apiSession.AppName, apiSession.Grant.Describe())) + c.AbortWithStatusJSON(v2Err.Status, v2Err) + } +} + +// insufficientScopeMessage names the key and its actual scope so the 403 +// reads as "re-issue the key with the right scope" rather than a transient +// permissions failure — Limited keys issued before the gate existed (e.g. by +// anytype-cli, which did not set a scope) hit this on every /v2 request while +// staying served on /v1. +func insufficientScopeMessage(entry ApiSessionEntry) string { + return fmt.Sprintf("%s: key %q has %s scope, create a new api key with JsonAPI scope", + ErrInsufficientKeyScope.Error(), entry.AppName, entry.Scope.String()) +} + // ensureAnalyticsEvent is a middleware that ensures broadcasting an analytics event after a successful request. func ensureAnalyticsEvent(code string, eventService apicore.EventService) gin.HandlerFunc { return func(c *gin.Context) { diff --git a/core/api/server/middleware_test.go b/core/api/server/middleware_test.go index 98e629c623..9b86c94799 100644 --- a/core/api/server/middleware_test.go +++ b/core/api/server/middleware_test.go @@ -6,15 +6,83 @@ import ( "net/http" "net/http/httptest" "testing" + "time" "github.com/gin-gonic/gin" "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" "github.com/anyproto/anytype-heart/core/api/util" + "github.com/anyproto/anytype-heart/core/domain" "github.com/anyproto/anytype-heart/pb" + "github.com/anyproto/anytype-heart/pkg/lib/pb/model" ) +// The integration-name carrier (APIV2_OBJECT_DELETE.md §11.3) must ride the +// REQUEST context: it is what the object-creation pipeline stamps onto the +// creating change, and ensureAuthenticated serves BOTH route groups, so this +// one install is also what makes v1 creations carry provenance (§8a). The +// mint below goes through the real middleware — if the install line is +// dropped, or the name stops coming from the session AppName, the carrier +// reads empty and the first subtest fails. +func TestEnsureAuthenticatedInstallsIntegrationName(t *testing.T) { + mintWithAppName := func(t *testing.T, appName string) *gin.Context { + fx := newFixture(t) + fx.KeyToToken = make(map[string]ApiSessionEntry) + fx.mwMock. + On("WalletCreateSession", mock.Anything, mock.Anything). + Return(&pb.RpcWalletCreateSessionResponse{ + Token: "tok", + AccountScope: model.AccountAuth_JsonAPI, + AppName: appName, + Error: &pb.RpcWalletCreateSessionResponseError{ + Code: pb.RpcWalletCreateSessionResponseError_NULL, + }, + }, nil).Once() + middleware := fx.ensureAuthenticated(fx.mwMock) + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + req := httptest.NewRequest("GET", "/", nil) + req.Header.Set("Authorization", "Bearer someKey") + c.Request = req + middleware(c) + require.False(t, c.IsAborted()) + return c + } + + t.Run("the session AppName rides the request ctx RAW", func(t *testing.T) { + // given/when: the carrier holds the name verbatim — asserting on the + // exact string also fails an implementation that re-introduces slug + // normalization ("Claude Desktop" → "claude-desktop") + c := mintWithAppName(t, "Claude Desktop") + + // then + require.Equal(t, "Claude Desktop", domain.IntegrationNameFromCtx(c.Request.Context())) + }) + + t.Run("slug-hostile names survive untouched — the F2/F3 regression pins", func(t *testing.T) { + // each of these was destroyed by the old normalization: the first + // collapsed onto "Claude Desktop"'s slug (F2 — a foreign key could + // archive its output), the rest slugged to "" (F3 — the key's own + // output became permanently undeletable). A revert to slug + // derivation fails every row: the first yields "claude-desktop", + // the others install no carrier at all. + for _, name := range []string{"Claude/Desktop", "日本語アプリ", "🙂"} { + c := mintWithAppName(t, name) + require.Equal(t, name, domain.IntegrationNameFromCtx(c.Request.Context()), "AppName %q", name) + } + }) + + t.Run("a nameless key installs no carrier", func(t *testing.T) { + // §5: empty AppName ⇒ no stamp — that key's creations stay + // unprovenanced rather than carrying an empty stamp + c := mintWithAppName(t, "") + + // then + require.Equal(t, "", domain.IntegrationNameFromCtx(c.Request.Context())) + }) +} + func TestEnsureMetadataHeader(t *testing.T) { t.Run("sets correct header", func(t *testing.T) { // given @@ -72,18 +140,48 @@ func TestEnsureAuthenticated(t *testing.T) { require.JSONEq(t, string(expectedJSON), w.Body.String()) }) + t.Run("empty bearer value is refused before the session mint", func(t *testing.T) { + // given: "Bearer " with an empty value. It must never reach + // WalletCreateSession, whose empty-AppKey fallthrough is the + // Full-minting mnemonic branch — no WalletCreateSession expectation + // is set, so a call fails the mock. + fx := newFixture(t) + fx.KeyToToken = make(map[string]ApiSessionEntry) + middleware := fx.ensureAuthenticated(fx.mwMock) + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + req := httptest.NewRequest("GET", "/", nil) + req.Header.Set("Authorization", "Bearer ") + c.Request = req + + // when + middleware(c) + + // then + require.Equal(t, http.StatusUnauthorized, w.Code) + expectedJSON, err := json.Marshal(util.CodeToApiError(http.StatusUnauthorized, ErrInvalidAuthorizationHeader.Error())) + require.NoError(t, err) + require.JSONEq(t, string(expectedJSON), w.Body.String()) + }) + t.Run("valid token creation", func(t *testing.T) { - // given + // given: a non-zero AppExpireAt — the mint-to-cache copy of every + // carried field is what this test pins; a dropped ExpireAt would + // silently disable the per-request expiry check for fresh mints fx := newFixture(t) fx.KeyToToken = make(map[string]ApiSessionEntry) tokenExpected := "valid-token" + expireAtExpected := time.Now().Unix() + 3600 fx.mwMock. On("WalletCreateSession", mock.Anything, &pb.RpcWalletCreateSessionRequest{ Auth: &pb.RpcWalletCreateSessionRequestAuthOfAppKey{AppKey: "someAppKey"}, }). Return(&pb.RpcWalletCreateSessionResponse{ - Token: tokenExpected, + Token: tokenExpected, + AccountScope: model.AccountAuth_JsonAPI, + AppName: "test-app", + AppExpireAt: expireAtExpected, Error: &pb.RpcWalletCreateSessionResponseError{ Code: pb.RpcWalletCreateSessionResponseError_NULL, }, @@ -103,6 +201,162 @@ func TestEnsureAuthenticated(t *testing.T) { token, exists := c.Get("token") require.True(t, exists) require.Equal(t, tokenExpected, token) + appName, exists := c.Get("apiAppName") + require.True(t, exists) + require.Equal(t, "test-app", appName) + + // the app name must also ride the REQUEST context — that is the only + // carrier the analytics middleware can see + payload, err := util.NewAnalyticsEventForApi(c.Request.Context(), "code", http.StatusOK) + require.NoError(t, err) + require.Contains(t, payload, `"apiAppName":"test-app"`) + + // scope and expiry are cached for later requests + entry := fx.KeyToToken["someAppKey"] + want := ApiSessionEntry{ + Token: tokenExpected, + AppName: "test-app", + Scope: model.AccountAuth_JsonAPI, + ExpireAt: expireAtExpected, + } + require.Equal(t, want, entry) + }) + + t.Run("the grant is cached at mint and rides both context carriers", func(t *testing.T) { + // given: the mint answers with a grant — it must land in the cache + // entry (the /v2 gate reads the gin-context session) AND on the + // request context (the v2 service layer reads it from ctx) + fx := newFixture(t) + fx.KeyToToken = make(map[string]ApiSessionEntry) + fx.mwMock. + On("WalletCreateSession", mock.Anything, mock.Anything). + Return(&pb.RpcWalletCreateSessionResponse{ + Token: "tok", + AccountScope: model.AccountAuth_JsonAPI, + AppName: "agent", + Grant: &model.AccountAuthAppGrant{ + SpaceIds: []string{"space1"}, + Perm: model.AccountAuthAppGrant_Read, + }, + Error: &pb.RpcWalletCreateSessionResponseError{ + Code: pb.RpcWalletCreateSessionResponseError_NULL, + }, + }, nil).Once() + + middleware := fx.ensureAuthenticated(fx.mwMock) + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + req := httptest.NewRequest("GET", "/", nil) + req.Header.Set("Authorization", "Bearer grantedKey") + c.Request = req + + // when + middleware(c) + + // then + require.False(t, c.IsAborted()) + wantGrant := &util.ApiGrant{Spaces: []string{"space1"}, Perms: util.GrantPermsRead} + entry := fx.KeyToToken["grantedKey"] + require.Equal(t, wantGrant, entry.Grant) + require.Equal(t, wantGrant, util.ApiGrantFromCtx(c.Request.Context())) + }) + + t.Run("a Limited key authenticates: scope is not auth's concern", func(t *testing.T) { + // Keys minted without a scope carry Limited and must keep working on + // /v1; the scope refusal lives in ensureJsonApiScope, installed on + // /v2 only. Auth aborting on scope here would reinstate the gate on + // every group that shares this middleware. + fx := newFixture(t) + fx.KeyToToken = make(map[string]ApiSessionEntry) + fx.mwMock. + On("WalletCreateSession", mock.Anything, mock.Anything). + Return(&pb.RpcWalletCreateSessionResponse{ + Token: "some-token", + AccountScope: model.AccountAuth_Limited, + AppName: "clipper", + Error: &pb.RpcWalletCreateSessionResponseError{ + Code: pb.RpcWalletCreateSessionResponseError_NULL, + }, + }, nil).Once() + + middleware := fx.ensureAuthenticated(fx.mwMock) + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + req := httptest.NewRequest("GET", "/", nil) + req.Header.Set("Authorization", "Bearer limitedKey") + c.Request = req + + // when + middleware(c) + + // then + require.False(t, c.IsAborted()) + token, exists := c.Get("token") + require.True(t, exists) + require.Equal(t, "some-token", token) + // the resolved session rides the gin context — the /v2 scope gate + // reads it from there + value, exists := c.Get(apiSessionContextKey) + require.True(t, exists) + require.Equal(t, model.AccountAuth_Limited, value.(ApiSessionEntry).Scope) + }) + + t.Run("expired key is a distinct 401", func(t *testing.T) { + // H5: the middleware maps APP_TOKEN_EXPIRED to its own message so the + // client knows to re-issue instead of retrying. + fx := newFixture(t) + fx.KeyToToken = make(map[string]ApiSessionEntry) + fx.mwMock. + On("WalletCreateSession", mock.Anything, mock.Anything). + Return(&pb.RpcWalletCreateSessionResponse{ + Error: &pb.RpcWalletCreateSessionResponseError{ + Code: pb.RpcWalletCreateSessionResponseError_APP_TOKEN_EXPIRED, + }, + }, nil).Once() + + middleware := fx.ensureAuthenticated(fx.mwMock) + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + req := httptest.NewRequest("GET", "/", nil) + req.Header.Set("Authorization", "Bearer expiredKey") + c.Request = req + + // when + middleware(c) + + // then + require.Equal(t, http.StatusUnauthorized, w.Code) + expectedJSON, err := json.Marshal(util.CodeToApiError(http.StatusUnauthorized, ErrApiKeyExpired.Error())) + require.NoError(t, err) + require.JSONEq(t, string(expectedJSON), w.Body.String()) + }) + + t.Run("key expiring while cached stops working and is evicted", func(t *testing.T) { + // H5: expiry is enforced per request, not only at session mint. + fx := newFixture(t) + fx.KeyToToken = map[string]ApiSessionEntry{ + "cachedKey": { + Token: "cached-token", + Scope: model.AccountAuth_JsonAPI, + ExpireAt: time.Now().Unix() - 60, + }, + } + middleware := fx.ensureAuthenticated(fx.mwMock) + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + req := httptest.NewRequest("GET", "/", nil) + req.Header.Set("Authorization", "Bearer cachedKey") + c.Request = req + + // when + middleware(c) + + // then + require.Equal(t, http.StatusUnauthorized, w.Code) + expectedJSON, err := json.Marshal(util.CodeToApiError(http.StatusUnauthorized, ErrApiKeyExpired.Error())) + require.NoError(t, err) + require.JSONEq(t, string(expectedJSON), w.Body.String()) + require.NotContains(t, fx.KeyToToken, "cachedKey") }) t.Run("invalid token", func(t *testing.T) { @@ -138,6 +392,170 @@ func TestEnsureAuthenticated(t *testing.T) { }) } +func TestEnsureJsonApiScope(t *testing.T) { + // The gate runs chained directly after ensureAuthenticated, the way the + // /v2 group installs it — the pair is exercised together because the gate + // reads the session entry auth resolves. + newChain := func(fx *fixture) *gin.Engine { + router := gin.New() + router.GET("/test", + fx.ensureAuthenticated(fx.mwMock), + ensureJsonApiScope(), + func(c *gin.Context) { c.String(http.StatusOK, "OK") }, + ) + return router + } + + t.Run("rejects keys that are neither JsonAPI nor Full", func(t *testing.T) { + // H2: a valid Limited (web-clipper) key must not silently grant the + // JSON API — 403, distinct from the 401 invalid-key path. + tests := []struct { + name string + scope model.AccountAuthLocalApiScope + wantCode int + }{ + {name: "Limited scope is refused", scope: model.AccountAuth_Limited, wantCode: http.StatusForbidden}, + {name: "JsonAPI scope passes", scope: model.AccountAuth_JsonAPI, wantCode: http.StatusOK}, + {name: "Full scope passes", scope: model.AccountAuth_Full, wantCode: http.StatusOK}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // given + fx := newFixture(t) + fx.KeyToToken = make(map[string]ApiSessionEntry) + fx.mwMock. + On("WalletCreateSession", mock.Anything, mock.Anything). + Return(&pb.RpcWalletCreateSessionResponse{ + Token: "some-token", + AccountScope: tt.scope, + AppName: "clipper", + Error: &pb.RpcWalletCreateSessionResponseError{ + Code: pb.RpcWalletCreateSessionResponseError_NULL, + }, + }, nil).Once() + w := httptest.NewRecorder() + req := httptest.NewRequest("GET", "/test", nil) + req.Header.Set("Authorization", "Bearer scopedKey") + + // when + newChain(fx).ServeHTTP(w, req) + + // then + require.Equal(t, tt.wantCode, w.Code) + if tt.wantCode == http.StatusForbidden { + // the body must name the key and its scope, so the failure + // reads as "re-issue the key", not as a permissions bug + wantMessage := `api key scope does not allow json api access: key "clipper" has Limited scope, create a new api key with JsonAPI scope` + expectedJSON, err := json.Marshal(util.CodeToApiError(http.StatusForbidden, wantMessage)) + require.NoError(t, err) + require.JSONEq(t, string(expectedJSON), w.Body.String()) + } + }) + } + }) + + t.Run("gate applies to cached entries too", func(t *testing.T) { + // given: a Limited entry already in the cache — the cache + // short-circuits the mint for the rest of the process run, so the + // gate must be evaluated on the cached path as well + fx := newFixture(t) + fx.KeyToToken = map[string]ApiSessionEntry{ + "cachedKey": {Token: "cached-token", Scope: model.AccountAuth_Limited}, + } + w := httptest.NewRecorder() + req := httptest.NewRequest("GET", "/test", nil) + req.Header.Set("Authorization", "Bearer cachedKey") + + // when + newChain(fx).ServeHTTP(w, req) + + // then + require.Equal(t, http.StatusForbidden, w.Code) + }) + + t.Run("no authenticated session fails closed with 401", func(t *testing.T) { + // given: the gate invoked without ensureAuthenticated ahead of it — + // no session entry in the gin context. It must refuse as + // unauthenticated, never pass and never 403 on a nil scope. + middleware := ensureJsonApiScope() + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Request = httptest.NewRequest("GET", "/", nil) + + // when + middleware(c) + + // then + require.Equal(t, http.StatusUnauthorized, w.Code) + expectedJSON, err := json.Marshal(util.CodeToApiError(http.StatusUnauthorized, ErrMissingAuthorizationHeader.Error())) + require.NoError(t, err) + require.JSONEq(t, string(expectedJSON), w.Body.String()) + }) +} + +func TestAuthWwwAuthenticateHeaders(t *testing.T) { + // The WWW-Authenticate values are wire surface MCP clients are required + // to parse (spec rev 2025-06-18): the bare challenge when no credential + // was presented (RFC 6750 §3), invalid_token for a present but unusable + // one, insufficient_scope on authorization refusals. + t.Run("missing credentials get the bare challenge", func(t *testing.T) { + fx := newFixture(t) + fx.KeyToToken = make(map[string]ApiSessionEntry) + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Request = httptest.NewRequest("GET", "/", nil) + + fx.ensureAuthenticated(fx.mwMock)(c) + + require.Equal(t, http.StatusUnauthorized, w.Code) + require.Equal(t, `Bearer realm="anytype"`, w.Header().Get("WWW-Authenticate")) + }) + + t.Run("an invalid key gets invalid_token", func(t *testing.T) { + fx := newFixture(t) + fx.KeyToToken = make(map[string]ApiSessionEntry) + fx.mwMock. + On("WalletCreateSession", mock.Anything, mock.Anything). + Return(&pb.RpcWalletCreateSessionResponse{ + Error: &pb.RpcWalletCreateSessionResponseError{ + Code: pb.RpcWalletCreateSessionResponseError_UNKNOWN_ERROR, + }, + }, nil).Once() + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + req := httptest.NewRequest("GET", "/", nil) + req.Header.Set("Authorization", "Bearer badKey") + c.Request = req + + fx.ensureAuthenticated(fx.mwMock)(c) + + require.Equal(t, http.StatusUnauthorized, w.Code) + require.Equal(t, `Bearer realm="anytype", error="invalid_token"`, w.Header().Get("WWW-Authenticate")) + }) + + t.Run("the scope gate's 403 carries insufficient_scope", func(t *testing.T) { + fx := newFixture(t) + fx.KeyToToken = map[string]ApiSessionEntry{ + "limitedKey": {Token: "tok", AppName: "clipper", Scope: model.AccountAuth_Limited}, + } + router := gin.New() + router.GET("/test", + fx.ensureAuthenticated(fx.mwMock), + ensureJsonApiScope(), + func(c *gin.Context) { c.String(http.StatusOK, "OK") }, + ) + w := httptest.NewRecorder() + req := httptest.NewRequest("GET", "/test", nil) + req.Header.Set("Authorization", "Bearer limitedKey") + + router.ServeHTTP(w, req) + + require.Equal(t, http.StatusForbidden, w.Code) + require.Equal(t, `Bearer error="insufficient_scope"`, w.Header().Get("WWW-Authenticate")) + }) +} + func TestEnsureAnalyticsEvent(t *testing.T) { t.Run("broadcasts analytics event after successful request", func(t *testing.T) { // given @@ -170,6 +588,32 @@ func TestEnsureAnalyticsEvent(t *testing.T) { require.Equal(t, expectedPayload, wrapper.Payload) }) + + t.Run("event is attributed to the authenticated app", func(t *testing.T) { + // given: an upstream middleware stores the app name on the request + // context, the way ensureAuthenticated does + fx := newFixture(t) + fx.eventMock.On("Broadcast", mock.AnythingOfType("*pb.Event")).Return() + router := gin.New() + router.Use(func(c *gin.Context) { + c.Request = c.Request.WithContext(util.CtxWithApiAppName(c.Request.Context(), "my-integration")) + c.Next() + }) + router.Use(ensureAnalyticsEvent("test-code", fx.eventMock)) + router.GET("/test", func(c *gin.Context) { + c.String(http.StatusOK, "OK") + }) + + // when + w := httptest.NewRecorder() + router.ServeHTTP(w, httptest.NewRequest("GET", "/test", nil)) + + // then + msgArg := fx.eventMock.Calls[0].Arguments.Get(0).(*pb.Event) + require.Len(t, msgArg.Messages, 1) + payload := msgArg.Messages[0].GetPayloadBroadcast().Payload + require.Contains(t, payload, `"apiAppName":"my-integration"`) + }) } func TestRateLimit(t *testing.T) { diff --git a/core/api/server/router.go b/core/api/server/router.go index 8d9b963c41..ee6f29a8cc 100644 --- a/core/api/server/router.go +++ b/core/api/server/router.go @@ -7,9 +7,9 @@ import ( "github.com/gin-gonic/gin" apicore "github.com/anyproto/anytype-heart/core/api/core" - _ "github.com/anyproto/anytype-heart/core/api/docs" "github.com/anyproto/anytype-heart/core/api/handler" "github.com/anyproto/anytype-heart/core/api/pagination" + apiv2 "github.com/anyproto/anytype-heart/core/api/v2" "github.com/anyproto/anytype-heart/util/localorigin" ) @@ -43,7 +43,14 @@ func (srv *Server) NewRouter(mw apicore.ClientCommands, eventService apicore.Eve v1 := router.Group("/v1") v1.Use(paginator) v1.Use(srv.ensureCacheInitialized()) + // No scope gate on /v1: keys minted without a scope carry Limited + // (anytype-cli's CreateApp historically sent none) and must keep working + // here — the JSON-API scope gate (ensureJsonApiScope) is /v2-only. v1.Use(srv.ensureAuthenticated(mw)) + // GRANTED keys are the one exception to /v1's grandfathering: their + // grant can only be honored on /v2, so /v1 refuses them with a pointer + // there. Legacy (nil-grant) keys pass untouched. + v1.Use(ensureUngrantedKey()) srv.registerChatRoutes(v1, eventService, writeRateLimitMW) srv.registerFileRoutes(v1, eventService, writeRateLimitMW) @@ -57,6 +64,19 @@ func (srv *Server) NewRouter(mw apicore.ClientCommands, eventService apicore.Eve srv.registerTemplateRoutes(v1, eventService) srv.registerTypeRoutes(v1, eventService, writeRateLimitMW) + apiv2.RegisterRoutes(router, apiv2.RouteDeps{ + Service: srv.v2Service, + CreateDisabled: srv.v2CreateDisabled, + EditDisabled: srv.v2EditDisabled, + Auth: srv.ensureAuthenticated(mw), + KeyScope: ensureJsonApiScope(), + CacheInit: srv.ensureCacheInitialized(), + WriteRateLimit: writeRateLimitMW, + AnalyticsEvent: func(code string) gin.HandlerFunc { + return ensureAnalyticsEvent(code, eventService) + }, + }) + return router } @@ -107,13 +127,21 @@ func (srv *Server) registerDocumentationRoutes(router *gin.Engine, openapiYAML [ c.Redirect(http.StatusMovedPermanently, target) }) - router.GET("/docs/openapi.yaml", func(c *gin.Context) { - c.Data(http.StatusOK, "application/x-yaml", openapiYAML) - }) - - router.GET("/docs/openapi.json", func(c *gin.Context) { - c.Data(http.StatusOK, "application/json", openapiJSON) - }) + // /docs/* keeps serving v1 unchanged: it is the path developers.anytype.io + // and existing integrations use, so repointing it at v2 would break them + // and repointing it at nothing would be worse. The versioned paths are the + // ones to link to from here on. + serveDoc := func(path, contentType string, body []byte) { + router.GET(path, func(c *gin.Context) { + c.Data(http.StatusOK, contentType, body) + }) + } + serveDoc("/docs/openapi.yaml", "application/x-yaml", openapiYAML) + serveDoc("/docs/openapi.json", "application/json", openapiJSON) + serveDoc("/v1/docs/openapi.yaml", "application/x-yaml", openapiYAML) + serveDoc("/v1/docs/openapi.json", "application/json", openapiJSON) + serveDoc("/v2/docs/openapi.yaml", "application/x-yaml", srv.docs.V2YAML) + serveDoc("/v2/docs/openapi.json", "application/json", srv.docs.V2JSON) } // registerAuthRoutes registers authentication routes (no auth required) diff --git a/core/api/server/router_test.go b/core/api/server/router_test.go index 0a47f91e4f..74e76e2232 100644 --- a/core/api/server/router_test.go +++ b/core/api/server/router_test.go @@ -1,15 +1,19 @@ package server import ( + "encoding/json" "net/http" "net/http/httptest" "testing" + "time" "github.com/gogo/protobuf/types" "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" + "github.com/anyproto/anytype-heart/core/api/util" "github.com/anyproto/anytype-heart/pb" + "github.com/anyproto/anytype-heart/pkg/lib/pb/model" ) // localApiHost is what a real client sends; httptest defaults to example.com, @@ -50,11 +54,77 @@ func TestRouter_AuthRoute(t *testing.T) { }) } +func TestRouter_V1KeyScopes(t *testing.T) { + t.Run("every key scope is accepted on /v1", func(t *testing.T) { + // The JSON-API scope gate is /v2-only. Keys minted without a scope + // carry Limited (anytype-cli's CreateApp historically sent none) and + // must keep working on /v1 — installing the gate on this group would + // 403 every such key with no repair path but re-issuing. + for _, scope := range []model.AccountAuthLocalApiScope{ + model.AccountAuth_Limited, + model.AccountAuth_JsonAPI, + model.AccountAuth_Full, + } { + t.Run(scope.String(), func(t *testing.T) { + // given + fx := newFixture(t) + engine := fx.NewRouter(fx.mwMock, fx.eventMock, []byte{}, []byte{}) + fx.KeyToToken = map[string]ApiSessionEntry{ + "validKey": {Token: "dummyToken", AppName: "legacy-cli", Scope: scope}, + } + fx.mwMock.On("ObjectSearch", mock.Anything, mock.Anything). + Return(&pb.RpcObjectSearchResponse{ + Records: []*types.Struct{}, + Error: &pb.RpcObjectSearchResponseError{Code: pb.RpcObjectSearchResponseError_NULL}, + }, nil).Once() + fx.eventMock.On("Broadcast", mock.Anything).Return(nil).Maybe() + + w := httptest.NewRecorder() + req := httptest.NewRequest("GET", "/v1/spaces", nil) + req.Host = localApiHost + req.Header.Set("Authorization", "Bearer validKey") + + // when + engine.ServeHTTP(w, req) + + // then + require.Equal(t, http.StatusOK, w.Code) + }) + } + }) + + t.Run("expired key gets the distinct 401 on /v1", func(t *testing.T) { + // given: expiry is enforced in ensureAuthenticated for BOTH groups — + // only the scope refusal is /v2-only (H5 did not move) + fx := newFixture(t) + engine := fx.NewRouter(fx.mwMock, fx.eventMock, []byte{}, []byte{}) + fx.KeyToToken = map[string]ApiSessionEntry{ + "expiredKey": {Token: "dummyToken", Scope: model.AccountAuth_JsonAPI, ExpireAt: time.Now().Unix() - 60}, + } + + w := httptest.NewRecorder() + req := httptest.NewRequest("GET", "/v1/spaces", nil) + req.Host = localApiHost + req.Header.Set("Authorization", "Bearer expiredKey") + + // when + engine.ServeHTTP(w, req) + + // then + require.Equal(t, http.StatusUnauthorized, w.Code) + expectedJSON, err := json.Marshal(util.CodeToApiError(http.StatusUnauthorized, ErrApiKeyExpired.Error())) + require.NoError(t, err) + require.JSONEq(t, string(expectedJSON), w.Body.String()) + }) +} + func TestRouter_MetadataHeader(t *testing.T) { t.Run("Response includes Anytype-Version header", func(t *testing.T) { // given fx := newFixture(t) engine := fx.NewRouter(fx.mwMock, fx.eventMock, []byte{}, []byte{}) + // no Scope on the entry: /v1 carries no scope gate, so a /v1 test + // setting one would imply a requirement that does not exist fx.KeyToToken = map[string]ApiSessionEntry{"validKey": {Token: "dummyToken", AppName: "dummyApp"}} fx.mwMock.On("ObjectSearch", mock.Anything, mock.Anything). Return(&pb.RpcObjectSearchResponse{ diff --git a/core/api/server/server.go b/core/api/server/server.go index 47b86da8c4..612dda72dd 100644 --- a/core/api/server/server.go +++ b/core/api/server/server.go @@ -4,32 +4,117 @@ import ( "context" "strings" "sync" + "time" "github.com/gin-gonic/gin" apicore "github.com/anyproto/anytype-heart/core/api/core" "github.com/anyproto/anytype-heart/core/api/service" + "github.com/anyproto/anytype-heart/core/api/util" + v2service "github.com/anyproto/anytype-heart/core/api/v2/service" + "github.com/anyproto/anytype-heart/pkg/lib/localstore/objectstore" + "github.com/anyproto/anytype-heart/pkg/lib/pb/model" ) +// ApiSessionEntry is written once at session mint and evicted only by the +// per-request expiry check and RevokeToken. A surface that edits a live +// key's scope or grant in place must also evict that key's entry, or the +// edit takes effect only after a process restart — LinkLocalUpdateApp does +// exactly that (via RevokeToken), and that coupling is what makes an +// in-place grant NARROWING take effect on the very next request instead of +// silently serving the cached wider grant. type ApiSessionEntry struct { Token string `json:"token"` AppName string `json:"appName"` + // Scope is the app link's scope, cached so the /v2-only scope gate + // (ensureJsonApiScope: only JsonAPI and Full may use /v2) can decide + // every request without a second key lookup. + Scope model.AccountAuthLocalApiScope `json:"scope"` + // ExpireAt is the app link's expiry unix timestamp (0 = never); checked + // per request so a key that expires while cached stops working. + ExpireAt int64 `json:"expire_at"` + // Grant is the key's space grant; nil means an unscoped/legacy key. + // Enforcement keys off the grant, never off the key string format: + // ensureSpaceGrant constrains /v2 requests to it, and a granted key is + // refused on /v1 (its grant cannot be honored there). + Grant *util.ApiGrant `json:"grant,omitempty"` + // KeyId is the app link's hash (the id ListApps shows) and CreatedAt its + // creation unix timestamp — cached for whoami and the legacy-key log + // line; neither is an authorization input. + KeyId string `json:"key_id,omitempty"` + CreatedAt int64 `json:"created_at,omitempty"` } // Server wraps the HTTP server and service logic. type Server struct { - engine *gin.Engine - service *service.Service - chatSubSvc apicore.ChatSubscriptionService + engine *gin.Engine + service *service.Service + v2Service *v2service.Service + // v2CreateDisabled skips the Phase-2 create routes when no creator + // dependency was provided (read-only construction, e.g. in tests). + v2CreateDisabled bool + // v2EditDisabled skips the Phase-3 edit routes when no mutator + // dependency was provided. + v2EditDisabled bool + chatSubSvc apicore.ChatSubscriptionService + // docs holds both generated OpenAPI documents. NewRouter still takes v1's + // bytes as parameters (its signature is what the route-conformance tests + // call), so only the v2 pair is read from here. + docs OpenApiDocs mu sync.Mutex KeyToToken map[string]ApiSessionEntry // appKey -> token + // legacyKeyLogSeen records when each legacy key's usage was last logged, + // keyed by the key id (app hash). The log line exists to tell US whether + // anyone still presents legacy keys before a sunset is ever contemplated + // — once per key per process start, re-armed hourly, is enough signal + // and cannot flood the log on an agent's request loop. + legacyKeyLogSeen map[string]time.Time + // evictGen counts cache evictions (RevokeToken sweeps and the + // per-request expiry delete). ensureAuthenticated snapshots it before a + // session mint and caches the minted entry only if no eviction happened + // in between: a RevokeToken racing a mint can only sweep entries that + // EXIST, so without the check a grant edit landing mid-mint would be + // swept past and the mint would then cache the pre-edit grant — with + // nothing left to evict it, ever (cached entries are re-validated only + // against ExpireAt). + evictGen uint64 initOnce sync.Once } +// V2Deps carries the API v2 dependencies (APIV2.md §8: live smartblock +// reads + objectstore-backed lists/resolvers, plus the Phase-2 create path +// and the Phase-3 edit path). With Reader or Store nil, the /v2 route group +// is not registered — v1 keeps working standalone. With Creator nil, only +// the read surface registers; with Mutator nil, the edit routes are skipped. +type V2Deps struct { + Reader apicore.ObjectReader + Creator apicore.ObjectCreator + Mutator apicore.ObjectMutator + // Provenance is the DELETE enforcement read (creator provenance from + // validated change storage). With it nil the object DELETE route still + // registers but refuses every delete — fail closed, not fail open. + Provenance apicore.ObjectProvenance + Store objectstore.ObjectStore + // AccountId is the caller's account identity, used by Phase 4's + // stored-view placeholder substitution (`_filter_template_2_` → the + // caller's participant id). Empty degrades the placeholder to a warning. + AccountId string +} + +// OpenApiDocs carries the generated OpenAPI documents, one pair per API +// version (core/api/docs/v1, core/api/docs/v2). They are served verbatim; see +// registerDocumentationRoutes for the paths. +type OpenApiDocs struct { + V1YAML []byte + V1JSON []byte + V2YAML []byte + V2JSON []byte +} + // NewServer constructs a new Server with the default config and sets up the routes. -func NewServer(mw apicore.ClientCommands, accountService apicore.AccountService, eventService apicore.EventService, crossSpaceSubService apicore.CrossSpaceSubscriptionService, chatSubSvc apicore.ChatSubscriptionService, fileObjectService apicore.FileObjectService, apiListenAddr string, openapiYAML []byte, openapiJSON []byte) *Server { +func NewServer(mw apicore.ClientCommands, accountService apicore.AccountService, eventService apicore.EventService, crossSpaceSubService apicore.CrossSpaceSubscriptionService, chatSubSvc apicore.ChatSubscriptionService, fileObjectService apicore.FileObjectService, v2Deps V2Deps, apiListenAddr string, docs OpenApiDocs) *Server { techSpaceId, err := getTechSpaceId(accountService) if err != nil { panic(err) @@ -39,13 +124,37 @@ func NewServer(mw apicore.ClientCommands, accountService apicore.AccountService, s := &Server{ service: service.NewService(mw, fileObjectService, apiBaseUrl, techSpaceId, crossSpaceSubService), chatSubSvc: chatSubSvc, + docs: docs, + } + if v2Deps.Reader != nil && v2Deps.Store != nil { + s.v2Service = v2service.NewService(mw, v2Deps.Reader, v2Deps.Creator, v2Deps.Mutator, v2Deps.Provenance, v2Deps.Store, techSpaceId, v2Deps.AccountId) + s.v2CreateDisabled = v2Deps.Creator == nil + s.v2EditDisabled = v2Deps.Mutator == nil } - s.engine = s.NewRouter(mw, eventService, openapiYAML, openapiJSON) + s.engine = s.NewRouter(mw, eventService, docs.V1YAML, docs.V1JSON) s.KeyToToken = make(map[string]ApiSessionEntry) + s.legacyKeyLogSeen = make(map[string]time.Time) return s } +// legacyKeyLogInterval re-arms the per-key legacy-usage log line: the first +// request after process start logs, later requests stay silent for an hour. +const legacyKeyLogInterval = time.Hour + +// shouldLogLegacyKeyUse reports whether this legacy-key request is the one +// that logs, and arms the limiter. Keyed by key id so two legacy keys each +// get their own line. +func (srv *Server) shouldLogLegacyKeyUse(keyId string, now time.Time) bool { + srv.mu.Lock() + defer srv.mu.Unlock() + if last, seen := srv.legacyKeyLogSeen[keyId]; seen && now.Sub(last) < legacyKeyLogInterval { + return false + } + srv.legacyKeyLogSeen[keyId] = now + return true +} + // getTechSpaceId retrieves the tech space ID from the account service. func getTechSpaceId(accountService apicore.AccountService) (techSpaceId string, err error) { accountInfo, err := accountService.GetInfo(context.Background()) @@ -73,14 +182,21 @@ func (srv *Server) Stop() { srv.service.Stop() } -// RevokeToken removes the cached API key entry associated with the given session token. +// RevokeToken removes EVERY cached API key entry carrying the given session +// token — one session token can back several cached keys, and revocation +// must not leave any of them usable (H4: revocation must be complete). +// +// The generation bump is unconditional, matching no entry included: that is +// precisely the racing case, where the entry this revocation targets is +// still mid-mint and does not exist yet — the bump is what stops the mint +// from caching it afterwards. func (srv *Server) RevokeToken(token string) { srv.mu.Lock() defer srv.mu.Unlock() + srv.evictGen++ for key, entry := range srv.KeyToToken { if entry.Token == token { delete(srv.KeyToToken, key) - return } } } diff --git a/core/api/server/server_test.go b/core/api/server/server_test.go index a0b1fb47ca..3ad83b52cc 100644 --- a/core/api/server/server_test.go +++ b/core/api/server/server_test.go @@ -9,6 +9,7 @@ import ( "github.com/anyproto/anytype-heart/core/api/core/mock_apicore" "github.com/anyproto/anytype-heart/core/subscription" + "github.com/anyproto/anytype-heart/pkg/lib/localstore/objectstore" "github.com/anyproto/anytype-heart/pkg/lib/pb/model" ) @@ -25,6 +26,8 @@ type fixture struct { crossSpaceSubService *mock_apicore.MockCrossSpaceSubscriptionService chatSubService *mock_apicore.MockChatSubscriptionService fileObjectMock *mock_apicore.MockFileObjectService + // objectStore is set by the v2 fixture only (nil on the plain fixture). + objectStore *objectstore.StoreFixture } func newFixture(t *testing.T) *fixture { @@ -40,7 +43,7 @@ func newFixture(t *testing.T) *fixture { TechSpaceId: mockedTechSpaceId, }, nil).Once() - server := NewServer(mwMock, accountMock, eventMock, crossSpaceSubService, chatSubService, fileObjectMock, mockedListenAddr, []byte{}, []byte{}) + server := NewServer(mwMock, accountMock, eventMock, crossSpaceSubService, chatSubService, fileObjectMock, V2Deps{}, mockedListenAddr, OpenApiDocs{}) return &fixture{ Server: server, @@ -115,6 +118,43 @@ func TestBuildApiBaseUrl(t *testing.T) { } } +func TestServer_RevokeToken(t *testing.T) { + t.Run("evicts every cache entry carrying the token", func(t *testing.T) { + // given: two keys mapping to the same session token plus an unrelated + // key — revocation must evict every entry carrying the token, not just + // the first match (H4: revocation must be complete) + s := newFixture(t) + s.KeyToToken = map[string]ApiSessionEntry{ + "key1": {Token: "revoked-token"}, + "key2": {Token: "revoked-token"}, + "other": {Token: "other-token"}, + } + + // when + s.RevokeToken("revoked-token") + + // then + want := map[string]ApiSessionEntry{ + "other": {Token: "other-token"}, + } + require.Equal(t, want, s.KeyToToken) + }) + + t.Run("no-op when the token is not cached", func(t *testing.T) { + // given + s := newFixture(t) + s.KeyToToken = map[string]ApiSessionEntry{ + "key1": {Token: "token1"}, + } + + // when + s.RevokeToken("unknown-token") + + // then + require.Len(t, s.KeyToToken, 1) + }) +} + func TestServer_Engine(t *testing.T) { t.Run("Engine returns same engine instance", func(t *testing.T) { // given diff --git a/core/api/server/v2_router_test.go b/core/api/server/v2_router_test.go new file mode 100644 index 0000000000..c54fb4bc57 --- /dev/null +++ b/core/api/server/v2_router_test.go @@ -0,0 +1,426 @@ +package server + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + + "github.com/anyproto/anytype-heart/core/api/core/mock_apicore" + "github.com/anyproto/anytype-heart/core/api/util" + apiv2 "github.com/anyproto/anytype-heart/core/api/v2" + "github.com/anyproto/anytype-heart/core/domain" + "github.com/anyproto/anytype-heart/core/subscription" + "github.com/anyproto/anytype-heart/pb" + "github.com/anyproto/anytype-heart/pkg/lib/bundle" + "github.com/anyproto/anytype-heart/pkg/lib/localstore/objectstore" + "github.com/anyproto/anytype-heart/pkg/lib/pb/model" +) + +// newV2Fixture builds a server with the v2 dependencies present, so the /v2 +// route group is registered. +func newV2ServerFixture(t *testing.T) *fixture { + mwMock := mock_apicore.NewMockClientCommands(t) + accountMock := mock_apicore.NewMockAccountService(t) + eventMock := mock_apicore.NewMockEventService(t) + crossSpaceSubService := mock_apicore.NewMockCrossSpaceSubscriptionService(t) + chatSubService := mock_apicore.NewMockChatSubscriptionService(t) + fileObjectMock := mock_apicore.NewMockFileObjectService(t) + readerMock := mock_apicore.NewMockObjectReader(t) + store := objectstore.NewStoreFixture(t) + + creatorMock := mock_apicore.NewMockObjectCreator(t) + mutatorMock := mock_apicore.NewMockObjectMutator(t) + + crossSpaceSubService.On("Subscribe", mock.Anything, mock.Anything).Return(&subscription.SubscribeResponse{}, nil).Maybe() + accountMock.On("GetInfo", mock.Anything).Return(&model.AccountInfo{TechSpaceId: mockedTechSpaceId}, nil).Once() + + server := NewServer(mwMock, accountMock, eventMock, crossSpaceSubService, chatSubService, fileObjectMock, + V2Deps{Reader: readerMock, Creator: creatorMock, Mutator: mutatorMock, Store: store}, mockedListenAddr, OpenApiDocs{}) + + return &fixture{ + Server: server, + mwMock: mwMock, + accountMock: accountMock, + eventMock: eventMock, + crossSpaceSubService: crossSpaceSubService, + chatSubService: chatSubService, + fileObjectMock: fileObjectMock, + objectStore: store, + } +} + +func TestV2Routes(t *testing.T) { + t.Run("v2 routes require auth", func(t *testing.T) { + // given: no Authorization header — the answer must be + // ensureAuthenticated's 401, never the scope gate's 403: the gate + // runs after auth and must not see an unauthenticated request + fx := newV2ServerFixture(t) + w := httptest.NewRecorder() + req := httptest.NewRequest("GET", "/v2/spaces", nil) + req.Host = localApiHost + + // when + fx.Engine().ServeHTTP(w, req) + + // then + require.Equal(t, http.StatusUnauthorized, w.Code) + expectedJSON, err := json.Marshal(util.CodeToApiError(http.StatusUnauthorized, ErrMissingAuthorizationHeader.Error())) + require.NoError(t, err) + require.JSONEq(t, string(expectedJSON), w.Body.String()) + }) + + t.Run("v2 validate responds through the shared auth", func(t *testing.T) { + // both scopes that admit the JSON API pass the /v2 scope gate + for _, scope := range []model.AccountAuthLocalApiScope{ + model.AccountAuth_JsonAPI, + model.AccountAuth_Full, + } { + t.Run(scope.String(), func(t *testing.T) { + // given + fx := newV2ServerFixture(t) + fx.KeyToToken = map[string]ApiSessionEntry{"validKey": {Token: "tok", Scope: scope}} + fx.eventMock.On("Broadcast", mock.Anything).Return(nil).Maybe() + w := httptest.NewRecorder() + req := httptest.NewRequest("POST", "/v2/validate", strings.NewReader(`{"version":1,"blocks":[]}`)) + req.Host = localApiHost + req.Header.Set("Authorization", "Bearer validKey") + + // when + fx.Engine().ServeHTTP(w, req) + + // then + require.Equal(t, http.StatusOK, w.Code) + require.Contains(t, w.Body.String(), `"issues":[]`) + }) + } + }) + + t.Run("a Limited key is refused with 403 and the actionable body", func(t *testing.T) { + // given: a valid but Limited (web-clipper) key — authenticated, yet + // not authorized for /v2 (H2). 403, distinct from the 401 + // invalid-key path, naming the key, its scope, and the remedy. + fx := newV2ServerFixture(t) + fx.KeyToToken = map[string]ApiSessionEntry{ + "limitedKey": {Token: "tok", AppName: "clipper", Scope: model.AccountAuth_Limited}, + } + w := httptest.NewRecorder() + req := httptest.NewRequest("GET", "/v2/spaces", nil) + req.Host = localApiHost + req.Header.Set("Authorization", "Bearer limitedKey") + + // when + fx.Engine().ServeHTTP(w, req) + + // then + require.Equal(t, http.StatusForbidden, w.Code) + wantMessage := `api key scope does not allow json api access: key "clipper" has Limited scope, create a new api key with JsonAPI scope` + expectedJSON, err := json.Marshal(util.CodeToApiError(http.StatusForbidden, wantMessage)) + require.NoError(t, err) + require.JSONEq(t, string(expectedJSON), w.Body.String()) + }) + + t.Run("expired key gets the distinct 401 on /v2", func(t *testing.T) { + // given: expiry is enforced in ensureAuthenticated for BOTH groups — + // only the scope refusal is /v2-only (H5 did not move) + fx := newV2ServerFixture(t) + fx.KeyToToken = map[string]ApiSessionEntry{ + "expiredKey": {Token: "tok", Scope: model.AccountAuth_JsonAPI, ExpireAt: time.Now().Unix() - 60}, + } + w := httptest.NewRecorder() + req := httptest.NewRequest("GET", "/v2/spaces", nil) + req.Host = localApiHost + req.Header.Set("Authorization", "Bearer expiredKey") + + // when + fx.Engine().ServeHTTP(w, req) + + // then + require.Equal(t, http.StatusUnauthorized, w.Code) + expectedJSON, err := json.Marshal(util.CodeToApiError(http.StatusUnauthorized, ErrApiKeyExpired.Error())) + require.NoError(t, err) + require.JSONEq(t, string(expectedJSON), w.Body.String()) + }) + + t.Run("every /v2 route carries the scope gate", func(t *testing.T) { + // The gate is installed by group membership (v2.Use in + // apiv2.RegisterRoutes), so a /v2 route registered on the engine + // directly — the pattern the public docs routes already use — would + // silently carry neither auth nor the gate. This walks the REAL + // engine's route table with a cached Limited key: every /v2 route + // must answer the gate's exact 403, except the explicit exempt list. + fx := newV2ServerFixture(t) + fx.KeyToToken = map[string]ApiSessionEntry{ + "limitedKey": {Token: "tok", AppName: "clipper", Scope: model.AccountAuth_Limited}, + } + + wantMessage := `api key scope does not allow json api access: key "clipper" has Limited scope, create a new api key with JsonAPI scope` + expectedJSON, err := json.Marshal(util.CodeToApiError(http.StatusForbidden, wantMessage)) + require.NoError(t, err) + + // The exempt set is DERIVED from the authorization registry — the one + // place the auth-exempt fact is written down (a second hand-kept list + // here could be edited into agreement with a hole). The registry + // class itself is verified behaviorally by the grant conformance + // walk: an auth-exempt route must answer without credentials, every + // other /v2 route must 401. Growing the class is an API decision, + // not a registration accident. + exempt := map[string]bool{} + for key, entry := range apiv2.RouteAuthzTable() { + if entry.Global == apiv2.GlobalAuthExempt { + exempt[key] = true + } + } + require.Len(t, exempt, 2, "the auth-exempt class is the two public documents — growing it is an API decision") + + v2Routes := 0 + for _, route := range fx.Engine().Routes() { + if !strings.HasPrefix(route.Path, "/v2/") { + continue + } + v2Routes++ + + // substitute path params so gin routes the probe to the handler + segments := strings.Split(route.Path, "/") + for i, segment := range segments { + if strings.HasPrefix(segment, ":") || strings.HasPrefix(segment, "*") { + segments[i] = "x" + } + } + path := strings.Join(segments, "/") + + w := httptest.NewRecorder() + req := httptest.NewRequest(route.Method, path, strings.NewReader(`{}`)) + req.Host = localApiHost + req.Header.Set("Authorization", "Bearer limitedKey") + fx.Engine().ServeHTTP(w, req) + + if exempt[route.Method+" "+route.Path] { + require.NotEqual(t, http.StatusForbidden, w.Code, + "%s %s is exempt: a public document must not sit behind the gate", route.Method, route.Path) + continue + } + require.Equal(t, http.StatusForbidden, w.Code, + "%s %s must refuse a Limited key — is it registered on the gated v2 group?", route.Method, route.Path) + require.JSONEq(t, string(expectedJSON), w.Body.String(), + "%s %s must answer with the scope gate's 403 body", route.Method, route.Path) + } + require.GreaterOrEqual(t, v2Routes, 40, "the walk must cover the /v2 surface, not a filtered-away remnant") + }) + + t.Run("the idempotency middleware is wired on the edit routes", func(t *testing.T) { + // the middleware itself is unit-tested, but its REGISTRATION is the + // user-visible half of C8 on PATCH: dropping idempotencyMW from + // registerV2EditRoutes would silently stop replay on every edit route + // while every other test stayed green. A replayed request is answered + // from the store before auth runs, so the marker proves the middleware + // is in the chain without needing a full edit to succeed. + // The body-size guard lives in the idempotency middleware and fires + // only for a keyed mutation, so a keyed oversized request answered + // with 413 request_too_large proves the middleware is in that route's + // chain — without needing the edit itself to succeed. + for _, route := range []struct{ method, path string }{ + {"PATCH", "/v2/spaces/space1/objects/obj1"}, + {"PATCH", "/v2/spaces/space1/types/task"}, + {"PATCH", "/v2/spaces/space1/properties/status"}, + // Phase-7 space mutations: a retried space create without C8 + // duplicates an ENTIRE SPACE — the worst possible duplicate + {"POST", "/v2/spaces"}, + {"PATCH", "/v2/spaces/space1"}, + // Phase-6 chat mutations: C8 on every one — a double-sent chat + // message is user-visible damage. DELETE is the Phase-6 widening + // of the middleware's method set. + {"POST", "/v2/spaces/space1/chats"}, + // C8 is route-uniform: the type/property DELETEs carry the + // middleware too — an agent sending Idempotency-Key on every + // mutation must not get replay protection on one DELETE and + // silently none on another (the review's C8 finding) + {"DELETE", "/v2/spaces/space1/types/task"}, + {"DELETE", "/v2/spaces/space1/properties/status"}, + {"POST", "/v2/spaces/space1/chats/chat1/messages"}, + {"PATCH", "/v2/spaces/space1/chats/chat1/messages/msg1"}, + {"DELETE", "/v2/spaces/space1/chats/chat1/messages/msg1"}, + {"POST", "/v2/spaces/space1/chats/chat1/messages/msg1/reactions"}, + {"POST", "/v2/spaces/space1/chats/chat1/read"}, + } { + t.Run(route.method+" "+route.path, func(t *testing.T) { + fx := newV2ServerFixture(t) + fx.KeyToToken = map[string]ApiSessionEntry{"validKey": {Token: "tok", Scope: model.AccountAuth_JsonAPI}} + fx.eventMock.On("Broadcast", mock.Anything).Return(nil).Maybe() + + req := httptest.NewRequest(route.method, route.path, + strings.NewReader(strings.Repeat("x", apiv2.MaxRequestBody+1))) + req.Host = localApiHost + req.Header.Set("Authorization", "Bearer validKey") + req.Header.Set(apiv2.IdempotencyKeyHeader, "routekey1") + w := httptest.NewRecorder() + + fx.Engine().ServeHTTP(w, req) + + require.Equal(t, http.StatusRequestEntityTooLarge, w.Code, + "the idempotency middleware must be registered on this route") + require.Contains(t, w.Body.String(), `"request_too_large"`) + }) + } + }) + + t.Run("a keyed POST /v2/spaces retry replays — exactly one space is created", func(t *testing.T) { + // POST /v2/spaces is the ONE v2 mutation whose route has no :space_id, + // so its idempotency store key carries an empty space component — a + // namespace no other replay test exercises end to end. It is also the + // mutation where a duplicate is worst: an entire space, with no v2 + // delete to recover through. The .Once() on WorkspaceCreate is the + // load-bearing assertion — a second RPC fails the mock. + fx := newV2ServerFixture(t) + fx.KeyToToken = map[string]ApiSessionEntry{"validKey": {Token: "tok", Scope: model.AccountAuth_JsonAPI}} + fx.eventMock.On("Broadcast", mock.Anything).Return(nil).Maybe() + fx.mwMock.EXPECT().WorkspaceCreate(mock.Anything, mock.Anything). + Return(&pb.RpcWorkspaceCreateResponse{SpaceId: "newSpace1"}).Once() + + post := func() *httptest.ResponseRecorder { + req := httptest.NewRequest("POST", "/v2/spaces", strings.NewReader(`{"name":"Research"}`)) + req.Host = localApiHost + req.Header.Set("Authorization", "Bearer validKey") + req.Header.Set("Content-Type", "application/json") + req.Header.Set(apiv2.IdempotencyKeyHeader, "spacekey1") + w := httptest.NewRecorder() + fx.Engine().ServeHTTP(w, req) + return w + } + + first := post() + second := post() + + require.Equal(t, http.StatusCreated, first.Code) + require.Contains(t, first.Body.String(), `"newSpace1"`) + require.Equal(t, http.StatusCreated, second.Code) + require.Equal(t, first.Body.String(), second.Body.String(), "the stored 201 is replayed byte-identical") + require.Equal(t, "true", second.Header().Get("Idempotency-Replayed")) + }) + + t.Run("search is a read: no idempotency middleware on the search routes", func(t *testing.T) { + // Phase 4: search is exempt from Idempotency-Key — the middleware is + // per-route and deliberately not attached. The proof is behavioral: + // the SAME keyed request executed twice must run twice; were the + // middleware in the chain, the second 2xx would be answered from its + // store with an Idempotency-Replayed header. (An earlier form of this + // test sent an unauthorized request and asserted 401 — vacuous, since + // the group-level auth aborts before any route middleware runs.) + for _, path := range []string{"/v2/search", "/v2/spaces/space1/search"} { + t.Run(path, func(t *testing.T) { + fx := newV2ServerFixture(t) + fx.KeyToToken = map[string]ApiSessionEntry{"validKey": {Token: "tok", Scope: model.AccountAuth_JsonAPI}} + fx.eventMock.On("Broadcast", mock.Anything).Return(nil).Maybe() + // register space1 in the store fixture's tech space so the + // space-scoped search resolves it (C2) + fx.objectStore.AddObjects(t, objectstore.TestTechSpaceId, []objectstore.TestObject{{ + bundle.RelationKeyId: domain.String("spaceView_space1"), + bundle.RelationKeyResolvedLayout: domain.Int64(int64(model.ObjectType_spaceView)), + bundle.RelationKeyTargetSpaceId: domain.String("space1"), + }}) + + for run := 0; run < 2; run++ { + req := httptest.NewRequest("POST", path, strings.NewReader(`{}`)) + req.Host = localApiHost + req.Header.Set("Authorization", "Bearer validKey") + req.Header.Set(apiv2.IdempotencyKeyHeader, "searchkey1") + w := httptest.NewRecorder() + + fx.Engine().ServeHTTP(w, req) + + require.Equal(t, http.StatusOK, w.Code) + require.Empty(t, w.Header().Get("Idempotency-Replayed"), + "a keyed search must never replay — search carries no idempotency middleware") + } + }) + } + }) + + t.Run("chat messages read rejects offset with cursor steering", func(t *testing.T) { + // the messages read is cursor-paged (after/before order ids); a + // silently honored ?offset= would let an agent believe it pages by + // offset while the RPC ignores it — reject with steering instead + fx := newV2ServerFixture(t) + fx.KeyToToken = map[string]ApiSessionEntry{"validKey": {Token: "tok", Scope: model.AccountAuth_JsonAPI}} + fx.eventMock.On("Broadcast", mock.Anything).Return(nil).Maybe() + + req := httptest.NewRequest("GET", "/v2/spaces/space1/chats/chat1/messages?offset=5", nil) + req.Host = localApiHost + req.Header.Set("Authorization", "Bearer validKey") + w := httptest.NewRecorder() + + fx.Engine().ServeHTTP(w, req) + + require.Equal(t, http.StatusBadRequest, w.Code) + require.Contains(t, w.Body.String(), "cursor-paged") + require.Contains(t, w.Body.String(), "after") + }) + + t.Run("v2 group absent without deps", func(t *testing.T) { + // given: the plain fixture constructs NewServer with V2Deps{} + fx := newFixture(t) + w := httptest.NewRecorder() + req := httptest.NewRequest("GET", "/v2/spaces", nil) + req.Host = localApiHost + + // when + fx.Engine().ServeHTTP(w, req) + + // then + require.Equal(t, http.StatusNotFound, w.Code) + }) + + t.Run("create routes are registered and require auth", func(t *testing.T) { + // given + fx := newV2ServerFixture(t) + + for _, route := range []struct{ method, path string }{ + {"GET", "/v2/spaces/space1"}, + {"POST", "/v2/spaces"}, + {"PATCH", "/v2/spaces/space1"}, + {"POST", "/v2/spaces/space1/objects"}, + {"POST", "/v2/spaces/space1/types"}, + {"PATCH", "/v2/spaces/space1/types/task"}, + {"DELETE", "/v2/spaces/space1/types/task"}, + {"POST", "/v2/spaces/space1/properties"}, + {"PATCH", "/v2/spaces/space1/properties/status"}, + {"DELETE", "/v2/spaces/space1/properties/status"}, + {"POST", "/v2/spaces/space1/sets"}, + {"POST", "/v2/spaces/space1/collections"}, + {"POST", "/v2/spaces/space1/templates"}, + {"POST", "/v2/spaces/space1/files"}, + {"GET", "/v2/schemas"}, + {"GET", "/v2/schemas/object"}, + {"GET", "/v2/schemas/ops/replace_text"}, + {"PATCH", "/v2/spaces/space1/objects/obj1"}, + {"POST", "/v2/search"}, + {"POST", "/v2/spaces/space1/search"}, + {"GET", "/v2/spaces/space1/sets/set1/objects"}, + {"GET", "/v2/spaces/space1/sets/set1/views"}, + {"GET", "/v2/spaces/space1/collections/col1/objects"}, + {"GET", "/v2/spaces/space1/collections/col1/views"}, + {"GET", "/v2/spaces/space1/chats"}, + {"POST", "/v2/spaces/space1/chats"}, + {"GET", "/v2/spaces/space1/chats/chat1/messages"}, + {"POST", "/v2/spaces/space1/chats/chat1/messages"}, + {"PATCH", "/v2/spaces/space1/chats/chat1/messages/msg1"}, + {"DELETE", "/v2/spaces/space1/chats/chat1/messages/msg1"}, + {"POST", "/v2/spaces/space1/chats/chat1/messages/msg1/reactions"}, + {"POST", "/v2/spaces/space1/chats/chat1/read"}, + } { + // when + w := httptest.NewRecorder() + req := httptest.NewRequest(route.method, route.path, strings.NewReader(`{}`)) + req.Host = localApiHost + fx.Engine().ServeHTTP(w, req) + + // then: 401 (not 404) proves the route exists behind shared auth + require.Equal(t, http.StatusUnauthorized, w.Code, "%s %s", route.method, route.path) + } + }) +} diff --git a/core/api/server/v2_spaceref_test.go b/core/api/server/v2_spaceref_test.go new file mode 100644 index 0000000000..279178d8d1 --- /dev/null +++ b/core/api/server/v2_spaceref_test.go @@ -0,0 +1,236 @@ +package server + +// v2_spaceref_test.go walks the REAL engine for the short space reference +// (APIV2.md §8.35). The unit tests in core/api/v2 build their own middleware +// chain, so they cannot see whether apiv2.RegisterRoutes actually installs +// the resolution middleware, or whether it installs it in front of the grant +// gate. This file is the one that fails if the router wiring goes. + +import ( + "net/http" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + + "github.com/anyproto/anytype-heart/core/api/util" + "github.com/anyproto/anytype-heart/pkg/lib/pb/model" +) + +const ( + // a real space id off the eval account: 59-char base32 CID, a dot, and + // the base36 replication key every space on that account shares + spaceRefFullId = "bafyreihwvsaekzzyb54o7um4hdpvpn5b2invn75lmijhhtghblvphxwz2i.28y6mgnwgodt7" + spaceRefShort = "hxwz2i" +) + +// newSpaceRefServerFixture is newV2ServerFixture with the analytics +// broadcast tolerated: these probes hit real (non-refused) routes, which the +// analytics middleware reports. +func newSpaceRefServerFixture(t *testing.T) *fixture { + fx := newV2ServerFixture(t) + fx.eventMock.On("Broadcast", mock.Anything).Return(nil).Maybe() + return fx +} + +func TestV2ShortSpaceRefThroughTheRealEngine(t *testing.T) { + t.Run("the spaces list serves the short reference", func(t *testing.T) { + // given + fx := newSpaceRefServerFixture(t) + registerGrantTestSpace(t, fx, spaceRefFullId, "APIv2 eval") + fx.KeyToToken = map[string]ApiSessionEntry{"k": {Token: "tok", Scope: model.AccountAuth_JsonAPI}} + + // when + w := serveWithKey(fx, "GET", "/v2/spaces", "k") + + // then + require.Equal(t, http.StatusOK, w.Code) + assert.Contains(t, w.Body.String(), `"id":"`+spaceRefShort+`"`) + assert.NotContains(t, w.Body.String(), spaceRefFullId) + }) + + t.Run("a short reference on a path param resolves through the registered chain", func(t *testing.T) { + // given: this is the router-wiring assertion — removing + // resolveSpaceRef from RegisterRoutes turns this into a 404 + fx := newSpaceRefServerFixture(t) + registerGrantTestSpace(t, fx, spaceRefFullId, "APIv2 eval") + fx.KeyToToken = map[string]ApiSessionEntry{"k": {Token: "tok", Scope: model.AccountAuth_JsonAPI}} + + // when + w := serveWithKey(fx, "GET", "/v2/spaces/"+spaceRefShort, "k") + + // then + require.Equal(t, http.StatusOK, w.Code) + assert.Contains(t, w.Body.String(), `"id":"`+spaceRefShort+`"`) + }) + + t.Run("the full id keeps working through the same chain", func(t *testing.T) { + fx := newSpaceRefServerFixture(t) + registerGrantTestSpace(t, fx, spaceRefFullId, "APIv2 eval") + fx.KeyToToken = map[string]ApiSessionEntry{"k": {Token: "tok", Scope: model.AccountAuth_JsonAPI}} + + w := serveWithKey(fx, "GET", "/v2/spaces/"+spaceRefFullId, "k") + + require.Equal(t, http.StatusOK, w.Code) + }) + + t.Run("a granted key reaches its space by the short reference", func(t *testing.T) { + // given: the grant holds the FULL id, as grants always do. If + // resolution ran after the gate, the gate would see "hxwz2i", find + // it in no grant, and answer 403. + fx := newSpaceRefServerFixture(t) + registerGrantTestSpace(t, fx, spaceRefFullId, "APIv2 eval") + grantedSession(fx, "scopedKey", &util.ApiGrant{ + Spaces: []string{spaceRefFullId}, Perms: util.GrantPermsRead, + }) + + // when + w := serveWithKey(fx, "GET", "/v2/spaces/"+spaceRefShort, "scopedKey") + + // then + require.Equal(t, http.StatusOK, w.Code, "resolution must run BEFORE the grant gate") + }) + + t.Run("a short reference is not a way past the grant", func(t *testing.T) { + // given: two live spaces, the key granted only the other one + fx := newSpaceRefServerFixture(t) + registerGrantTestSpace(t, fx, spaceRefFullId, "APIv2 eval") + registerGrantTestSpace(t, fx, otherSpaceRefFullId, "Project Tracker") + grantedSession(fx, "scopedKey", &util.ApiGrant{ + Spaces: []string{spaceRefFullId}, Perms: util.GrantPermsRead, + }) + + // when: the NON-granted space's tail + w := serveWithKey(fx, "GET", "/v2/spaces/"+otherSpaceRefShort, "scopedKey") + + // then: refused, quoting the caller's value and never naming the + // space the tail belongs to + require.Equal(t, http.StatusForbidden, w.Code) + assert.Contains(t, w.Body.String(), otherSpaceRefShort) + assert.NotContains(t, w.Body.String(), otherSpaceRefFullId) + }) +} + +const ( + otherSpaceRefFullId = "bafyreia4znhhjvxek2iux7enfzilekj5vwlgddgogpfgnx5qnim7bugaxa.28y6mgnwgodt7" + otherSpaceRefShort = "bugaxa" + + // two spaces differing at the SIXTH character from the end of the CID + // half: distinct short forms (q3oake / r3oake) over one shared + // five-character tail that answers to neither alone + cousinSpaceAFullId = "bafyreiay4rdeleruyuy6x575hvhtifedmjq4g3ojpffvpbgmuackq3oake.28y6mgnwgodt7" + cousinSpaceAShort = "q3oake" + cousinSpaceBFullId = "bafyreiay4rdeleruyuy6x575hvhtifedmjq4g3ojpffvpbgmuackr3oake.28y6mgnwgodt7" + cousinSharedTail = "3oake" +) + +// TestV2FullSpaceIdsThroughTheRealEngine walks the registered engine for +// §8.36. The package-local middleware tests build their own chain, so none +// of them can see whether apiv2.RegisterRoutes installs ensureIdsShape at +// all — this file is the one that fails if that line goes. +// +// It also covers the surfaces the chain reaches only through the real +// router: whoami (no space in its path, so nothing else exercises the +// parameter there) and the C10-paginated spaces list. +func TestV2FullSpaceIdsThroughTheRealEngine(t *testing.T) { + t.Run("the spaces list serves the full id under ?ids=full", func(t *testing.T) { + // given + fx := newSpaceRefServerFixture(t) + registerGrantTestSpace(t, fx, spaceRefFullId, "APIv2 eval") + fx.KeyToToken = map[string]ApiSessionEntry{"k": {Token: "tok", Scope: model.AccountAuth_JsonAPI}} + + // when + w := serveWithKey(fx, "GET", "/v2/spaces?ids=full", "k") + + // then: the id a caller can persist — and NOT the short reference, + // which is only unique against the spaces visible right now + require.Equal(t, http.StatusOK, w.Code) + assert.Contains(t, w.Body.String(), `"id":"`+spaceRefFullId+`"`) + assert.NotContains(t, w.Body.String(), `"id":"`+spaceRefShort+`"`) + }) + + t.Run("the default is untouched — short stays the served shape", func(t *testing.T) { + fx := newSpaceRefServerFixture(t) + registerGrantTestSpace(t, fx, spaceRefFullId, "APIv2 eval") + fx.KeyToToken = map[string]ApiSessionEntry{"k": {Token: "tok", Scope: model.AccountAuth_JsonAPI}} + + w := serveWithKey(fx, "GET", "/v2/spaces?ids=compact", "k") + + require.Equal(t, http.StatusOK, w.Code) + assert.Contains(t, w.Body.String(), `"id":"`+spaceRefShort+`"`) + }) + + t.Run("GET-one accepts the short reference and serves the full id back", func(t *testing.T) { + // given: the round trip a caller makes to turn a reference it was + // served into one it can store + fx := newSpaceRefServerFixture(t) + registerGrantTestSpace(t, fx, spaceRefFullId, "APIv2 eval") + fx.KeyToToken = map[string]ApiSessionEntry{"k": {Token: "tok", Scope: model.AccountAuth_JsonAPI}} + + // when + w := serveWithKey(fx, "GET", "/v2/spaces/"+spaceRefShort+"?ids=full", "k") + + // then + require.Equal(t, http.StatusOK, w.Code) + assert.Contains(t, w.Body.String(), `"id":"`+spaceRefFullId+`"`) + }) + + t.Run("whoami spells its grant echo in full", func(t *testing.T) { + // given: a grant is keyed by the FULL id, and this is the surface + // that tells a holder which spaces it holds + fx := newSpaceRefServerFixture(t) + registerGrantTestSpace(t, fx, spaceRefFullId, "APIv2 eval") + grantedSession(fx, "scopedKey", &util.ApiGrant{ + Spaces: []string{spaceRefFullId}, Perms: util.GrantPermsRead, + }) + + // when + full := serveWithKey(fx, "GET", "/v2/auth/whoami?ids=full", "scopedKey") + short := serveWithKey(fx, "GET", "/v2/auth/whoami", "scopedKey") + + // then + require.Equal(t, http.StatusOK, full.Code) + require.Equal(t, http.StatusOK, short.Code) + assert.Contains(t, full.Body.String(), `"id":"`+spaceRefFullId+`"`) + assert.Contains(t, short.Body.String(), `"id":"`+spaceRefShort+`"`) + }) + + t.Run("the shape is registered IN FRONT of resolution — an ambiguity's candidates obey it", func(t *testing.T) { + // given: two spaces that both answer to `3oake` and both have short + // forms of their own. The candidate list is minted inside + // ResolveSpaceRef, so it only obeys ?ids= if RegisterRoutes installs + // ensureIdsShape BEFORE resolveSpaceRef. + fx := newSpaceRefServerFixture(t) + registerGrantTestSpace(t, fx, cousinSpaceAFullId, "Cousin A") + registerGrantTestSpace(t, fx, cousinSpaceBFullId, "Cousin B") + fx.KeyToToken = map[string]ApiSessionEntry{"k": {Token: "tok", Scope: model.AccountAuth_JsonAPI}} + + // when + short := serveWithKey(fx, "GET", "/v2/spaces/"+cousinSharedTail+"/objects", "k") + full := serveWithKey(fx, "GET", "/v2/spaces/"+cousinSharedTail+"/objects?ids=full", "k") + + // then + require.Equal(t, http.StatusBadRequest, short.Code) + require.Equal(t, http.StatusBadRequest, full.Code) + assert.Contains(t, short.Body.String(), cousinSpaceAShort) + assert.NotContains(t, short.Body.String(), cousinSpaceAFullId) + assert.Contains(t, full.Body.String(), cousinSpaceAFullId) + assert.Contains(t, full.Body.String(), cousinSpaceBFullId) + }) + + t.Run("an unknown ids value is a 400 on a space route too", func(t *testing.T) { + // given: the parameter is validated once for the whole group, so the + // refusal does not depend on the route owning it + fx := newSpaceRefServerFixture(t) + registerGrantTestSpace(t, fx, spaceRefFullId, "APIv2 eval") + fx.KeyToToken = map[string]ApiSessionEntry{"k": {Token: "tok", Scope: model.AccountAuth_JsonAPI}} + + // when + w := serveWithKey(fx, "GET", "/v2/spaces?ids=export", "k") + + // then + require.Equal(t, http.StatusBadRequest, w.Code) + assert.Contains(t, w.Body.String(), "compact, full") + }) +} diff --git a/core/api/server/v2_wrapper_routes_test.go b/core/api/server/v2_wrapper_routes_test.go new file mode 100644 index 0000000000..ed66052937 --- /dev/null +++ b/core/api/server/v2_wrapper_routes_test.go @@ -0,0 +1,29 @@ +package server + +import ( + "fmt" + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/anyproto/anytype-heart/core/api/wrapper" +) + +// TestWrapperRoutesRegistered walks the task-tool wrapper's hard-coded +// route templates against the gin router's registered routes: the wrapper +// suite stubs its own paths, so a renamed /v2 route would otherwise stay +// green there while every tool 404s in production. +func TestWrapperRoutesRegistered(t *testing.T) { + fx := newV2ServerFixture(t) + engine := fx.NewRouter(fx.mwMock, fx.eventMock, []byte{}, []byte{}) + + registered := map[string]bool{} + for _, route := range engine.Routes() { + registered[route.Method+" "+route.Path] = true + } + + for _, rt := range wrapper.RouteTemplates() { + assert.True(t, registered[rt.Method+" "+rt.Path], + fmt.Sprintf("the wrapper calls %s %s but the router does not register it", rt.Method, rt.Path)) + } +} diff --git a/core/api/server/whoami_test.go b/core/api/server/whoami_test.go new file mode 100644 index 0000000000..46d76d4672 --- /dev/null +++ b/core/api/server/whoami_test.go @@ -0,0 +1,331 @@ +package server + +// whoami_test.go pins the P1c introspection surface end to end through the +// real engine: the exact whoami bodies for both key kinds, the +// Authorization-header-only credential rule, the plain 401 for unknown +// keys, the legacy-key deprecation signal on both route groups, and — the +// load-bearing one — the anti-drift test proving the whoami mirror and the +// space-grant gate cannot disagree about the same key. + +import ( + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + + "github.com/anyproto/anytype-heart/core/api/util" + v2model "github.com/anyproto/anytype-heart/core/api/v2/model" + "github.com/anyproto/anytype-heart/pb" + "github.com/anyproto/anytype-heart/pkg/lib/pb/model" +) + +// assertNoRfc9745Headers pins the spec's hardest prohibition: never emit +// RFC 9745 Deprecation or RFC 8594 Sunset. §2.2 scopes those to the RESOURCE +// in the response, so emitting them on /v1 would declare /v1 itself +// deprecated — the opposite of the grandfathering promise. The credential +// signal is Anytype-Key-Status and the date-free Link rel="deprecation". +func assertNoRfc9745Headers(t *testing.T, w *httptest.ResponseRecorder) { + t.Helper() + require.Empty(t, w.Header().Get("Deprecation"), "RFC 9745 Deprecation is forbidden — it deprecates the RESOURCE, not the credential") + require.Empty(t, w.Header().Get("Sunset"), "RFC 8594 Sunset is forbidden — no sunset is committed, and it too names the resource") +} + +func TestWhoami(t *testing.T) { + t.Run("scoped key: the exact body, names from the grant-intersected list", func(t *testing.T) { + // given: two live spaces, the grant covers one — the non-granted + // space's name must not appear anywhere in the body + fx := newV2ServerFixture(t) + registerGrantTestSpace(t, fx, "spaceA", "Work") + registerGrantTestSpace(t, fx, "spaceB", "Personal") + fx.KeyToToken = map[string]ApiSessionEntry{ + "scopedKey": { + Token: "tok", AppName: "Claude Desktop", Scope: model.AccountAuth_JsonAPI, + Grant: &util.ApiGrant{Spaces: []string{"spaceA"}, Perms: util.GrantPermsReadWrite}, + KeyId: "hash1", + CreatedAt: 1700000000, + }, + } + fx.eventMock.On("Broadcast", mock.Anything).Return(nil).Maybe() + want := fmt.Sprintf(`{ + "key": {"id":"hash1","name":"Claude Desktop","created_at":"2023-11-14T22:13:20Z","expires_at":null}, + "scope": "jsonApi", + "grant": {"scoped":true,"permission":"readwrite", + "spaces":[{"id":"spaceA","name":"Work","permission":"readwrite"}]}, + "api": {"version":%q}, + "key_status": "scoped" + }`, util.ApiVersion) + + // when + w := serveWithKey(fx, "GET", "/v2/auth/whoami", "scopedKey") + + // then + require.Equal(t, http.StatusOK, w.Code) + require.JSONEq(t, want, w.Body.String()) + require.NotContains(t, w.Body.String(), "Personal") + // the signal headers: status always present, the legacy-only pair absent + assert.Equal(t, util.KeyStatusScoped, w.Header().Get(util.KeyStatusHeader)) + assert.Empty(t, w.Header().Get(util.NoticeHeader), "the notice header is legacy-only") + assert.Empty(t, w.Header().Values("Link"), "the deprecation link is legacy-only") + assertNoRfc9745Headers(t, w) + }) + + t.Run("legacy key: scoped false, spaces [], permission null, the signal in the body", func(t *testing.T) { + // given: a nil-grant key. spaces MUST be [] and scoped an explicit + // false — spaces:null would eventually be misread fail-open. + fx := newV2ServerFixture(t) + fx.KeyToToken = map[string]ApiSessionEntry{ + "legacyKey": { + Token: "tok", AppName: "old-script", Scope: model.AccountAuth_JsonAPI, + KeyId: "hash2", + ExpireAt: 1900000000, + }, + } + fx.eventMock.On("Broadcast", mock.Anything).Return(nil).Maybe() + want := fmt.Sprintf(`{ + "key": {"id":"hash2","name":"old-script","created_at":null,"expires_at":"2030-03-17T17:46:40Z"}, + "scope": "jsonApi", + "grant": {"scoped":false,"permission":null,"spaces":[]}, + "api": {"version":%q}, + "key_status": "legacy", + "notice": %q + }`, util.ApiVersion, util.LegacyKeyNotice) + + // when + w := serveWithKey(fx, "GET", "/v2/auth/whoami", "legacyKey") + + // then + require.Equal(t, http.StatusOK, w.Code) + require.JSONEq(t, want, w.Body.String()) + // the raw JSON must carry the empty ARRAY, not null — JSONEq treats + // them as different already, but pin the bytes to be explicit + require.Contains(t, w.Body.String(), `"spaces":[]`) + assert.Equal(t, util.KeyStatusLegacy, w.Header().Get(util.KeyStatusHeader)) + assert.Equal(t, util.LegacyKeyNotice, w.Header().Get(util.NoticeHeader)) + assert.Equal(t, []string{util.KeyDeprecationLink}, w.Header().Values("Link")) + assertNoRfc9745Headers(t, w) + }) + + t.Run("a Full key is nil-grant but gets no remedial signal", func(t *testing.T) { + // given: a Full-scope credential passes the /v2 scope gate but can + // NEVER carry a grant (wallet.ValidateAppLinkGrant requires JsonAPI), + // so the "re-issue as a scoped key" advice is impossible to follow — + // the status stays legacy, the notice and the Link never appear + fx := newV2ServerFixture(t) + fx.KeyToToken = map[string]ApiSessionEntry{ + "fullKey": {Token: "tok", AppName: "desktop", Scope: model.AccountAuth_Full, KeyId: "hashFull"}, + } + fx.eventMock.On("Broadcast", mock.Anything).Return(nil).Maybe() + + // when + w := serveWithKey(fx, "GET", "/v2/auth/whoami", "fullKey") + + // then + require.Equal(t, http.StatusOK, w.Code) + assert.Equal(t, util.KeyStatusLegacy, w.Header().Get(util.KeyStatusHeader)) + assert.Empty(t, w.Header().Get(util.NoticeHeader), "the notice is JsonAPI-only") + assert.Empty(t, w.Header().Values("Link"), "the deprecation link is JsonAPI-only") + assert.Contains(t, w.Body.String(), `"key_status":"legacy"`) + assert.NotContains(t, w.Body.String(), `"notice"`, "the body notice is JsonAPI-only") + }) + + t.Run("the token is never accepted as a parameter", func(t *testing.T) { + // given: a key that IS valid when presented in the Authorization + // header — presented anywhere else it must count for nothing, or + // whoami becomes the enumeration oracle RFC 7662 §4 warns about + fx := newV2ServerFixture(t) + fx.KeyToToken = map[string]ApiSessionEntry{ + "validKey": {Token: "tok", Scope: model.AccountAuth_JsonAPI}, + } + + for _, probe := range []struct{ name, target, body string }{ + {"query key", "/v2/auth/whoami?key=validKey", ""}, + {"query token", "/v2/auth/whoami?token=validKey", ""}, + {"query access_token", "/v2/auth/whoami?access_token=validKey", ""}, + {"body token", "/v2/auth/whoami", `{"token":"validKey"}`}, + } { + t.Run(probe.name, func(t *testing.T) { + w := httptest.NewRecorder() + req := httptest.NewRequest("GET", probe.target, strings.NewReader(probe.body)) + req.Host = localApiHost + fx.Engine().ServeHTTP(w, req) + + require.Equal(t, http.StatusUnauthorized, w.Code) + require.Equal(t, `Bearer realm="anytype"`, w.Header().Get("WWW-Authenticate")) + require.NotContains(t, w.Body.String(), "grant") + }) + } + }) + + t.Run("an unknown or revoked key gets the plain 401", func(t *testing.T) { + // given: the shared auth middleware answers before the handler — + // uniform across every /v2 route, no whoami-specific shape, no + // RFC 7662 active:false body, no signal headers + fx := newV2ServerFixture(t) + fx.mwMock.On("WalletCreateSession", mock.Anything, mock.Anything). + Return(&pb.RpcWalletCreateSessionResponse{ + Error: &pb.RpcWalletCreateSessionResponseError{ + Code: pb.RpcWalletCreateSessionResponseError_APP_TOKEN_NOT_FOUND_IN_THE_CURRENT_ACCOUNT, + }, + }, nil).Once() + + // when + w := serveWithKey(fx, "GET", "/v2/auth/whoami", "revokedKey") + + // then + require.Equal(t, http.StatusUnauthorized, w.Code) + expectedJSON, err := json.Marshal(util.CodeToApiError(http.StatusUnauthorized, ErrInvalidApiKey.Error())) + require.NoError(t, err) + require.JSONEq(t, string(expectedJSON), w.Body.String()) + require.Equal(t, `Bearer realm="anytype", error="invalid_token"`, w.Header().Get("WWW-Authenticate")) + require.Empty(t, w.Header().Get(util.KeyStatusHeader), "no credential, no credential-status signal") + require.NotContains(t, w.Body.String(), "scoped") + }) +} + +func TestWhoamiAgreesWithTheGate(t *testing.T) { + // The anti-drift test: whoami is a MIRROR of the same grant record the + // gate enforces, and this test makes disagreement a failure. The + // expectations for the gate probes are derived ONLY from the whoami + // body — if the mirror ever comes from a second derivation path, the + // gate's answers stop matching it here. + for _, tc := range []struct { + name string + grant *util.ApiGrant + }{ + {"read-only grant", &util.ApiGrant{Spaces: []string{"spaceA"}, Perms: util.GrantPermsRead}}, + {"readwrite grant", &util.ApiGrant{Spaces: []string{"spaceA"}, Perms: util.GrantPermsReadWrite}}, + } { + t.Run(tc.name, func(t *testing.T) { + // given + fx := newV2ServerFixture(t) + registerGrantTestSpace(t, fx, "spaceA", "Work") + registerGrantTestSpace(t, fx, "spaceB", "Personal") + grantedSession(fx, "scopedKey", tc.grant) + fx.eventMock.On("Broadcast", mock.Anything).Return(nil).Maybe() + + // when: ask the mirror first + whoamiResp := serveWithKey(fx, "GET", "/v2/auth/whoami", "scopedKey") + require.Equal(t, http.StatusOK, whoamiResp.Code) + var mirror v2model.WhoamiResponse + require.NoError(t, json.Unmarshal(whoamiResp.Body.Bytes(), &mirror)) + require.True(t, mirror.Grant.Scoped) + require.NotNil(t, mirror.Grant.Permission) + + claimed := map[string]bool{} + for _, space := range mirror.Grant.Spaces { + claimed[space.Id] = true + } + + // then: the gate must agree, space by space, in both directions + for _, spaceId := range []string{"spaceA", "spaceB"} { + w := serveWithKey(fx, "GET", "/v2/spaces/"+spaceId, "scopedKey") + if claimed[spaceId] { + require.Equal(t, http.StatusOK, w.Code, + "whoami claims %s is granted — the gate must serve it", spaceId) + } else { + require.Equal(t, http.StatusForbidden, w.Code, + "whoami omits %s — the gate must refuse it", spaceId) + require.Contains(t, w.Body.String(), `"space_not_granted"`) + } + } + + // and the verb: a write probe on a granted space must be refused + // exactly when the mirror says the permission is read-only + write := serveWithKeyBody(fx, "POST", "/v2/spaces/spaceA/objects", "scopedKey", `{}`) + if *mirror.Grant.Permission == util.GrantPermsReadWrite { + require.NotEqual(t, http.StatusForbidden, write.Code, + "whoami claims readwrite — the gate must not refuse the write") + } else { + require.Equal(t, http.StatusForbidden, write.Code, + "whoami claims read-only — the gate must refuse the write") + require.Contains(t, write.Body.String(), `"write_not_granted"`) + } + }) + } +} + +func TestLegacyKeySignals(t *testing.T) { + t.Run("the signal rides /v1 responses too", func(t *testing.T) { + // given: legacy keys live on /v1 — the signal must reach them there, + // not only on the /v2 surface they never call + fx := newFixture(t) + fx.KeyToToken = map[string]ApiSessionEntry{ + "legacyKey": {Token: "tok", AppName: "legacy", Scope: model.AccountAuth_JsonAPI, KeyId: "hash3"}, + } + fx.mwMock.On("ObjectSearch", mock.Anything, mock.Anything). + Return(&pb.RpcObjectSearchResponse{ + Error: &pb.RpcObjectSearchResponseError{Code: pb.RpcObjectSearchResponseError_NULL}, + }, nil).Once() + fx.eventMock.On("Broadcast", mock.Anything).Return(nil).Maybe() + + // when + w := serveWithKey(fx, "GET", "/v1/spaces", "legacyKey") + + // then + require.Equal(t, http.StatusOK, w.Code) + assert.Equal(t, util.KeyStatusLegacy, w.Header().Get(util.KeyStatusHeader)) + assert.Equal(t, util.LegacyKeyNotice, w.Header().Get(util.NoticeHeader)) + assert.Equal(t, []string{util.KeyDeprecationLink}, w.Header().Values("Link")) + assertNoRfc9745Headers(t, w) + }) + + t.Run("a Limited key on /v1 gets the status but never the remedial signal", func(t *testing.T) { + // given: a Limited (clipper) key is nil-grant forever — a grant is + // only ever valid on JsonAPI scope — so the re-issue advice cannot be + // followed and must not be given, and the usage log must not count it + // (the metric exists to measure legacy JSON-API keys before a sunset) + fx := newFixture(t) + fx.KeyToToken = map[string]ApiSessionEntry{ + "clipperKey": {Token: "tok", AppName: "clipper", Scope: model.AccountAuth_Limited, KeyId: "hashLimited"}, + } + fx.mwMock.On("ObjectSearch", mock.Anything, mock.Anything). + Return(&pb.RpcObjectSearchResponse{ + Error: &pb.RpcObjectSearchResponseError{Code: pb.RpcObjectSearchResponseError_NULL}, + }, nil).Once() + fx.eventMock.On("Broadcast", mock.Anything).Return(nil).Maybe() + + // when + w := serveWithKey(fx, "GET", "/v1/spaces", "clipperKey") + + // then + require.Equal(t, http.StatusOK, w.Code) + assert.Equal(t, util.KeyStatusLegacy, w.Header().Get(util.KeyStatusHeader), + "the status header stays unconditional — absence must never mean anything") + assert.Empty(t, w.Header().Get(util.NoticeHeader), "the notice is JsonAPI-only") + assert.Empty(t, w.Header().Values("Link"), "the deprecation link is JsonAPI-only") + assert.NotContains(t, fx.legacyKeyLogSeen, "hashLimited", + "a non-JSON-API key must not arm the legacy-usage log limiter") + }) + + t.Run("the notice is one printable single-line ASCII sentence", func(t *testing.T) { + // npm's pattern: a client may print it verbatim, so it must never + // need escaping and never interpolate user data + require.NotContains(t, util.LegacyKeyNotice, "\n") + require.NotContains(t, util.LegacyKeyNotice, "%") + for _, r := range util.LegacyKeyNotice { + require.True(t, r >= 0x20 && r < 0x7f, "non-printable-ASCII rune %q", r) + } + }) + + t.Run("the legacy-usage log line is rate-limited per key", func(t *testing.T) { + // given + fx := newFixture(t) + now := time.Now() + + // then: once per key per process start… + require.True(t, fx.shouldLogLegacyKeyUse("hashA", now)) + require.False(t, fx.shouldLogLegacyKeyUse("hashA", now)) + require.False(t, fx.shouldLogLegacyKeyUse("hashA", now.Add(30*time.Minute))) + // …re-armed hourly… + require.True(t, fx.shouldLogLegacyKeyUse("hashA", now.Add(61*time.Minute))) + // …and per KEY, so two legacy keys each get their line + require.True(t, fx.shouldLogLegacyKeyUse("hashB", now)) + }) +} diff --git a/core/api/service.go b/core/api/service.go index 2a045b5afb..022dfc0277 100644 --- a/core/api/service.go +++ b/core/api/service.go @@ -15,11 +15,15 @@ import ( "github.com/anyproto/anytype-heart/core/anytype/config" apicore "github.com/anyproto/anytype-heart/core/api/core" "github.com/anyproto/anytype-heart/core/api/server" + "github.com/anyproto/anytype-heart/core/block/cache" "github.com/anyproto/anytype-heart/core/block/chats/chatsubscription" + "github.com/anyproto/anytype-heart/core/block/object/objectcreator" "github.com/anyproto/anytype-heart/core/event" "github.com/anyproto/anytype-heart/core/files/fileobject" "github.com/anyproto/anytype-heart/core/subscription/crossspacesub" + "github.com/anyproto/anytype-heart/pkg/lib/localstore/objectstore" "github.com/anyproto/anytype-heart/pkg/lib/logging" + "github.com/anyproto/anytype-heart/space" ) const ( @@ -33,11 +37,21 @@ var ( mwSrv apicore.ClientCommands - //go:embed docs/openapi.yaml - openapiYAML []byte + // The generated documents, one per API version. They are data only: + // `make openapi` writes just openapi.{json,yaml} (--outputTypes json,yaml), + // no docs.go — nothing in this binary ever read swag's global registry, and + // the bytes below are what the /docs routes actually serve. + //go:embed docs/v1/openapi.yaml + openapiV1YAML []byte - //go:embed docs/openapi.json - openapiJSON []byte + //go:embed docs/v1/openapi.json + openapiV1JSON []byte + + //go:embed docs/v2/openapi.yaml + openapiV2YAML []byte + + //go:embed docs/v2/openapi.json + openapiV2JSON []byte ) type Service interface { @@ -53,6 +67,11 @@ type apiService struct { crossSpaceSubService apicore.CrossSpaceSubscriptionService chatSubService apicore.ChatSubscriptionService fileObjectService apicore.FileObjectService + objectReader apicore.ObjectReader + objectCreator apicore.ObjectCreator + objectMutator apicore.ObjectMutator + objectProvenance apicore.ObjectProvenance + objectStore objectstore.ObjectStore listenAddr string @@ -90,8 +109,23 @@ func (s *apiService) Init(a *app.App) error { s.accountService = a.MustComponent(account.CName).(account.Service) s.eventService = a.MustComponent(event.CName).(apicore.EventService) s.crossSpaceSubService = a.MustComponent(crossspacesub.CName).(apicore.CrossSpaceSubscriptionService) + // The adapters below (chatSubAdapter, objectRead/Create/Mutate) stay in + // package api on purpose, even though the object adapters serve /v2 only: + // package api is this tree's composition root — the only package that + // touches *app.App and the heart-internal services (block/cache, + // objectcreator, space, chatsubscription, fileobject). What they produce + // are implementations of the apicore ports, and apicore is shared by both + // API versions, so an adapter is a shared-side artifact by construction. + // Keeping them here is also what keeps core/api/v2 free of heart-internal + // imports: v2 is HTTP plus logic over ports, which is what makes it + // testable against mock_apicore. See core/api/APIV2_LAYOUT_PLAN.md §9.2. s.chatSubService = &chatSubAdapter{svc: a.MustComponent(chatsubscription.CName).(chatsubscription.Service)} s.fileObjectService = a.MustComponent(fileobject.CName).(apicore.FileObjectService) + s.objectReader = newObjectReadAdapter(app.MustComponent[cache.ObjectGetterComponent](a)) + s.objectCreator = newObjectCreateAdapter(app.MustComponent[objectcreator.Service](a), app.MustComponent[space.Service](a)) + s.objectMutator = newObjectMutateAdapter(app.MustComponent[cache.ObjectGetterComponent](a)) + s.objectProvenance = newObjectProvenanceAdapter(app.MustComponent[space.Service](a), a.MustComponent(account.CName).(account.Service)) + s.objectStore = app.MustComponent[objectstore.ObjectStore](a) return nil } @@ -102,6 +136,24 @@ func (s *apiService) Run(ctx context.Context) error { return nil } +// The accountId probe below is structural — if account.Service ever renamed +// AccountID, the probe would still compile, silently return "" and degrade +// every current-user placeholder to a warning. This assertion turns that +// rename into a compile error instead. +var _ interface{ AccountID() string } = (account.Service)(nil) + +// accountId returns the caller's account identity for API v2's stored-view +// placeholder substitution (`_filter_template_2_` → participant id). The +// apicore.AccountService port only exposes GetInfo, so the richer concrete +// account component is probed for its AccountID; a foreign implementation +// degrades to "" (the placeholder then warns instead of resolving). +func (s *apiService) accountId() string { + if withId, ok := s.accountService.(interface{ AccountID() string }); ok { + return withId.AccountID() + } + return "" +} + func (s *apiService) Close(ctx context.Context) error { if s.srv != nil { s.srv.Stop() @@ -126,9 +178,14 @@ func (s *apiService) startServer() error { s.crossSpaceSubService, s.chatSubService, s.fileObjectService, + server.V2Deps{Reader: s.objectReader, Creator: s.objectCreator, Mutator: s.objectMutator, Provenance: s.objectProvenance, Store: s.objectStore, AccountId: s.accountId()}, s.listenAddr, - openapiYAML, - openapiJSON, + server.OpenApiDocs{ + V1YAML: openapiV1YAML, + V1JSON: openapiV1JSON, + V2YAML: openapiV2YAML, + V2JSON: openapiV2JSON, + }, ) s.httpSrv = &http.Server{ diff --git a/core/api/service/cache.go b/core/api/service/cache.go index d6f7c097f5..653dde6ab2 100644 --- a/core/api/service/cache.go +++ b/core/api/service/cache.go @@ -37,15 +37,18 @@ func (s *Service) InitializeAllCaches() error { return nil } -// subscribeToCrossSpaceProperties subscribes to property changes across all active spaces -func (s *Service) subscribeToCrossSpaceProperties() error { - if s.subscriptions.properties.queue != nil { - return nil // Already subscribed - } - - s.subscriptions.properties.queue = mb.New[*pb.EventMessage](0) - - filters := []database.FilterRequest{ +// crossSpacePropertyFilters bounds v1's property cache — which IS v1's key +// namespace: it answers ResolveProperty, it backs GET /properties, and it is +// what a same-key create is checked against. +// +// isUninstalled — the UI-delete flag — used to be missing here while v2 +// excludes it everywhere (§7.5-requirement-2). One slug then got opposite +// verdicts from the two versions: v2 had vacated it and would mint onto it, +// v1 still listed the corpse, still resolved it as an address and still +// refused a same-key create against it. The namespace a key lives in cannot +// depend on which version asks. +func crossSpacePropertyFilters() []database.FilterRequest { + return []database.FilterRequest{ { RelationKey: bundle.RelationKeyResolvedLayout, Condition: model.BlockContentDataviewFilter_Equal, @@ -56,11 +59,25 @@ func (s *Service) subscribeToCrossSpaceProperties() error { Condition: model.BlockContentDataviewFilter_NotEqual, Value: domain.Bool(true), }, + { + RelationKey: bundle.RelationKeyIsUninstalled, + Condition: model.BlockContentDataviewFilter_NotEqual, + Value: domain.Bool(true), + }, } +} + +// subscribeToCrossSpaceProperties subscribes to property changes across all active spaces +func (s *Service) subscribeToCrossSpaceProperties() error { + if s.subscriptions.properties.queue != nil { + return nil // Already subscribed + } + + s.subscriptions.properties.queue = mb.New[*pb.EventMessage](0) resp, err := s.crossSpaceSubService.Subscribe(subscription.SubscribeRequest{ SubId: "api.properties.crossspace", - Filters: filters, + Filters: crossSpacePropertyFilters(), Keys: []string{ bundle.RelationKeyId.String(), bundle.RelationKeyRelationKey.String(), diff --git a/core/api/service/cache_manager.go b/core/api/service/cache_manager.go index 00a10e2a23..92a2716390 100644 --- a/core/api/service/cache_manager.go +++ b/core/api/service/cache_manager.go @@ -4,6 +4,7 @@ import ( "sync" apimodel "github.com/anyproto/anytype-heart/core/api/model" + "github.com/anyproto/anytype-heart/core/api/util" ) // participantEntry caches the participant fields needed to enrich chat message @@ -83,6 +84,21 @@ func (c *cacheManager) cacheType(spaceId string, t *apimodel.Type) { c.types[spaceId][t.Id] = t c.types[spaceId][t.UniqueKey] = t + // the key DERIVED from the unique key, always — not only when it happens + // to equal t.Key. t.Key is the apiObjectKey slug when one is stored, and + // for a BSON-keyed custom type ("ot-") the bare "" is then + // present in NO other slot: uniqueKey keeps its "ot-" prefix and the id is + // the object id. Every v1 address the surface has ever served for such a + // type is that hex — a create's typeKey, a search's `types`, the `key` of + // every object row it ever returned — so the moment the apiObjectKey + // backfill stamps a slug, ResolveTypeApiKey stops answering for it and + // create/update 400/500 while search silently drops the type from its + // filter. Indexing the derived key alongside the slug keeps both spellings + // live; properties already get this for free from the RelationKey slot. + // Written BEFORE t.Key so an explicit slug still wins the slot on a clash. + if derived := util.ToTypeApiKey(t.UniqueKey); derived != "" { + c.types[spaceId][derived] = t + } c.types[spaceId][t.Key] = t } @@ -150,6 +166,7 @@ func (c *cacheManager) removeType(spaceId, id, uniqueKey, key string) { if spaceCache, exists := c.types[spaceId]; exists { delete(spaceCache, id) delete(spaceCache, uniqueKey) + delete(spaceCache, util.ToTypeApiKey(uniqueKey)) // the slot cacheType adds delete(spaceCache, key) } } diff --git a/core/api/service/cache_manager_test.go b/core/api/service/cache_manager_test.go new file mode 100644 index 0000000000..26d4d370e8 --- /dev/null +++ b/core/api/service/cache_manager_test.go @@ -0,0 +1,174 @@ +package service + +import ( + "errors" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + + apimodel "github.com/anyproto/anytype-heart/core/api/model" + "github.com/anyproto/anytype-heart/core/domain" + "github.com/anyproto/anytype-heart/core/subscription" + "github.com/anyproto/anytype-heart/core/subscription/crossspacesub" + "github.com/anyproto/anytype-heart/pkg/lib/bundle" + "github.com/anyproto/anytype-heart/pkg/lib/database" + "github.com/anyproto/anytype-heart/pkg/lib/pb/model" +) + +// bsonTypeKey is a minted BSON type key — the shape getUniqueKeyOrGenerate +// gives EVERY type not created with an explicit unique key, i.e. every custom +// type in every existing account. A fixture over `page` or another readable +// key cannot fail this test and proves nothing. +const bsonTypeKey = "67b0d3e3cda913b84c1299b1" + +// preBackfillType is how the cache saw a BSON-keyed custom type before the +// apiObjectKey backfill: no stored slug, so getTypeFromStruct's fallback +// (util.ToTypeApiKey) makes the bare hex the served key. +func preBackfillType() *apimodel.Type { + return &apimodel.Type{ + Object: "type", + Id: "type-id-1", + Key: bsonTypeKey, + Name: "Invoice", + UniqueKey: "ot-" + bsonTypeKey, + } +} + +// postBackfillType is the SAME type after the migration stamped a slug: +// getTypeFromStruct now prefers apiObjectKey, so Key becomes the slug and the +// hex is gone from that slot. +func postBackfillType() *apimodel.Type { + t := preBackfillType() + t.Key = "invoice" + return t +} + +func TestCacheType_BsonKeyStaysAddressableAfterTheApiObjectKeyBackfill(t *testing.T) { + t.Run("the derived key resolves in a cache built from post-backfill details", func(t *testing.T) { + // given — a fresh process (the restart-latent case: cacheType adds + // without evicting, so a live process keeps the pre-backfill slot and + // the break only appears on the next heart restart) + fx := newFixture(t) + fx.service.cache.cacheType(mockedSpaceId, postBackfillType()) + + // when / then — both spellings address the type + assert.Equal(t, "ot-"+bsonTypeKey, fx.service.ResolveTypeApiKey(mockedSpaceId, bsonTypeKey), + "the hex is the only key v1 ever served for this type before the backfill") + assert.Equal(t, "ot-"+bsonTypeKey, fx.service.ResolveTypeApiKey(mockedSpaceId, "invoice")) + }) + + t.Run("search does not silently drop the type from its filter", func(t *testing.T) { + // given + fx := newFixture(t) + fx.service.cache.cacheType(mockedSpaceId, postBackfillType()) + + // when — prepareTypeFilters DROPS an unresolvable key, so a 200 with + // an empty data array is what an integration polling "all my + // Invoices" would see + filters, _ := fx.service.prepareTypeFilters([]string{bsonTypeKey}, mockedSpaceId) + + // then + require.Len(t, filters, 1) + require.Len(t, filters[0].NestedFilters, 1) + assert.Equal(t, "type-id-1", filters[0].NestedFilters[0].Value.GetStringValue()) + }) + + t.Run("removeType clears the derived slot too", func(t *testing.T) { + // given + fx := newFixture(t) + typ := postBackfillType() + fx.service.cache.cacheType(mockedSpaceId, typ) + + // when + fx.service.cache.removeType(mockedSpaceId, typ.Id, typ.UniqueKey, typ.Key) + + // then — a deleted type must not keep answering under any spelling + assert.Empty(t, fx.service.ResolveTypeApiKey(mockedSpaceId, bsonTypeKey)) + assert.Empty(t, fx.service.ResolveTypeApiKey(mockedSpaceId, "invoice")) + assert.Empty(t, fx.service.cache.getTypes(mockedSpaceId)) + }) + + t.Run("an explicit slug still wins a slot it shares with a derived key", func(t *testing.T) { + // given — a readable-uniqueKey type whose derived key is "invoice", + // and a second type that stored "invoice" as its apiObjectKey. The + // derived write must not clobber the slug holder. + fx := newFixture(t) + derivedHolder := &apimodel.Type{Object: "type", Id: "type-id-2", Key: "other", Name: "Other", UniqueKey: "ot-invoice"} + slugHolder := postBackfillType() + fx.service.cache.cacheType(mockedSpaceId, derivedHolder) + fx.service.cache.cacheType(mockedSpaceId, slugHolder) + + // then + assert.Equal(t, "ot-"+bsonTypeKey, fx.service.ResolveTypeApiKey(mockedSpaceId, "invoice")) + assert.Equal(t, "ot-invoice", fx.service.ResolveTypeApiKey(mockedSpaceId, "other")) + }) +} + +// TestCrossSpacePropertyFiltersVacateCorpses. v1's property cache IS v1's key +// namespace, and it filtered isHidden but not isUninstalled — so a UI-deleted +// property still listed, still resolved as an address and still blocked a +// same-key create in v1, while v2 had already vacated that slug and would +// happily mint onto it. Two versions, one slug, opposite verdicts. +func TestCrossSpacePropertyFiltersVacateCorpses(t *testing.T) { + byKey := map[domain.RelationKey]database.FilterRequest{} + for _, f := range crossSpacePropertyFilters() { + byKey[f.RelationKey] = f + } + + require.Contains(t, byKey, bundle.RelationKeyIsUninstalled, + "the UI-delete flag must exclude a corpse from v1's namespace, as it does from v2's") + assert.Equal(t, model.BlockContentDataviewFilter_NotEqual, byKey[bundle.RelationKeyIsUninstalled].Condition) + assert.Equal(t, domain.Bool(true), byKey[bundle.RelationKeyIsUninstalled].Value) + assert.Contains(t, byKey, bundle.RelationKeyIsHidden, "and the pre-existing hidden filter stays") +} + +// TestCrossSpaceTypeAndTagFiltersLackTheCorpseFilter — DOCUMENTS A GAP in +// the v1 corpse policy's coverage: only the PROPERTY subscription carries the +// isUninstalled filter (the test above); the TYPE and TAG subscriptions +// filter isHidden only. Executed against the real subscription requests via +// a capturing mock, so the asymmetry is pinned where it lives, not inferred. +// +// Today this is masked in production by the store's injected +// `isDeleted != true` default: a real UI delete persists BOTH isUninstalled +// and isDeleted (delete.go + smartblock/detailsinject.go), so prod corpses +// never reach any of the three subscriptions. The isUninstalled filter is +// the belt-and-braces layer, and it exists on one namespace out of three. +// Extending it to types and tags must flip the False assertions below — +// this test marks the asymmetry so that either resolution is a conscious +// change, not drift. +func TestCrossSpaceTypeAndTagFiltersLackTheCorpseFilter(t *testing.T) { + // given — capture every cross-space subscription request + fx := newFixture(t) + filtersBySub := map[string]map[domain.RelationKey]database.FilterRequest{} + fx.crossSpaceSubService.EXPECT().Subscribe(mock.Anything, mock.Anything).RunAndReturn( + func(req subscription.SubscribeRequest, _ crossspacesub.Predicate) (*subscription.SubscribeResponse, error) { + byKey := map[domain.RelationKey]database.FilterRequest{} + for _, f := range req.Filters { + byKey[f.RelationKey] = f + } + filtersBySub[req.SubId] = byKey + return nil, errors.New("stop after capturing the request") + }) + + // when — each subscription builds its real request (the error return + // stops each one before any queue machinery starts) + _ = fx.service.subscribeToCrossSpaceProperties() + _ = fx.service.subscribeToCrossSpaceTypes() + _ = fx.service.subscribeToCrossSpaceTags() + + // then — properties carry the corpse filter… + require.Contains(t, filtersBySub, "api.properties.crossspace") + assert.Contains(t, filtersBySub["api.properties.crossspace"], bundle.RelationKeyIsUninstalled) + + // …types and tags do NOT (the gap: a flag-only corpse would list in v1's + // type and tag namespaces; only the injected isDeleted default hides the + // prod double-flag shape) + require.Contains(t, filtersBySub, "api.types.crossspace") + assert.NotContains(t, filtersBySub["api.types.crossspace"], bundle.RelationKeyIsUninstalled, + "pinned asymmetry — adding the filter to types must update this test") + require.Contains(t, filtersBySub, "api.tags.crossspace") + assert.NotContains(t, filtersBySub["api.tags.crossspace"], bundle.RelationKeyIsUninstalled, + "pinned asymmetry — adding the filter to tags must update this test") +} diff --git a/core/api/service/property.go b/core/api/service/property.go index 5f7c801f00..ae2d45252e 100644 --- a/core/api/service/property.go +++ b/core/api/service/property.go @@ -278,6 +278,9 @@ func (s *Service) UpdateProperty(ctx context.Context, spaceId string, propertyId if bundle.HasRelation(domain.RelationKey(prop.RelationKey)) { return nil, util.ErrBadInput("property key of bundled properties cannot be changed") } + if shadowsBundledRelationKey(apiKey, prop.RelationKey) { + return nil, util.ErrBadInput(fmt.Sprintf("property key %q is reserved by a bundled property", apiKey)) + } detailsToUpdate = append(detailsToUpdate, &model.Detail{ Key: bundle.RelationKeyApiObjectKey.String(), Value: pbtypes.String(apiKey), @@ -321,6 +324,35 @@ func (s *Service) sanitizedString(str string) string { return strings.TrimSpace(str) } +// shadowsBundledRelationKey / shadowsBundledTypeKey are the BUNDLED arm of the +// §7.5a-6 union check, applied to v1's rename channel. +// +// The mint (objectcreator/apikey.go) runs the full union — live stored slugs, +// live stored keys, and the bundled derived table — but a RENAME never enters +// objectcreator: it stamps `apiObjectKey` straight through ObjectSetDetails. +// Its only guard was v1's per-space cache, which has no row for a bundled +// relation that is not INSTALLED in the space, so renaming a custom property's +// key to `dueDate` minted a fresh `due_date` shadow — the exact failure the +// mint hardening exists to make unreachable, through a door beside it. +// +// The predicate is the same one apiKeyNamespace.taken applies, both arms: the +// bundled table's derived slug, and a spelling that IS a bundled internal key. +// A caller renaming to the slug its own bundled key derives is not shadowing +// anything (bundled properties are refused a rename anyway, one check up). +func shadowsBundledRelationKey(apiKey, ownStoredKey string) bool { + if key, ok := bundle.RelationKeyByApiSlug(apiKey); ok && string(key) != ownStoredKey { + return true + } + return apiKey != ownStoredKey && bundle.HasRelation(domain.RelationKey(apiKey)) +} + +func shadowsBundledTypeKey(apiKey, ownStoredKey string) bool { + if key, ok := bundle.TypeKeyByApiSlug(apiKey); ok && string(key) != ownStoredKey { + return true + } + return apiKey != ownStoredKey && bundle.HasObjectTypeByKey(domain.TypeKey(apiKey)) +} + // createTagsForProperty creates tags for a newly created property func (s *Service) createTagsForProperty(ctx context.Context, spaceId string, propertyId string, tagsToCreate []apimodel.CreateTagRequest) error { for _, tagRequest := range tagsToCreate { diff --git a/core/api/service/property_test.go b/core/api/service/property_test.go index 3fb7be00b6..56d3c03400 100644 --- a/core/api/service/property_test.go +++ b/core/api/service/property_test.go @@ -991,3 +991,91 @@ func TestSanitizeAndValidatePropertyValueTypeObject(t *testing.T) { assert.Contains(t, err.Error(), "invalid object reference") }) } + +// TestApiKeyRenameDoesNotMintAShadow covers the §7.5a-6 hole v1's rename +// channel left open: PATCH /v1/…/properties/{id} and /types/{id} stamp +// `apiObjectKey` through ObjectSetDetails without ever entering objectcreator, +// so they bypass ensureUniqueApiObjectKey's union check. Their only guard was +// the per-space cache, which has NO ROW for a bundled relation that is not +// installed in the space — so renaming a custom key to `dueDate` minted a +// fresh `due_date` shadow, the exact failure the mint hardening exists to make +// unreachable. +// +// Revert the shadowsBundled*Key calls in property.go / type.go and the two +// end-to-end subtests below accept the rename again. +func TestApiKeyRenameDoesNotMintAShadow(t *testing.T) { + t.Run("the predicate is the bundled arm of the union check", func(t *testing.T) { + // a bundled derived slug, held by someone else + assert.True(t, shadowsBundledRelationKey("due_date", "6a7663db61fab21cd4b9e101")) + // a spelling that IS a bundled internal key + assert.True(t, shadowsBundledRelationKey("dueDate", "6a7663db61fab21cd4b9e101")) + // its own derived slug is not a shadow of itself + assert.False(t, shadowsBundledRelationKey("due_date", "dueDate")) + // nothing bundled answers to it + assert.False(t, shadowsBundledRelationKey("manual_property", "6a7663db61fab21cd4b9e101")) + + assert.True(t, shadowsBundledTypeKey("object_type", "6a7663db61fab21cd4b9e103")) + assert.False(t, shadowsBundledTypeKey("object_type", "objectType")) + assert.False(t, shadowsBundledTypeKey("meeting_note", "6a7663db61fab21cd4b9e103")) + }) + + t.Run("renaming a property key onto a bundled slug is refused", func(t *testing.T) { + // given: a BSON-keyed custom property, and the bundled dueDate NOT + // installed — the cache row that would flag `due_date` does not exist + fx := newFixture(t) + // deliberately NOT populateCache: the shipped fixture caches a due_date + // row, which is the one thing that would have caught this + fx.mwMock.On("ObjectShow", mock.Anything, &pb.RpcObjectShowRequest{ + SpaceId: mockedSpaceId, ObjectId: "rel-custom", + }).Return(&pb.RpcObjectShowResponse{ + Error: &pb.RpcObjectShowResponseError{Code: pb.RpcObjectShowResponseError_NULL}, + ObjectView: &model.ObjectView{Details: []*model.ObjectViewDetailsSet{{Details: &types.Struct{Fields: map[string]*types.Value{ + bundle.RelationKeyId.String(): pbtypes.String("rel-custom"), + bundle.RelationKeyRelationKey.String(): pbtypes.String("6a7663db61fab21cd4b9e101"), + bundle.RelationKeyApiObjectKey.String(): pbtypes.String("manual_property"), + bundle.RelationKeyName.String(): pbtypes.String("Manual property"), + bundle.RelationKeyRelationFormat.String(): pbtypes.Int64(int64(model.RelationFormat_longtext)), + }}}}}, + }).Maybe() + newKey := "dueDate" // strcase.ToSnake makes this due_date + + // when + _, err := fx.service.UpdateProperty(context.Background(), mockedSpaceId, "rel-custom", + apimodel.UpdatePropertyRequest{Key: &newKey}) + + // then + require.Error(t, err) + assert.Contains(t, err.Error(), "reserved by a bundled property") + }) + + t.Run("renaming a type key onto a bundled slug is refused", func(t *testing.T) { + // given: a custom type, and `objectType` NOT installed in this space — + // so the cache holds no row that could reveal the clash + fx := newFixture(t) + fx.populateCache(mockedSpaceId) + custom := &apimodel.Type{Id: "type-custom", Key: "meeting_note", UniqueKey: "ot-6a7663db61fab21cd4b9e103"} + newKey := "object_type" + + // when + _, err := fx.service.buildUpdatedTypeDetails(context.Background(), mockedSpaceId, custom, + apimodel.UpdateTypeRequest{Key: &newKey}) + + // then + require.Error(t, err) + assert.Contains(t, err.Error(), "reserved by a bundled type") + }) + + t.Run("an unrelated rename still works", func(t *testing.T) { + fx := newFixture(t) + fx.populateCache(mockedSpaceId) + custom := &apimodel.Type{Id: "type-custom", Key: "meeting_note", UniqueKey: "ot-6a7663db61fab21cd4b9e103"} + newKey := "meeting_minutes" + + details, err := fx.service.buildUpdatedTypeDetails(context.Background(), mockedSpaceId, custom, + apimodel.UpdateTypeRequest{Key: &newKey}) + + require.NoError(t, err) + assert.Equal(t, "meeting_minutes", + pbtypes.GetString(details, bundle.RelationKeyApiObjectKey.String())) + }) +} diff --git a/core/api/service/retryreadseeker_test.go b/core/api/service/retryreadseeker_test.go index 922a12d75b..b793aca32e 100644 --- a/core/api/service/retryreadseeker_test.go +++ b/core/api/service/retryreadseeker_test.go @@ -15,12 +15,12 @@ import ( // flakyReader fails the first `failuresBeforeSuccess` Read calls with the // supplied error, then succeeds and delegates to delegate. type flakyReader struct { - delegate io.ReadSeeker - failureErr error - readsBeforeSuccess int - seeksBeforeSuccess int - readsObserved int - seeksObserved int + delegate io.ReadSeeker + failureErr error + readsBeforeSuccess int + seeksBeforeSuccess int + readsObserved int + seeksObserved int } func (f *flakyReader) Read(p []byte) (int, error) { diff --git a/core/api/service/type.go b/core/api/service/type.go index ff3e33954e..1dd3716602 100644 --- a/core/api/service/type.go +++ b/core/api/service/type.go @@ -294,6 +294,9 @@ func (s *Service) buildUpdatedTypeDetails(ctx context.Context, spaceId string, t if bundle.HasObjectTypeByKey(domain.TypeKey(util.ToTypeApiKey(t.UniqueKey))) { return nil, util.ErrBadInput("type key of bundled types cannot be changed") } + if shadowsBundledTypeKey(apiKey, util.ToTypeApiKey(t.UniqueKey)) { + return nil, util.ErrBadInput(fmt.Sprintf("type key %q is reserved by a bundled type", apiKey)) + } fields[bundle.RelationKeyApiObjectKey.String()] = pbtypes.String(apiKey) } } diff --git a/core/api/util/analytics.go b/core/api/util/analytics.go index f3eb48077d..f10cd849de 100644 --- a/core/api/util/analytics.go +++ b/core/api/util/analytics.go @@ -42,10 +42,24 @@ func NewAnalyticsEvent(code, route, apiAppName string, status int) *AnalyticsBro } } +// apiAppNameCtxKey is the private carrier type for the authenticated key's +// app name; a typed key cannot collide with other context values. +type apiAppNameCtxKey struct{} + +// CtxWithApiAppName stores the authenticated key's app name on the context. +// The auth middleware calls it on the REQUEST context (not only the gin +// context): NewAnalyticsEventForApi only ever sees a context.Context, and +// gin's c.Set values are not reachable through c.Request.Context(). +func CtxWithApiAppName(ctx context.Context, appName string) context.Context { + return context.WithValue(ctx, apiAppNameCtxKey{}, appName) +} + // NewAnalyticsEventForApi creates a new analytics event for api with the app name from the context func NewAnalyticsEventForApi(ctx context.Context, code string, status int) (string, error) { - // TODO: enable when apiAppName is available in context - // apiAppName := ctx.Value("apiAppName").(string) - apiAppName := "api-app" + apiAppName, ok := ctx.Value(apiAppNameCtxKey{}).(string) + if !ok || apiAppName == "" { + // unauthenticated routes (e.g. the auth flow itself) have no app name + apiAppName = "api-app" + } return NewAnalyticsEvent(code, "api", apiAppName, status).ToJSON() } diff --git a/core/api/util/constant.go b/core/api/util/constant.go index b9bede931c..3ca760d6df 100644 --- a/core/api/util/constant.go +++ b/core/api/util/constant.go @@ -36,6 +36,14 @@ func IsFileTypeUniqueKey(uniqueKey string) bool { return ok } +// IsFileTypeKey reports whether the given bare type key (e.g. "image") +// belongs to a file-layout type — the v2 query surface's file-layout opt-in +// trigger (naming a file type in the type channel widens the row scope to +// ObjectAndFileLayouts). +func IsFileTypeKey(key string) bool { + return IsFileTypeUniqueKey(domain.TypeKey(key).URL()) +} + var MemberLayouts = []model.ObjectTypeLayout{ model.ObjectType_participant, } diff --git a/core/api/util/grant.go b/core/api/util/grant.go new file mode 100644 index 0000000000..15748f267c --- /dev/null +++ b/core/api/util/grant.go @@ -0,0 +1,140 @@ +package util + +// grant.go carries an app key's space grant through the HTTP layer. The +// grant record itself lives in the wallet (core/wallet.AppLinkGrant, sealed +// into the app-link file); WalletCreateSession surfaces it as a proto +// message, and this package holds the plain form both route groups and the +// v2 service read — the request context.Context is the carrier, like the +// app name above. + +import ( + "context" + "fmt" + "strings" + + "github.com/anyproto/anytype-heart/pkg/lib/pb/model" +) + +// Grant permission levels. The vocabulary matches the wallet's persisted +// perms values (core/wallet: AppLinkPermsRead / AppLinkPermsReadWrite). +const ( + GrantPermsRead = "read" + GrantPermsReadWrite = "readwrite" +) + +// ApiGrant is the HTTP layer's view of an app key's space grant. A nil +// *ApiGrant means an unscoped/legacy key — enforcement passes it through +// unchanged. A non-nil grant constrains every request to Spaces × Perms. +type ApiGrant struct { + Spaces []string `json:"spaces"` + Perms string `json:"perms"` // GrantPermsRead | GrantPermsReadWrite +} + +// ApiGrantFromProto converts the WalletCreateSession grant. An unrecognized +// permission value maps to read, never to readwrite: an enum this binary +// does not know must not widen into write access. +func ApiGrantFromProto(grant *model.AccountAuthAppGrant) *ApiGrant { + if grant == nil { + return nil + } + perms := GrantPermsRead + if grant.Perm == model.AccountAuthAppGrant_ReadWrite { + perms = GrantPermsReadWrite + } + return &ApiGrant{ + Spaces: append([]string(nil), grant.SpaceIds...), + Perms: perms, + } +} + +// AllowsSpace reports whether the grant covers spaceId. Nil means +// unscoped/legacy and callers branch on that BEFORE calling — a nil +// receiver here answers false, so a caller that forgets the branch fails +// closed instead of open. An EMPTY Spaces list also denies every space: +// empty must be impossible (persist-time validation rejects it) and, if +// ever encountered, must NEVER be read as "all spaces" — the loop's +// vacuous false is load-bearing. +func (g *ApiGrant) AllowsSpace(spaceId string) bool { + if g == nil || spaceId == "" { + return false + } + for _, granted := range g.Spaces { + if granted == spaceId { + return true + } + } + return false +} + +// CanWrite reports whether the grant permits write-classified routes. Only +// the exact readwrite value passes — an empty or unknown Perms is read at +// most (fail closed). +func (g *ApiGrant) CanWrite() bool { + return g != nil && g.Perms == GrantPermsReadWrite +} + +// Describe renders the grant for 403 messages: error-guided self-correction +// is the v2 design language, and enumeration resistance is a non-goal on a +// localhost single-user API, so the message names the actual grant. +func (g *ApiGrant) Describe() string { + if g == nil { + return "unscoped" + } + return fmt.Sprintf("spaces [%s] with %s access", strings.Join(g.Spaces, ", "), g.Perms) +} + +// apiGrantCtxKey is the private carrier type for the authenticated key's +// grant on the request context. +type apiGrantCtxKey struct{} + +// CtxWithApiGrant stores the authenticated key's grant on the context. The +// gin context carries the full session entry for route middleware; the +// request context is what the v2 service layer reads (the fan-out +// constraint and the ensureSpace backstop), so the grant must ride both. +func CtxWithApiGrant(ctx context.Context, grant *ApiGrant) context.Context { + return context.WithValue(ctx, apiGrantCtxKey{}, grant) +} + +// ApiGrantFromCtx returns the request's grant, nil when the key is +// unscoped/legacy (or the request never passed ensureAuthenticated). +func ApiGrantFromCtx(ctx context.Context) *ApiGrant { + grant, _ := ctx.Value(apiGrantCtxKey{}).(*ApiGrant) + return grant +} + +// +// ---- WWW-Authenticate (RFC 6750) ---- +// + +// WwwAuthenticateHeader is emitted alongside the JSON error envelope on +// auth failures; MCP clients are required to parse it (spec rev 2025-06-18). +const WwwAuthenticateHeader = "WWW-Authenticate" + +// BearerChallenge is the 401 challenge when the request carried no +// credentials at all (RFC 6750 §3: no error code then). +func BearerChallenge() string { + return `Bearer realm="anytype"` +} + +// BearerChallengeInvalidToken is the 401 challenge for a present but +// unusable credential (malformed, unknown, revoked, expired). +func BearerChallengeInvalidToken() string { + return `Bearer realm="anytype", error="invalid_token"` +} + +// BearerChallengeInsufficientScope is the 403 challenge for an +// authenticated key whose scope or grant does not cover the request. scope +// may be empty when the request maps to no single space. +func BearerChallengeInsufficientScope(scope string) string { + if scope == "" { + return `Bearer error="insufficient_scope"` + } + return fmt.Sprintf(`Bearer error="insufficient_scope", scope=%q`, scope) +} + +// SpaceScope renders the implementation-defined RFC 6750 §3.1 scope string +// this API documents: `space::` — the space the +// request addressed and the permission it needed. +func SpaceScope(spaceId, perms string) string { + return fmt.Sprintf("space:%s:%s", spaceId, perms) +} diff --git a/core/api/util/grant_test.go b/core/api/util/grant_test.go new file mode 100644 index 0000000000..41ed0be9b1 --- /dev/null +++ b/core/api/util/grant_test.go @@ -0,0 +1,136 @@ +package util + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/anyproto/anytype-heart/pkg/lib/pb/model" +) + +func TestApiGrantFromProto(t *testing.T) { + tests := []struct { + name string + proto *model.AccountAuthAppGrant + want *ApiGrant + }{ + { + name: "nil proto is a nil grant (unscoped key)", + proto: nil, + want: nil, + }, + { + name: "read maps to read", + proto: &model.AccountAuthAppGrant{SpaceIds: []string{"space1"}, Perm: model.AccountAuthAppGrant_Read}, + want: &ApiGrant{Spaces: []string{"space1"}, Perms: GrantPermsRead}, + }, + { + name: "readwrite maps to readwrite", + proto: &model.AccountAuthAppGrant{SpaceIds: []string{"space1", "space2"}, Perm: model.AccountAuthAppGrant_ReadWrite}, + want: &ApiGrant{Spaces: []string{"space1", "space2"}, Perms: GrantPermsReadWrite}, + }, + { + name: "an unknown perm enum maps to read, never readwrite", + // a future enum value this binary does not know must not widen + // into write access + proto: &model.AccountAuthAppGrant{SpaceIds: []string{"space1"}, Perm: model.AccountAuthAppGrantPerm(99)}, + want: &ApiGrant{Spaces: []string{"space1"}, Perms: GrantPermsRead}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, ApiGrantFromProto(tt.proto)) + }) + } +} + +func TestApiGrantAllowsSpace(t *testing.T) { + tests := []struct { + name string + grant *ApiGrant + spaceId string + want bool + }{ + { + name: "granted space passes", + grant: &ApiGrant{Spaces: []string{"space1", "space2"}, Perms: GrantPermsRead}, + spaceId: "space2", + want: true, + }, + { + name: "non-granted space is denied", + grant: &ApiGrant{Spaces: []string{"space1"}, Perms: GrantPermsRead}, + spaceId: "space2", + want: false, + }, + { + name: "an EMPTY space list denies every space — it is never all spaces", + // persist-time validation rejects empty lists; if one is ever + // encountered anyway it must deny, not widen + grant: &ApiGrant{Spaces: []string{}, Perms: GrantPermsReadWrite}, + spaceId: "space1", + want: false, + }, + { + name: "a nil receiver denies (callers branch on nil BEFORE calling)", + // a caller that forgets the nil-means-unscoped branch fails + // closed, not open + grant: nil, + spaceId: "space1", + want: false, + }, + { + name: "the empty space id is denied", + grant: &ApiGrant{Spaces: []string{""}, Perms: GrantPermsRead}, + spaceId: "", + want: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, tt.grant.AllowsSpace(tt.spaceId)) + }) + } +} + +func TestApiGrantCanWrite(t *testing.T) { + assert.True(t, (&ApiGrant{Spaces: []string{"s"}, Perms: GrantPermsReadWrite}).CanWrite()) + assert.False(t, (&ApiGrant{Spaces: []string{"s"}, Perms: GrantPermsRead}).CanWrite()) + // unknown or empty perms are read at most — fail closed + assert.False(t, (&ApiGrant{Spaces: []string{"s"}, Perms: "admin"}).CanWrite()) + assert.False(t, (&ApiGrant{Spaces: []string{"s"}}).CanWrite()) + assert.False(t, (*ApiGrant)(nil).CanWrite()) +} + +func TestApiGrantCtxRoundTrip(t *testing.T) { + t.Run("grant rides the context", func(t *testing.T) { + // given + want := &ApiGrant{Spaces: []string{"space1"}, Perms: GrantPermsRead} + + // when + ctx := CtxWithApiGrant(context.Background(), want) + + // then + require.Equal(t, want, ApiGrantFromCtx(ctx)) + }) + + t.Run("absent grant reads as nil (unscoped)", func(t *testing.T) { + assert.Nil(t, ApiGrantFromCtx(context.Background())) + }) + + t.Run("a stored nil grant reads as nil", func(t *testing.T) { + ctx := CtxWithApiGrant(context.Background(), nil) + assert.Nil(t, ApiGrantFromCtx(ctx)) + }) +} + +func TestBearerChallenges(t *testing.T) { + // the header values are wire surface MCP clients parse — pin them + assert.Equal(t, `Bearer realm="anytype"`, BearerChallenge()) + assert.Equal(t, `Bearer realm="anytype", error="invalid_token"`, BearerChallengeInvalidToken()) + assert.Equal(t, `Bearer error="insufficient_scope"`, BearerChallengeInsufficientScope("")) + assert.Equal(t, `Bearer error="insufficient_scope", scope="space:space1:readwrite"`, + BearerChallengeInsufficientScope(SpaceScope("space1", GrantPermsReadWrite))) +} diff --git a/core/api/util/keystatus.go b/core/api/util/keystatus.go new file mode 100644 index 0000000000..b296259893 --- /dev/null +++ b/core/api/util/keystatus.go @@ -0,0 +1,90 @@ +package util + +// keystatus.go carries the authenticated credential's description and the +// legacy-key deprecation signal through the HTTP layer. Both route groups +// emit the signal headers; the /v2 whoami body repeats the same values — +// agents read bodies, not headers — so the constants live here, in the one +// package both sides already share, and the header and the body cannot +// drift apart. + +import ( + "context" + + "github.com/anyproto/anytype-heart/pkg/lib/pb/model" +) + +// ApiVersion is the API version reported by the Anytype-Version response +// header and the whoami body's api.version — one constant, two mirrors. +const ApiVersion = "2025-11-08" + +// The legacy-key deprecation signal (design spec P1 §6). Deliberately NOT +// RFC 9745 Deprecation/Sunset: those headers require a Date value and are +// scoped to the RESOURCE in the response — emitting them on /v1 would +// declare /v1 deprecated, the opposite of the grandfathering promise. +// Anytype-Key-Status names the CREDENTIAL, the thing that is actually +// legacy, and is sent on every authenticated response — a client never has +// to treat absence as meaningful. +const ( + KeyStatusHeader = "Anytype-Key-Status" + KeyStatusLegacy = "legacy" + KeyStatusScoped = "scoped" + + // NoticeHeader carries one short single-line ASCII sentence a client can + // print verbatim (npm's notice pattern). It never interpolates user data. + NoticeHeader = "Anytype-Notice" + // LegacyKeyNotice is the sentence itself — also embedded in the whoami + // body for legacy keys. Text is API surface; tests pin it. Emitted only + // for nil-grant keys of JsonAPI scope: a grant is only ever valid on + // JsonAPI scope (wallet.ValidateAppLinkGrant), so a Limited or Full + // credential cannot follow the re-issue advice and never gets it. + LegacyKeyNotice = "This API key is a legacy unscoped key with access to every space. It will keep working. Re-issue it as a scoped key in Settings > API Keys." + + // KeyDeprecationLink is the Link header value pointing at the key + // policy. rel="deprecation" WITHOUT a Deprecation header is RFC 9745 + // §3.1's own worked example for "here is the policy, no date committed". + // The target is the developer portal's live authentication guide (its + // Settings > API Keys walkthrough); move it to the dedicated key-scoping + // section when that page ships (design spec P1 §7) — a policy link that + // 404s would invert the signal's whole point. + KeyDeprecationLink = `; rel="deprecation"; type="text/html"` +) + +// KeyStatus names the credential kind for the signal: legacy for nil-grant +// keys, scoped otherwise. Grant PRESENCE decides, never key-string format — +// a legacy-format key can be granted in place and a new-format key can be +// unscoped. +func KeyStatus(grant *ApiGrant) string { + if grant == nil { + return KeyStatusLegacy + } + return KeyStatusScoped +} + +// ApiKeyInfo describes the authenticated CREDENTIAL (never the person) for +// introspection: the app link attributes WalletCreateSession resolved at +// session mint. Zero timestamps mean unknown (CreatedAt) / never (ExpiresAt). +type ApiKeyInfo struct { + Id string // the app link's hash — the id ListApps shows + Name string + CreatedAt int64 + ExpiresAt int64 + Scope model.AccountAuthLocalApiScope +} + +// apiKeyInfoCtxKey is the private carrier type for the credential +// description on the request context. +type apiKeyInfoCtxKey struct{} + +// CtxWithApiKeyInfo stores the authenticated credential's description on the +// request context, next to the grant (CtxWithApiGrant) — whoami reads both +// from the same carriers the enforcement path populates. +func CtxWithApiKeyInfo(ctx context.Context, info ApiKeyInfo) context.Context { + return context.WithValue(ctx, apiKeyInfoCtxKey{}, info) +} + +// ApiKeyInfoFromCtx returns the request's credential description; ok is +// false when the request never passed ensureAuthenticated. +func ApiKeyInfoFromCtx(ctx context.Context) (ApiKeyInfo, bool) { + info, ok := ctx.Value(apiKeyInfoCtxKey{}).(ApiKeyInfo) + return info, ok +} diff --git a/core/api/v2/EVAL_FINDINGS.md b/core/api/v2/EVAL_FINDINGS.md new file mode 100644 index 0000000000..a3a88d93e6 --- /dev/null +++ b/core/api/v2/EVAL_FINDINGS.md @@ -0,0 +1,335 @@ +# API v2 eval findings — go-7383-apiv2-phase0 + +## Status: 223 → 0 failing subtests — closed (five commits) + +Round 4 (commit `db3e38124`) fixed the last piece: `pkg/lib/anyblockjson`'s +`filterstring` package, the compact filter STRING compiler. It's a shared +library outside `core/api/v2`, so this needed its own explicit go-ahead — +given, and fixed. + +Same shape as everything before it, ten hardcoded call sites plus one JSON +struct tag plus a value table, all confirmed against `anyblockjson`'s own +canonical enums before touching anything: + +- the ten condition tokens (`notEqual`, `greaterOrEqual`, `lessOrEqual`, + `notContains`, `notIn`, `allIn`, `notAllIn`, `exactIn`, `notExactIn`, + `notEmpty`) → their snake_case forms +- the emitted node's `date_preset` JSON tag (was `datePreset`) +- the `datePresets` VALUE table (`daysAgo()` → `number_of_days_ago`, + `currentWeek()` → `current_week`, etc.) — the map KEYS (the compact + syntax's own function names the user types, e.g. `currentWeek()`) + correctly stay camelCase; that's a separate, intentional vocabulary, + confirmed against the served EBNF grammar, not part of this migration +- the `multiSelect` format check inside `stringValue` +- `schemas.go`'s structured `filters` kind: the `date_preset` field's + documented enum and worked example were teaching the same stale values +- every consumer's own stale expectations: `filterstring`'s own test suite, + and `core/api/v2/service`'s `search_test.go` / `list_create_test.go` / + `viewops_test.go`, all of which hardcode either the compiler's output or + raw structured-filter JSON using the old spelling directly + +`go test ./core/api/v2/... ./pkg/lib/anyblockjson/...`: **all green.** +`go build ./...`, `go vet`, and `gofmt` all clean. Five commits total on +this branch, `223 → 0` failing subtests. + +## Status (round 3): 223 → 9 failing subtests + +Round 3 (commit `932f31318`) traced every failure that survived round 2 +instead of guessing, and found the same pattern three more times: +`schemas_ops.go`'s table-row schema still advertised `isHeader` (real +validator wants `is_header` — a caller following v2's own served schema for +table rows got rejected downstream); `viewops.go`'s output-only-field guard +for `objectOrders` (the one that gives a clear "this is output-only, don't +write it" error) was still keyed on the old spelling, so real `object_orders` +input silently fell through to a generic "unknown field" refusal instead; +and the inline `` tag — which turned out to need +no production fix at all, `pkg/lib/anyblockjson`'s codec already speaks +`object_id` in both directions, only a served-doc string and two test +expectations were stale. **29 → 9 failing subtests.** + +**All 9 remaining now trace to exactly one place**, confirmed by adding +temporary debug logging to print the actual validation issues rather than +guessing from the generic error message: +`pkg/lib/anyblockjson/filterstring/filterstring.go`, the compact filter +STRING compiler, still emits camelCase condition/field tokens at ten +separate call sites — every multi-word condition and the `datePreset` field +folded in from date-preset functions like `daysAgo()`. The downstream +validator (also in `anyblockjson`, already migrated) rejects all of them. +Single-word conditions (`contains`, `in`, `empty`, `exists`, `equal`, +`greater`, `less`) are unaffected — everything else (`!=`, `>=`, `<=`, +`NOT CONTAINS`, `NOT IN`, `HAS ALL`, `NOT HAS ALL`, both set-literal forms, +`IS NOT EMPTY`, and any use of a date-preset function) is broken. This is +the shared-library fix flagged since round 1 — same shape of bug, same +mechanical fix (rename ~10 string literals to match the enum the validator +already uses), just outside `core/api/v2`'s own package boundary. Not yet +fixed; needs a decision on touching `pkg/lib/anyblockjson`. + +## Status (round 2): 223 → 29 failing subtests + +Round 2 (commit `07e8bfa43`) found the same gap one level down: block TYPE +names (`heading1`→`heading_1`, `bulletedListItem`→`bulleted_list_item`, +`toggleHeading1-3`→`toggle_heading_1-3`), already migrated in +`pkg/lib/anyblockjson`'s enum, still spelled the old way in `object.go`, +two served-schema examples, and 5 test files. One confirmed functional +regression in that round: `object.go`'s `outlineHeadingTypes` map — the one +deciding whether a block's text shows up in a `?outline=true` read — was +still keyed on the old spellings, so it matched nothing and outline reads +silently stopped showing heading text for every object. Fixed. **170 → 29 +failing subtests** from this round alone. + +**Remaining 29**, not yet fixed, roughly three buckets: +- Chat/object mention-tag rendering (`TestChatMessageFromProto`, + `TestV2GetObjectIdShapes`) — test expects ``, + the renderer already emits ``. Deliberately not + touched: this is the inline-markup GRAMMAR (an XML-style attribute inside + markdown text), not a JSON field — a bigger, more product-visible decision + than a struct-tag rename, and out of scope without explicit sign-off. +- `TestViewOpReviewFixes` (3) and part of `TestV2SearchPlanConvergence`/ + `TestV2SearchFileLayoutOptIn` — look adjacent to the `filterstring.go` + boundary already flagged above (out of scope: shared library). +- `TestPatchObject` (`set_cell`×4, `insert_blocks_inside_a_leaf_block`, + `set_properties_add_on_a_scalar_format`), `TestPatchPayloadIdsResolve` + (4), `TestPatchReportsMintedNestedIds` (2), `TestV2CreateSet` (2), + `TestUpdateViewOp` (1) — not traced yet; didn't show an obvious single + shared root cause in a first pass the way the last two rounds did. + +## Status (round 1): 223 → 172 failing subtests + +`go test ./core/api/v2/...` on HEAD (before anything in this doc) had **223 +failing subtests** — hidden until now because piping the run through +`tail`/`head` swallows `go test`'s real exit code; run it unpiped to see the +true result. Root cause, confirmed directly from failure messages: HEAD's own +merge commit — `88bf52ffe GO-7383 Merge go-7383-anyblockjson: the format's +vocabulary is snake_case` — moved the shared AnyBlock schema/library to +snake_case, but `core/api/v2/service`'s OWN vocabulary (the `kind` dispatch, +block property names, view fields, sort/filter fields, the type/template +envelope, and a large fraction of the test suite's own fixture bodies) was +never updated to match. + +**Fixed in this pass, verified by `git stash` A/B and a full test run each +time — 223 → 172 (−51 subtests, 24%), zero new failures introduced:** + +- `kind` (`objectType` → `object_type`) — the original bug #1 below +- `template_for`, `type_properties` — the type/template envelope +- 13 view-set fields (`group_by`, `cover_property`, `end_property`, + `hide_icon`, `card_size`, `cover_fit`, `colored_groups`, `page_size`, + `default_template_id`, `default_type_id`, `wrap_content`, `list_size`, + `alternate_rows`) plus sort-item fields (`custom_order`, `include_time`, + `empty_placement`, `no_collate`) and the column aggregation enum + (`count_value`, `count_distinct`, `count_empty`, `count_not_empty`, + `percent_empty`, `percent_not_empty`) +- block schema fields: `object_id`, `icon_emoji`, `icon_image`, `card_style`, + `background_color` +- `date_preset` (search/sort probes) +- property format `multi_select` (was `multiSelect` — a caller could never + successfully create a multi-select property through the documented value) +- the structured `filters` kind's `condition` enum in `schemas.go` — was + documenting `notEqual`/`greaterOrEqual`/etc. when the real validator + (`pkg/lib/anyblockjson`) already only accepts the snake_case forms; a + documentation-only fix, the real validator was already correct +- 2 production bugs this uncovered along the way, not just doc/schema + strings: **`keycanon.go`**'s view-key canonicalizer was still keyed on + `view["groupBy"]`, so it silently stopped canonicalizing a view's group-by + property-key value the moment callers sent the (now-correct) `group_by` + field name; **`keys.go`**'s envelope canonicalization loop was still keyed + on `"templateFor"`, so a template's target-type key spelling silently + stopped resolving too. Both fixed to match. +- ~11 test files' own fixtures updated to the corrected spelling, plus one + stale test (`schemas_ops_test.go`'s `KnownBlockProperty` assertions) that + was asserting the *old* camelCase names were known and the new snake_case + ones weren't — backwards from current reality, flipped to match + +**Deliberately NOT touched — a different package, different blast radius:** +the compact filter string compiler, `pkg/lib/anyblockjson/filterstring/filterstring.go`, +still *emits* camelCase condition tokens (`notEqual`, `greaterOrEqual`, +`notEmpty`, …) that its own downstream validator (also in `anyblockjson`) +rejects — this is the root cause of confirmed bug #3 below. Fixing it means +editing the shared AnyBlock library itself, which has consumers beyond v2 — +out of scope for a v2-focused pass without separate sign-off. +`core/api/v2/service/resolver.go:347` and `viewops.go:596` (`case "empty", +"notEmpty", "exists":`) were deliberately left alone for the same reason: +they correctly match what the compiler *currently* emits, and "fixing" them +in isolation would break value-stripping instead of fixing anything. + +**~172 failing subtests remain**, spanning patch ops, locators, search, +chat, and more (`TestPatchObject`, `TestV2SearchObjects`, +`TestChatMessageFromProto`, `TestUpdateViewOp`, …). Spot-checked a few: +they pre-date this pass (confirmed present in the original 223, e.g. +`TestChatMessageFromProto` lives in `core/api/v2/model`, a package this pass +never touched) and look like further instances of the same casing split +(e.g. chat mention tags: test expects ``, renderer +already emits ``) plus at least one block-type enum +issue (`/blocks/0/type: value must be one of 'paragraph', ...`) not yet +traced. Not chased further in this pass — flagging for the team to scope +as its own effort. + +For reference, the exhaustive struct-tag sweep of `core/api/v2` production +code that started this — every remaining camelCase `json:` struct tag in the +package, cross-checked against `pkg/lib/anyblockjson`'s own (already +snake_case) canonical spelling for the same field: + +| our tag (camelCase) | anyblockjson canonical | where | +|---|---|---| +| `templateFor` | `template_for` | `service/create.go:34` | +| `typeProperties` | `type_properties` | `service/create.go:37`, `service/schema_write.go:441` | +| `groupBy` | `group_by` | `service/list_create.go:253` — bug #2 above | +| `customOrder` | `custom_order` | `service/resolver.go:380`, `service/list_create.go:249` | +| `datePreset` | `date_preset` | `service/search.go:609` | +| `includeTime` | `include_time` | `service/list_create.go:246` | + +**All six — plus everything else in the "Fixed in this pass" list above — +are now fixed.** `templateFor`'s earlier "not reproduced" verdict in this +doc was wrong: `pkg/lib/anyblockjson` already spoke `template_for` +end-to-end (its own validator's error message is literally `template_for is +only valid on templates (type "template")` — the exact message the original +probe hit), and the real bug was `docEnvelope.TemplateFor`'s stale +`json:"templateFor"` tag never binding a spec-correct request. +`typeProperties` was worse — **silent**: a spec-correct `type_properties` +array just failed to bind (no error), so `CreateType`'s +create-missing-properties feature quietly did nothing. + +--- + + +Run: 13 parallel Haiku subagents drove 122 live HTTP calls against every route +in `core/api/v2/router.go`, against `http://127.0.0.1:31009`, space `3ovz6u` +("API eval"). Every finding below was independently re-reproduced by hand +with `curl` before being kept — see [Retracted](#retracted-not-real-bugs) for +what didn't survive that pass. + +Result: 122 calls, 115 pass, 3 confirmed bugs, 1 unresolved lead, 4 retracted. + +All three confirmed bugs share one root cause: the snake_case migration this +branch is doing landed unevenly — one validator got updated, the validator +next to it didn't, and the two now disagree about what a request is allowed +to say. In every case that makes the feature **completely unreachable**, not +just inconvenienced — there is no request body that satisfies both sides. + +--- + +## Confirmed bugs + +### 1. Type creation is unreachable — no value of `kind` satisfies both validators + +**Severity:** High · `POST /v2/spaces/{id}/types` + +``` +# kind: "objectType" (camelCase) — the value the endpoint's own schema example uses +curl -X POST /v2/spaces/{id}/types -d '{"version":1,"kind":"objectType",...}' +→ 400 "/kind value must be one of 'object_type', 'bundled_object_type', ..." + +# kind: "object_type" (snake_case) — the value that error just asked for +curl -X POST /v2/spaces/{id}/types -d '{"kind":"object_type",...}' +→ 400 "POST types accepts kind \"objectType\" documents only" +``` + +A dispatch check runs first and hard-requires the old camelCase spelling; the +generic AnyBlock document validator runs second and only accepts the new +snake_case enum. Each value clears one gate and fails the other. + +**Root cause:** +- `core/api/v2/service/schema_write.go:111` — `kind != "objectType"` hardcoded pre-check, never updated +- `pkg/lib/anyblockjson/json.go` — the AnyBlock `kind` enum, already snake_case + +--- + +### 2. `insert_view` can't create a grouped/kanban view — `groupBy` vs `group_by` + +**Severity:** High · `PATCH .../objects/{id}` op `insert_view` + +``` +# groupBy — what the op's own error message says is allowed +{"op":"insert_view","set":{"type":"kanban","groupBy":"status"}} +→ 400 "additional properties 'groupBy' not allowed" (document-level check) + +# group_by — what the resulting view object actually stores (confirmed via GET) +{"op":"insert_view","set":{"type":"kanban","group_by":"status"}} +→ 400 "unknown view field \"group_by\" — allowed: ...groupBy..." (op-level check) +``` + +The op-level validator's own allow-list names `groupBy` as correct; the +document-level check that runs on the result rejects exactly that spelling. +Neither order works — a view can be created (confirmed working without a +group-by), but never with `group_by` set at creation time. + +**Root cause:** +- `core/api/v2/service/keycanon.go:168` canonicalizes view fields as `groupBy` +- the other side of the check — wherever `/blocks/0/views/N` gets validated + against the object document schema — still expects the snake_case form + +--- + +### 3. Compact filter `IS NOT EMPTY` — documented, rejected by its own validator + +**Severity:** High · `POST .../search`, compact `filter` string + +The exact example from `core/api/v2/SKILL.md` and from +`GET /v2/schemas/filters`'s own `grammar_examples`: + +``` +curl -X POST /v2/spaces/{id}/search -d '{"filter":"assignee IS NOT EMPTY"}' +→ 400 unknown condition "notEmpty" — allowed: ...not_empty... +``` + +The grammar compiler turns `IS NOT EMPTY` into the internal token `notEmpty` +(camelCase); the very next validation step only accepts `not_empty` +(snake_case) from its own allow-list. A syntactically perfect, +spec-documented filter can never pass. + +**Root cause:** +- `core/api/v2/service/resolver.go:344` and `core/api/v2/service/schemas.go:156` + both still spell the value-less conditions `notEmpty` +- `core/api/v2/service/viewops.go:583-596` carries the same spelling into a + second code path + +--- + +## Unresolved lead (not confirmed) + +### Compact filter: `due_date < today()` combined with `AND` drops a matching row + +**Severity:** Medium (unconfirmed cause) · `POST .../search`, compact `filter` string + +`name CONTAINS "QA Search" AND due_date < today()` returned only the object +with **no** `due_date` (the one the response's own warning says the +comparison spuriously includes) — an object whose `due_date` was genuinely in +the past did not come back. The equivalent structured `filters` array +returns all matching rows correctly, so the gap is specific to compiling a +compact `date < …` leaf combined with another leaf via `AND`. Reproduced +directly but not traced to a line — worth chasing. + +--- + +## Retracted (not real bugs) + +Flagged by an agent, didn't survive a second, careful pass: + +- **"Template creation expects camelCase `templateFor`"** — not reproduced; + `template_for` (snake_case) is accepted as a field name once the rest of + the body is well-formed. The original 400 was a cascading error from an + unrelated malformed body (a stray top-level `name`), not a casing + rejection. +- **"Option-ID-shaped value accepted as a new option name"** — + `GET .../properties/{key}/options` exposes only `name` (and `color`), no + `id` field at all. The test used a made-up ID-shaped string with no real + option behind it, so create-missing correctly treated it as a new name. + There's no way to reach a *real* option ID through this endpoint to test + the guide's actual warning. +- **"`match` substring specificity" in block locators** — the agent flagged + this and then correctly explained its own mistake in the same breath: + `match:"Draft timeline"` is a substring of three different blocks' text, + so the API's refusal was exactly per spec. +- Duplicate log of the above, from the same task. + +--- + +## What passed clean (115 / 122 calls) + +Every other endpoint behaved exactly as `core/api/v2/SKILL.md` describes, +including the negative paths that are *supposed* to fail: idempotency replay ++ conflict, `If-Match` etag mismatch, atomic PATCH-batch rejection with +untouched state on failure, did-you-mean hints, the recursive-delete guard, +ambiguous `match`/`id` refusals, offset-pagination rejection on chats, and +the own-output-only 403 on deleting a foreign object. Full per-task call log +is in the workflow transcript if needed — this file only carries the +problems. diff --git a/core/api/v2/SKILL.md b/core/api/v2/SKILL.md new file mode 100644 index 0000000000..a468788260 --- /dev/null +++ b/core/api/v2/SKILL.md @@ -0,0 +1,302 @@ +--- +name: anytype-api +description: Call Anytype's local HTTP API v2 directly — search, read, create and edit objects, sets, collections and chats over REST. Use when writing scripts, SDK code or curl against the API. For interactive note/task work prefer the `anytype` CLI and its skill (cmd/anytype/SKILL.md); this guide is the raw-HTTP layer beneath it. +--- + +# Anytype API v2 — HTTP guide for agents + +Local REST API at `http://127.0.0.1:31009` (the Anytype app must be +running). Every call sends `Authorization: Bearer `; keys are created +in the app (Settings → API keys) — **the API mints none**. Bodies are +compact JSON, and every name this API owns is `snake_case` — params, +fields and op names alike. Every list takes `?offset=&limit=` (default 25, +max 1000) and returns `{data, total, offset, limit, has_more}`. + +**First call: `GET /v2/auth/whoami`.** A key may be scoped to particular +spaces and to read-only. `grant.scoped: false` means the whole account; +otherwise `grant.spaces` lists what you may touch and `grant.permission` +whether you may write. Ask this instead of discovering limits through 403s +(`space_not_granted` / `write_not_granted` — the message names the grant). + +## The data model in six ideas + +- **Spaces** contain everything; nearly every route is + `/v2/spaces/{space_id}/…`. `GET /v2/spaces` lists them. +- An **object** = `properties` (typed key-values) + `blocks` (document + content). `type` is always a type **key** (`page`, `task`) — never an id. +- **Properties** are addressed by key, and every key is snake_case — + bundled, API-created and UI-created alike (`due_date`, `icon_emoji`, + `manual_property`). `GET …/properties` spells them the same way; so do + documents. Keys are forgiving on input — `dueDate` or `DueDate` resolve + to `due_date` too; an input matching two properties is a 400 listing + both. (One exception: the compact `filter` STRING validates before + folding — use a listed spelling there.) + Select/multi_select values are option **names** (`"In progress"`, + case-sensitive) — never option ids. A name the property does not already + hold is **refused** — check it against `GET …/properties/{key}/options`, + or resend with **`?create_missing_options=true`** to create it (a PATCH caps that + at 64). Unknown property keys are rejected with a did-you-mean. +- **Blocks** are a FLAT array in pre-order with an integer `indent` + (absent = 0) — no `children` key. Inline formatting is markdown inside + `text`. Use block ids exactly as a read served them. +- Title and description are **not blocks** — they live in `properties` + (`name`, `description`). A fresh object has zero blocks. +- A **set** is a live query over a type; a **collection** is a hand-curated + list (edited via `add_items`/`remove_items`). **Chats** store messages + outside blocks, paged by order-id cursors. + +## Which operation + +| Intent | Call | +|---|---| +| find objects | `POST …/{space_id}/search` (or `POST /v2/search` across spaces — rows then carry `space_id`). Search with filters; don't enumerate `GET …/objects` | +| read one object | `GET …/objects/{id}` — start with `?outline=true` | +| change property values | PATCH op `set_properties` — `add`/`remove` for list values, `set` for scalars | +| complete a task object | `set_properties` (`"set":{"done":true}` or the status option) — a property, not a block edit | +| change a word/phrase | op `replace_text` `{find, replace}` — `id` optional; never retype the block | +| toggle a checkbox block | op `update_block` `{"match":"Draft timeline","set":{"checked":true}}` — merge; text untouched. `match` or `id`, never both | +| add content | op `insert_blocks` with a `markdown` payload — write markdown, the server parses it | +| restructure | ops `move_block` / `replace_subtree` / `delete_block` (`delete_block` takes `match` too) | +| one table cell | op `set_cell` — never rewrite the table | +| show/hide a view column, edit a view | op `update_view` — works on sets, collections and a type's default view (PATCH the type OBJECT id from `GET …/types/{key}`) | +| add / reorder / remove a view | ops `insert_view` (`copy_from` duplicates one) · `move_view` (`position:"first"` = default tab) · `delete_view` | +| create an object | `POST …/objects` — shortcut `{type, name, properties, markdown}` covers most cases | +| delete an object you created | `DELETE …/objects/{id}` — archives (Bin, reversible in the app). Only works on objects THIS key created after provenance shipped; anything else → 403 `not_created_by_this_key`, permanently — don't retry, archive in the app instead. Ownership is matched on the app name EXACTLY (byte-for-byte — re-pair under the identical name to keep delete rights). User content only: system objects 403. Probe first with `?dry_run=true` | +| curate a collection | PATCH ops `add_items` / `remove_items` on the collection object | +| read a set / collection | `GET …/sets/{id}/objects` · `…/collections/{id}/objects` (`?view=`, `?fields=`) | +| new type / property | `POST …/types` · `POST …/properties`; select options ride the property, or `?create_missing_options=true` mints them from values | +| upload a file | `POST …/files` (multipart or `{"url":…}`) → the id file blocks and chat attachments need | +| chat | `GET/POST …/chats/{id}/messages`, `POST …/read` — see Chats | + +## Read cheaply + +- `GET …/objects/{id}?outline=true` → every block's `{indent, id, type}` + (text on headings only) — structure + addressable ids at a fraction of + the tokens. Follow up with `?block={id}` for one subtree, or PATCH + directly: **editing needs no prior full read once you know the ids**. +- When the request already quotes the text to change, skip the read + entirely: `replace_text {find, replace}` locates the block itself, and + `update_block`/`delete_block` take `match` for the same job (one match, or + a refusal listing the candidates). +- `?include=properties` or `?include=blocks` reads half the object. + `?format=md` is a read-only markdown rendering. +- Echo block ids back exactly as a read served them; if one is rejected as + unknown, re-read and use the fresh ids. `?ids=full` is the backup/export + shape — the read to archive or clone from, not needed for editing. +- List/search rows are minimal `{id, name, type}`; add columns with + `fields=` (property keys) instead of GETting each object. +- Every object read returns an `etag` (envelope + `ETag` header). + +## Edit: PATCH ops + +`PATCH …/objects/{id}` body `{"ops":[…]}` — one atomic batch (≤512 ops, +≤256 blocks per op): any invalid op rejects the whole PATCH with +`ops[i]`-addressed issues. Fourteen ops: + +```json +{ "ops": [ + { "op": "set_properties", "set": {"status": ["Done"]}, "unset": ["oldKey"], + "add": {"tags": ["urgent"]}, "remove": {"assignee": ["bafy…"]} }, + { "op": "update_block", "match": "Draft timeline", "set": {"checked": true} }, + { "op": "replace_text", "find": "Q3 report", "replace": "Q4 report" }, + { "op": "insert_blocks", "after": "b3", "markdown": "## Notes\n- first\n- second" }, + { "op": "move_block", "id": "b9", "inside": "b2", "position": "last" }, + { "op": "delete_block", "id": "b4", "recursive": true }, + { "op": "set_cell", "table_id": "t1", "row": "r2", "col": "c1", "value": "done" }, + { "op": "update_view", "columns": {"status": {"hidden": false}} }, + { "op": "insert_view", "name": "Board", "copy_from": "viewAll1", + "set": {"type": "kanban", "groupBy": "status"} } +] } +``` + +- **`set_properties`**: a key appears in at most one of + `set`/`unset`/`add`/`remove`. `add`/`remove` are per-entry list edits + (select/multiSelect/objects/files) — appending one tag never rewrites + the array. `remove` never creates the option it names. `set: {"k": []}` + = present-but-empty; `unset` removes presence. +- **`update_block`** is THE block-field op (merge; explicit `null` clears a + field) — checkbox, color, language, retype, or full text rewrite. +- **`match` addresses the block by its TEXT** on `update_block` and + `delete_block` — the `id` alternative: give one or the other, **never + both** (and never neither). The text must appear in exactly ONE block or + the op refuses: zero → read the outline, several → the error lists + candidate ids to retry with. Repeats inside the one matched block are + fine — `match` names a block, not an occurrence. It reads the document as + the ops before it in the batch left it. +- **`replace_text`**: `id` is optional — omitted, `find` locates the block + and must appear in exactly ONE block (zero or several matching blocks + refuse; the ambiguity error lists candidate ids to retry with). Within + the matched block `find` must match exactly once ("found 2 matches — + provide more context"); `replace_all: true` is the escape, within that + one block only. Preferred over `update_block` for word-level edits. + `replace_subtree {id, blocks}` swaps a block plus descendants. +- **`insert_blocks`**: `blocks` (flat array) or `markdown` — mutually + exclusive, same targeting. Target with one of `after`/`before`/`inside` + (+`position: first|last` inside that container); omit all three and + `position` picks an end of the DOCUMENT — `last` (or absent) appends, + `first` inserts at the start, both on an empty object too. Payload + `indent: 0` = the anchor's level (`after`/`before`) or the container's + child level (`inside`). `move_block` targets the same way, so + `{"op":"move_block","id":"b9","position":"first"}` moves a block to the top + of the document. +- **Author new content without ids** — an `id` names an EXISTING block, so + `insert_blocks` takes none anywhere in its payload (rows and columns + included); the server mints them and returns them in `created_blocks`, + keyed by the payload path that produced each — `ops[0].blocks[0]`, + `ops[0].blocks[0].rows[1]`, `ops[0].blocks[0].columns[0]`. The same holds + wherever you leave an id out of an existing-content payload (a new row in + `update_block set.rows`, a block inside a `set_cell` array), so you never + have to re-read to learn an id you just created. +- **`update_view`** edits ONE dataview view — never resend the views array. + `block`/`view` are optional when the object has one dataview and it one + view (types, sets, collections usually do). `set` merges view fields + (`name`, `type`, `groupBy`, `sorts`, `filters` — arrays replace whole; + `filter` takes the compact string; null clears a field); `columns` merges + per property key: `{"hidden": false}` shows a column, `null` removes it, + a new key appends one. Works on Blocks-restricted objects — view config + is not a block edit. +- **`insert_view`/`move_view`/`delete_view`** complete the family (same + addressing, same channels; insert_view's name is its own required field — + not in `set`). insert_view needs only `name` — bare default: every listed + property visible, newest first; `copy_from` duplicates a view (then + `set`/`columns` override); the minted id returns in `created_views`, + keyed `ops[i]`. move_view REQUIRES one of `after`/`before`/`position` + (`"first"` = default tab). delete_view refuses the last view — insert the + replacement first (one atomic batch swaps a bad default view). +- Response: new `etag`, `created_blocks` (payload position → real id; + nested row/column/cell slots included), `created_views` (same, for minted + view ids), `created` (options minted under `?create_missing_options=true`), + `diff_stats {blocks_added, blocks_removed, blocks_changed, blocks_moved, + properties_changed}`, `warnings` (advisory, e.g. an unguarded date filter). +- **There is no whole-document replace** — never read a document, + regenerate it and write it back. Replace a section with + `replace_subtree`; start over by batching `delete_block`s with the new + `insert_blocks`. + +## Query + +`POST …/search` body: `{query?, type?, filter?|filters?, sorts?, fields?}`. +Pagination is the query params — a body `limit` is rejected. Search is a +read: no `Idempotency-Key`, `dry_run` ignored. + +```json +{ "query": "report", "type": "task", + "filter": "done = false AND (due_date < currentWeek() OR due_date IS EMPTY)", + "sorts": [ { "property": "due_date", "direction": "asc" } ], + "fields": ["name", "due_date", "status"] } +``` + +- **Prefer the compact `filter` string** (≤4096 chars): + `status IN ("In progress", "Blocked")` · `name CONTAINS "report"` · + `last_modified_date > daysAgo(7)` · `tags HAS ALL ("urgent", "q3") AND + assignee IS NOT EMPTY`. Dates are RFC 3339 or preset functions + (`today()`, `currentWeek()`, `daysAgo(n)`). Parse errors are + offset-addressed with did-you-mean. +- The structured `filters` array: leaf = + `{"property","condition","value"}`, group = + `{"operator":"and|or","filters":[…]}` (non-empty). Date values there are + **unix seconds**, not RFC 3339 (the string form converts for you). + `filter` and `filters` together → 400 `ambiguous_input`. +- `type` is also a filter pseudo-key for multi-type: `type IN ("task", + "bug")`. **File rows appear only when a file type is named** in the type + channel (`type = "image"`, `type IN (… "file")`) — `size > 5` alone + matches nothing; compose `type = "image" AND size > 5`. `mimeType` and + `size` work in fields/filters/sorts. +- An unguarded `due_date < …` also matches objects with **no** date — the + response warns; add `AND due_date IS NOT EMPTY` unless intended. +- Full-text `total` is a lower bound while `has_more` is true — walk + pages, don't plan on the number. +- Sorts: any property key, `{"property", "direction": "asc|desc"}`; + default is `last_modified_date desc`. + +## Chats + +- `GET …/chats/{id}/messages` returns `{messages, state, message_count, + has_more, next_before?, next_after?}`. `state` carries `unread_messages`, + `unread_mentions`, `last_state_id` — so "anything new?" is a `?limit=1` + read. Cursors only (`?after=` walks forward; otherwise newest-first via + `next_before`); `?offset=` is rejected. +- Message `text` is inline markup both ways (mentions as + ``); ≤8000 chars; `attachments` = up to 32 object + ids from `POST …/files`. `?reactions=full` adds who reacted. +- Mark read: `POST …/chats/{id}/read` with `{"up_to": , + "last_state_id": }` — **both** from the same GET, else nothing marks. +- `PATCH …/messages/{id}` `{"text"}` edits text only (attachments kept); + editing/deleting another member's message → 403. DELETE permanently + removes orphaned attachments — the response warns with their ids. +- No etag/If-Match on chats; order ids are the concurrency vocabulary. + +## Conventions on every call + +- **Errors** are `{status, code, message, issues:[{path, message, + hint}]}` — built to be repaired in ONE retry: fix the named path per the + hint, resend once. Never loop blindly; 403s and validation failures do + not improve with repetition. +- `warnings` on success responses are advisory — no retry needed. +- **`Idempotency-Key`** (all mutations incl. DELETE): mint a fresh random + key per logical mutation; reuse the SAME key only to retry the identical + request — a replay answers `Idempotency-Replayed: true`. The same key + with a different body/path/query → 409 `idempotency_conflict`. +- **`?dry_run=true`** on any mutation: full validation, identical verdicts, + nothing committed (response echoes `dry_run: true`). +- **`?create_missing_options=true`** on any write that sets a select value: consent + to MINT option names the property does not hold yet. Default off, and off + refuses — an unmatched name is usually a typo or a stale label, and a + minted option joins the property's vocabulary for the whole space with no + delete surface. `created` on the response lists what a consented write + actually minted. +- **`If-Match`** (objects only): send an etag back verbatim when a + concurrent overwrite would matter; mismatch → 409 `etag_mismatch` + carrying the current etag. Omit it by default — sync also moves the + etag, so habitual If-Match 409s on noise. +- `POST /v2/validate` pre-flights an AnyBlock document: 200 with + `{issues, warnings}` even for an invalid one. + +## Look it up at runtime — don't guess + +- `GET /v2/schemas` — index. `GET /v2/schemas/{kind}` — strict JSON Schema + + worked example per request kind (`object`, `shortcut`, `type`, + `template`, `property`, `set`, `collection`, `file`, `search`, `space`, + `filters`, `chat`, `chatMessage`, `chatMessageEdit`, `chatReaction`, + `chatRead`). The `filters` kind also serves the filter-string grammar + (EBNF + examples). `GET /v2/schemas/ops/{op}` — per-op schema + example. +- Live vocabulary: `GET …/types` and `GET …/types/{key}` (the type + document, incl. its property keys); `GET …/properties`; + `GET …/properties/{key}/options?prefix=` (check before writing select + values); `GET …/members` and `…/members/me` (participant ids for + `assignee`/`creator`; `/me` is your own). +- Full reference: `GET /v2/docs/openapi.json` (or `.yaml`). + +## Mistakes that actually happen + +- RFC 3339 dates in the **structured** `filters` array (unix seconds + there — or use the filter string, which converts). +- Rewriting a whole multiSelect array to add one entry — use + `set_properties.add`. +- Option **ids**, or wrong-case option names, as values — names are the + identity; check `…/options` first. +- Reusing one `Idempotency-Key` across different requests → 409. Keys are + per logical mutation, not per session. +- `replace_text`/`edit` text is markup SOURCE: `*`, `[`, `~~` in a + replacement become real formatting — escape with `\`. +- Deleting a parent block without `recursive: true`, or moving a block + into its own subtree — rejected with the reason; read the error. +- Filtering on file metadata without naming a file type — zero rows. + +## Not in v2 (yet) + +- **Object DELETE is own-output-only** — `DELETE …/objects/{id}` archives + only objects THIS key created (recorded at creation, immutably). Objects + created before that shipped, in the app, by import or by other members + are permanently 403 for every key — there is no "delete arbitrary + objects" capability. `?dry_run=true` is the cheap deletability probe; + types/properties still use their own DELETE routes. +- **No file byte download** under /v2 (Phase 8; bytes live on v1's + `GET /v1/spaces/{id}/files/{fileId}` — unreachable for space-scoped + keys, which /v1 refuses). No file content extraction ("read this PDF") + anywhere in the API. +- **No chat SSE stream** under /v2 and no per-chat message full-text + search (both v1 for now); poll `GET messages?limit=1` instead. +- `GET …/types/{key}/schema` is a 501 stub — compose from + `GET …/types/{key}` + `…/properties/{key}/options`. +- No option rename/recolor/delete under /v2 (v1 tags admin, Phase 8). diff --git a/core/api/v2/authz.go b/core/api/v2/authz.go new file mode 100644 index 0000000000..0f19c2621a --- /dev/null +++ b/core/api/v2/authz.go @@ -0,0 +1,260 @@ +package apiv2 + +// authz.go is the /v2 space-grant enforcement: the per-route authorization +// registry (read/write classification plus the global-route classes) and +// the ensureSpaceGrant middleware that applies a key's grant to every +// request. The registry is deliberately an explicit table, not an inference +// from the HTTP method or the path shape: classification is an +// authorization decision, and the conformance test in core/api/server pins +// that every registered route appears here — a new route that skips +// classification fails CI instead of shipping as a silent hole. + +import ( + "fmt" + "net/http" + + "github.com/gin-gonic/gin" + + "github.com/anyproto/anytype-heart/core/api/util" + v2model "github.com/anyproto/anytype-heart/core/api/v2/model" +) + +// SpaceParam is the ONE route-param name under which a /v2 route may +// address a space. The gate reads exactly this name; a space-addressing +// route registered under any other param (`:workspace_id`, `:spaceId`) +// would present an empty space id here and fall into the global branch, +// where the natural-looking classes wave it through with no space check at +// all. The conformance walk therefore refuses unknown param names outright. +const SpaceParam = "space_id" + +// RouteVerb classifies what a route DOES to data, not which HTTP method it +// uses: POST /v2/search and POST /v2/validate are reads that need a body; +// chat POST …/read is a write (it mutates the synced read watermark). +type RouteVerb string + +const ( + RouteVerbRead RouteVerb = "read" + RouteVerbWrite RouteVerb = "write" +) + +// GlobalRouteClass classifies a route that carries no :space_id param — +// the closed set of ways such a route may exist under a space-scoped grant. +type GlobalRouteClass string + +const ( + // GlobalAuthExempt marks routes served OUTSIDE the authenticated /v2 + // group (public documents); the gate never runs on them. Listed so the + // conformance walk stays a closed inventory — and the walk verifies the + // precondition behaviorally: a route with this class must answer a + // credential-less request, every other /v2 route must 401. A route + // registered INSIDE the authenticated group (an authenticated + // /v2/auth/* surface, say) can therefore never carry this class. + GlobalAuthExempt GlobalRouteClass = "auth-exempt" + // GlobalDataFreeAllow marks routes that touch no space data at all + // (schema/validation surfaces) — any granted key passes. + GlobalDataFreeAllow GlobalRouteClass = "data-free-allow" + // GlobalServiceFiltered marks routes whose handlers pick their own + // space set INSIDE the service (the fan-out surfaces). The gate lets + // them through; the service intersects its space set with the ctx + // grant (Service.ListSpaces, spaceRefs). + GlobalServiceFiltered GlobalRouteClass = "service-filtered" + // GlobalScopedDenied marks routes deliberately refused for every + // granted key: POST /v2/spaces — a key that can mint spaces it then + // owns is not meaningfully scoped. + GlobalScopedDenied GlobalRouteClass = "scoped-denied" +) + +// RouteAuthz is one route's authorization classification. Global is empty +// for space-scoped routes (those carry :space_id and are checked against +// the grant's space list). +type RouteAuthz struct { + Verb RouteVerb + Global GlobalRouteClass +} + +// routeKey builds the registry key for a route. +func routeKey(method, path string) string { + return method + " " + path +} + +// v2RouteAuthz classifies EVERY /v2 route. Kept in registration order of +// router.go so a diff of the two files reads side by side. +// +// The non-obvious verb calls, recorded: +// - POST /v2/validate and both search POSTs are READS: POST only because +// the request needs a body; nothing is persisted. +// - POST …/chats/:chat_id/read is a WRITE: it advances the synced read +// watermark that every device sees. +// - GET …/types/:type/schema is a read (it currently answers 501, and +// when it lands it stays a derived-artifact read). +// - POST /v2/spaces is a write AND scoped-denied: even a readwrite grant +// must not mint spaces outside its list. +// - GET /v2/auth/whoami is a read, classified service-filtered by +// reasoning, not by reflex: it is AUTHENTICATED (auth-exempt is +// impossible inside the gated group — the conformance walk enforces +// that behaviorally), it addresses no single space, and it discloses +// nothing the holder cannot already enumerate — the grant echo IS the +// enforcement boundary, and the space names in its body come from the +// service's own grant-intersected ListSpaces path, the exact pattern +// the service-filtered class names. data-free-allow would be wrong: +// the body carries space data (names). +var v2RouteAuthz = map[string]RouteAuthz{ + // core group (router.go RegisterRoutes) + routeKey(http.MethodGet, "/v2/auth/whoami"): {Verb: RouteVerbRead, Global: GlobalServiceFiltered}, + routeKey(http.MethodPost, "/v2/validate"): {Verb: RouteVerbRead, Global: GlobalDataFreeAllow}, + routeKey(http.MethodGet, "/v2/spaces"): {Verb: RouteVerbRead, Global: GlobalServiceFiltered}, + routeKey(http.MethodGet, "/v2/spaces/:space_id"): {Verb: RouteVerbRead}, + routeKey(http.MethodPost, "/v2/spaces"): {Verb: RouteVerbWrite, Global: GlobalScopedDenied}, + routeKey(http.MethodPatch, "/v2/spaces/:space_id"): {Verb: RouteVerbWrite}, + routeKey(http.MethodGet, "/v2/spaces/:space_id/objects"): {Verb: RouteVerbRead}, + routeKey(http.MethodGet, "/v2/spaces/:space_id/objects/:object_id"): {Verb: RouteVerbRead}, + routeKey(http.MethodGet, "/v2/spaces/:space_id/members"): {Verb: RouteVerbRead}, + routeKey(http.MethodGet, "/v2/spaces/:space_id/members/me"): {Verb: RouteVerbRead}, + routeKey(http.MethodGet, "/v2/spaces/:space_id/types"): {Verb: RouteVerbRead}, + routeKey(http.MethodGet, "/v2/spaces/:space_id/types/:type"): {Verb: RouteVerbRead}, + routeKey(http.MethodGet, "/v2/spaces/:space_id/types/:type/schema"): {Verb: RouteVerbRead}, + routeKey(http.MethodGet, "/v2/spaces/:space_id/properties"): {Verb: RouteVerbRead}, + routeKey(http.MethodGet, "/v2/spaces/:space_id/properties/:key/options"): {Verb: RouteVerbRead}, + routeKey(http.MethodPost, "/v2/search"): {Verb: RouteVerbRead, Global: GlobalServiceFiltered}, + routeKey(http.MethodPost, "/v2/spaces/:space_id/search"): {Verb: RouteVerbRead}, + routeKey(http.MethodGet, "/v2/spaces/:space_id/sets/:set_id/objects"): {Verb: RouteVerbRead}, + routeKey(http.MethodGet, "/v2/spaces/:space_id/sets/:set_id/views"): {Verb: RouteVerbRead}, + routeKey(http.MethodGet, "/v2/spaces/:space_id/collections/:collection_id/objects"): {Verb: RouteVerbRead}, + routeKey(http.MethodGet, "/v2/spaces/:space_id/collections/:collection_id/views"): {Verb: RouteVerbRead}, + routeKey(http.MethodGet, "/v2/schemas"): {Verb: RouteVerbRead, Global: GlobalDataFreeAllow}, + routeKey(http.MethodGet, "/v2/schemas/:kind"): {Verb: RouteVerbRead, Global: GlobalDataFreeAllow}, + routeKey(http.MethodGet, "/v2/schemas/ops/:op"): {Verb: RouteVerbRead, Global: GlobalDataFreeAllow}, + // create surface (registerCreateRoutes) + routeKey(http.MethodPost, "/v2/spaces/:space_id/objects"): {Verb: RouteVerbWrite}, + routeKey(http.MethodPost, "/v2/spaces/:space_id/templates"): {Verb: RouteVerbWrite}, + routeKey(http.MethodPost, "/v2/spaces/:space_id/types"): {Verb: RouteVerbWrite}, + routeKey(http.MethodPatch, "/v2/spaces/:space_id/types/:type"): {Verb: RouteVerbWrite}, + routeKey(http.MethodDelete, "/v2/spaces/:space_id/types/:type"): {Verb: RouteVerbWrite}, + routeKey(http.MethodPost, "/v2/spaces/:space_id/properties"): {Verb: RouteVerbWrite}, + routeKey(http.MethodPatch, "/v2/spaces/:space_id/properties/:key"): {Verb: RouteVerbWrite}, + routeKey(http.MethodDelete, "/v2/spaces/:space_id/properties/:key"): {Verb: RouteVerbWrite}, + routeKey(http.MethodPost, "/v2/spaces/:space_id/sets"): {Verb: RouteVerbWrite}, + routeKey(http.MethodPost, "/v2/spaces/:space_id/collections"): {Verb: RouteVerbWrite}, + routeKey(http.MethodPost, "/v2/spaces/:space_id/files"): {Verb: RouteVerbWrite}, + // edit surface (registerEditRoutes) + routeKey(http.MethodPatch, "/v2/spaces/:space_id/objects/:object_id"): {Verb: RouteVerbWrite}, + routeKey(http.MethodDelete, "/v2/spaces/:space_id/objects/:object_id"): {Verb: RouteVerbWrite}, + // chat surface (registerChatRoutes) + routeKey(http.MethodGet, "/v2/spaces/:space_id/chats"): {Verb: RouteVerbRead}, + routeKey(http.MethodPost, "/v2/spaces/:space_id/chats"): {Verb: RouteVerbWrite}, + routeKey(http.MethodGet, "/v2/spaces/:space_id/chats/:chat_id/messages"): {Verb: RouteVerbRead}, + routeKey(http.MethodPost, "/v2/spaces/:space_id/chats/:chat_id/messages"): {Verb: RouteVerbWrite}, + routeKey(http.MethodPatch, "/v2/spaces/:space_id/chats/:chat_id/messages/:message_id"): {Verb: RouteVerbWrite}, + routeKey(http.MethodDelete, "/v2/spaces/:space_id/chats/:chat_id/messages/:message_id"): {Verb: RouteVerbWrite}, + routeKey(http.MethodPost, "/v2/spaces/:space_id/chats/:chat_id/messages/:message_id/reactions"): {Verb: RouteVerbWrite}, + routeKey(http.MethodPost, "/v2/spaces/:space_id/chats/:chat_id/read"): {Verb: RouteVerbWrite}, + // public documents, registered on the engine root (server + // registerDocumentationRoutes) — the gate never sees them + routeKey(http.MethodGet, "/v2/docs/openapi.yaml"): {Verb: RouteVerbRead, Global: GlobalAuthExempt}, + routeKey(http.MethodGet, "/v2/docs/openapi.json"): {Verb: RouteVerbRead, Global: GlobalAuthExempt}, +} + +// RouteAuthzTable returns a copy of the authorization registry for the +// conformance test in core/api/server (both directions: every registered +// route classified, every classified route registered). +func RouteAuthzTable() map[string]RouteAuthz { + out := make(map[string]RouteAuthz, len(v2RouteAuthz)) + for key, authz := range v2RouteAuthz { + out[key] = authz + } + return out +} + +// neededPerms names the permission a route needs, for the RFC 6750 scope +// string and the 403 messages. +func neededPerms(verb RouteVerb) string { + if verb == RouteVerbWrite { + return util.GrantPermsReadWrite + } + return util.GrantPermsRead +} + +// ensureSpaceGrant enforces the key's space grant on every /v2 request. It +// runs directly after the key-scope gate (deps.KeyScope) and BEFORE the +// service's ensureSpace — which deliberately admits the tech space as an +// ordinary space id, so the tech space is denied here unless explicitly +// granted, like any other space. +// +// - Grant == nil → pass through: an unscoped/legacy key keeps today's +// behavior. (An EMPTY grant space list is not "all spaces": AllowsSpace +// denies everything then — see util.ApiGrant.) +// - :space_id present → it must be in the grant's space list, else 403 +// space_not_granted naming the grant. +// - no :space_id → the route must appear in v2RouteAuthz with an explicit +// global class; an UNREGISTERED route is refused, not allowed (fail +// closed — the conformance test makes that a CI failure before it can +// become a runtime 403). +// - Perms == read on a write-classified route → 403 write_not_granted. +// A route missing a verb classification counts as write (fail closed). +// +// The route middleware gives the clean 403; Service.ensureSpace consults +// the ctx grant again as the backstop for a future route that forgets this +// middleware or resolves ids unusually. +func ensureSpaceGrant() gin.HandlerFunc { + return func(c *gin.Context) { + grant := util.ApiGrantFromCtx(c.Request.Context()) + if grant == nil { + c.Next() + return + } + + authz, classified := v2RouteAuthz[routeKey(c.Request.Method, c.FullPath())] + spaceId := c.Param(SpaceParam) + if spaceId == "" { + if !classified || authz.Global == "" || authz.Global == GlobalScopedDenied { + c.Header(util.WwwAuthenticateHeader, util.BearerChallengeInsufficientScope("")) + respondV2Error(c, v2model.SpaceNotGranted(globalRouteRefusal(c, classified, authz))) + return + } + } else if !grant.AllowsSpace(spaceId) { + needed := neededPerms(effectiveVerb(authz, classified)) + c.Header(util.WwwAuthenticateHeader, + util.BearerChallengeInsufficientScope(util.SpaceScope(spaceId, needed))) + respondV2Error(c, v2model.SpaceNotGranted(fmt.Sprintf( + "key not granted space %q; granted: %s", spaceId, grant.Describe()))) + return + } + + if effectiveVerb(authz, classified) == RouteVerbWrite && !grant.CanWrite() { + // A global write route (allowed through the branch above) has no + // space to name: the challenge takes its empty-scope form rather + // than rendering a malformed "space::readwrite". + scope := "" + if spaceId != "" { + scope = util.SpaceScope(spaceId, util.GrantPermsReadWrite) + } + c.Header(util.WwwAuthenticateHeader, util.BearerChallengeInsufficientScope(scope)) + respondV2Error(c, v2model.WriteNotGranted(fmt.Sprintf( + "%s %s is a write and the key's grant is read-only; granted: %s", + c.Request.Method, c.FullPath(), grant.Describe()))) + return + } + c.Next() + } +} + +// effectiveVerb treats an unclassified route as a write: for a read-only +// grant the unknown route is then refused, and a widening can only come +// from an explicit registry entry. +func effectiveVerb(authz RouteAuthz, classified bool) RouteVerb { + if !classified { + return RouteVerbWrite + } + return authz.Verb +} + +// globalRouteRefusal words the 403 for a no-space route a scoped key cannot +// use — the deliberate deny (POST /v2/spaces) and the fail-closed default +// for a route the registry does not know. +func globalRouteRefusal(c *gin.Context, classified bool, authz RouteAuthz) string { + route := c.Request.Method + " " + c.FullPath() + if classified && authz.Global == GlobalScopedDenied { + return fmt.Sprintf("%s is not available to space-scoped keys: a key that can create spaces it then owns is not meaningfully scoped", route) + } + return fmt.Sprintf("%s addresses no single space and is not classified for space-scoped keys — refused fail-closed", route) +} diff --git a/core/api/v2/authz_test.go b/core/api/v2/authz_test.go new file mode 100644 index 0000000000..2d526a4c70 --- /dev/null +++ b/core/api/v2/authz_test.go @@ -0,0 +1,248 @@ +package apiv2 + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/anyproto/anytype-heart/core/api/util" +) + +// newGrantEngine builds a bare engine with a stub auth middleware carrying +// the given grant on the request context (the way server's +// ensureAuthenticated does) and the gate under test, then a set of probe +// routes mirroring the real registration shapes. +func newGrantEngine(grant *util.ApiGrant) *gin.Engine { + router := gin.New() + ok := func(c *gin.Context) { c.String(http.StatusOK, "OK") } + group := router.Group("/v2") + group.Use(func(c *gin.Context) { + c.Request = c.Request.WithContext(util.CtxWithApiGrant(c.Request.Context(), grant)) + c.Next() + }) + group.Use(ensureSpaceGrant()) + // registered probes (each present in v2RouteAuthz) + group.GET("/spaces/:space_id", ok) + group.GET("/spaces/:space_id/objects", ok) + group.POST("/spaces/:space_id/objects", ok) + group.POST("/spaces/:space_id/search", ok) + group.POST("/spaces/:space_id/chats/:chat_id/read", ok) + group.POST("/validate", ok) + group.GET("/schemas", ok) + group.GET("/spaces", ok) + group.POST("/search", ok) + group.POST("/spaces", ok) + // an UNREGISTERED no-space route: not in v2RouteAuthz — must be refused + // for any granted key, fail closed + group.GET("/bogus", ok) + // an unclassified space route: the verb gate must treat it as write + group.GET("/spaces/:space_id/bogus", ok) + return router +} + +func serveGrant(t *testing.T, grant *util.ApiGrant, method, path string) *httptest.ResponseRecorder { + t.Helper() + w := httptest.NewRecorder() + req := httptest.NewRequest(method, path, nil) + newGrantEngine(grant).ServeHTTP(w, req) + return w +} + +func readGrant(spaces ...string) *util.ApiGrant { + return &util.ApiGrant{Spaces: spaces, Perms: util.GrantPermsRead} +} + +func readWriteGrant(spaces ...string) *util.ApiGrant { + return &util.ApiGrant{Spaces: spaces, Perms: util.GrantPermsReadWrite} +} + +func TestEnsureSpaceGrant(t *testing.T) { + t.Run("a nil grant passes everywhere: legacy keys keep today's behavior", func(t *testing.T) { + for _, probe := range []struct{ method, path string }{ + {"GET", "/v2/spaces/space1"}, + {"POST", "/v2/spaces/space1/objects"}, + {"POST", "/v2/spaces"}, + {"GET", "/v2/bogus"}, + } { + w := serveGrant(t, nil, probe.method, probe.path) + require.Equal(t, http.StatusOK, w.Code, "%s %s", probe.method, probe.path) + } + }) + + t.Run("granted space passes", func(t *testing.T) { + w := serveGrant(t, readWriteGrant("space1", "space2"), "GET", "/v2/spaces/space2/objects") + require.Equal(t, http.StatusOK, w.Code) + }) + + t.Run("non-granted space is 403 space_not_granted naming the grant", func(t *testing.T) { + // when + w := serveGrant(t, readWriteGrant("space1"), "GET", "/v2/spaces/other/objects") + + // then + require.Equal(t, http.StatusForbidden, w.Code) + body := w.Body.String() + assert.Contains(t, body, `"space_not_granted"`) + assert.Contains(t, body, `key not granted space \"other\"`) + assert.Contains(t, body, "spaces [space1] with readwrite access") + assert.Equal(t, `Bearer error="insufficient_scope", scope="space:other:read"`, + w.Header().Get("WWW-Authenticate")) + }) + + t.Run("the WWW-Authenticate scope names readwrite when the denied route is a write", func(t *testing.T) { + w := serveGrant(t, readWriteGrant("space1"), "POST", "/v2/spaces/other/objects") + require.Equal(t, http.StatusForbidden, w.Code) + assert.Equal(t, `Bearer error="insufficient_scope", scope="space:other:readwrite"`, + w.Header().Get("WWW-Authenticate")) + }) + + t.Run("an EMPTY granted-space list denies every space — never all spaces", func(t *testing.T) { + // persist-time validation makes an empty list impossible; if one is + // ever encountered the gate must deny, not widen + w := serveGrant(t, &util.ApiGrant{Spaces: []string{}, Perms: util.GrantPermsReadWrite}, "GET", "/v2/spaces/space1") + require.Equal(t, http.StatusForbidden, w.Code) + assert.Contains(t, w.Body.String(), `"space_not_granted"`) + }) + + t.Run("a read grant is refused on writes with 403 write_not_granted", func(t *testing.T) { + tests := []struct{ method, path string }{ + {"POST", "/v2/spaces/space1/objects"}, + // chat read watermark: POST …/read is classified WRITE — it + // mutates the synced read state every device sees + {"POST", "/v2/spaces/space1/chats/chat1/read"}, + } + for _, tt := range tests { + t.Run(tt.method+" "+tt.path, func(t *testing.T) { + w := serveGrant(t, readGrant("space1"), tt.method, tt.path) + require.Equal(t, http.StatusForbidden, w.Code) + body := w.Body.String() + assert.Contains(t, body, `"write_not_granted"`) + assert.Contains(t, body, "read-only") + assert.Contains(t, body, "spaces [space1] with read access") + assert.Equal(t, `Bearer error="insufficient_scope", scope="space:space1:readwrite"`, + w.Header().Get("WWW-Authenticate")) + }) + } + }) + + t.Run("a read grant passes on reads, POST search and validate included", func(t *testing.T) { + for _, probe := range []struct{ method, path string }{ + {"GET", "/v2/spaces/space1"}, + {"GET", "/v2/spaces/space1/objects"}, + // POST only because the request needs a body — classified READ + {"POST", "/v2/spaces/space1/search"}, + {"POST", "/v2/validate"}, + {"GET", "/v2/schemas"}, + // service-filtered: allowed through the gate, constrained in the + // service layer + {"GET", "/v2/spaces"}, + {"POST", "/v2/search"}, + } { + w := serveGrant(t, readGrant("space1"), probe.method, probe.path) + require.Equal(t, http.StatusOK, w.Code, "%s %s", probe.method, probe.path) + } + }) + + t.Run("POST /v2/spaces is refused for every granted key, readwrite included", func(t *testing.T) { + // a key that can mint spaces it then owns is not meaningfully scoped + w := serveGrant(t, readWriteGrant("space1"), "POST", "/v2/spaces") + require.Equal(t, http.StatusForbidden, w.Code) + body := w.Body.String() + assert.Contains(t, body, `"space_not_granted"`) + assert.Contains(t, body, "not available to space-scoped keys") + assert.Equal(t, `Bearer error="insufficient_scope"`, w.Header().Get("WWW-Authenticate")) + }) + + t.Run("an unregistered no-space route is refused, not allowed", func(t *testing.T) { + // fail closed: the registry is the allowlist; the conformance test + // turns this runtime 403 into a CI failure before it can ship + w := serveGrant(t, readWriteGrant("space1"), "GET", "/v2/bogus") + require.Equal(t, http.StatusForbidden, w.Code) + body := w.Body.String() + assert.Contains(t, body, `"space_not_granted"`) + assert.Contains(t, body, "not classified for space-scoped keys") + }) + + t.Run("an unclassified space route counts as write for a read grant", func(t *testing.T) { + // the verb table is the allowlist too: no entry → write → refused + // for read-only grants + w := serveGrant(t, readGrant("space1"), "GET", "/v2/spaces/space1/bogus") + require.Equal(t, http.StatusForbidden, w.Code) + assert.Contains(t, w.Body.String(), `"write_not_granted"`) + }) + + t.Run("a global write route yields the scope-less insufficient_scope challenge", func(t *testing.T) { + // unreachable through today's registry (the only global write is + // scoped-denied and refused earlier), but the moment a global route + // is classified write the challenge must take its empty-scope form, + // never the malformed `scope="space::readwrite"` + key := routeKey(http.MethodPost, "/v2/global-write") + v2RouteAuthz[key] = RouteAuthz{Verb: RouteVerbWrite, Global: GlobalDataFreeAllow} + defer delete(v2RouteAuthz, key) + + router := gin.New() + group := router.Group("/v2") + group.Use(func(c *gin.Context) { + c.Request = c.Request.WithContext(util.CtxWithApiGrant(c.Request.Context(), readGrant("space1"))) + c.Next() + }) + group.Use(ensureSpaceGrant()) + group.POST("/global-write", func(c *gin.Context) { c.String(http.StatusOK, "OK") }) + + w := httptest.NewRecorder() + router.ServeHTTP(w, httptest.NewRequest(http.MethodPost, "/v2/global-write", nil)) + + require.Equal(t, http.StatusForbidden, w.Code) + assert.Contains(t, w.Body.String(), `"write_not_granted"`) + assert.Equal(t, `Bearer error="insufficient_scope"`, w.Header().Get("WWW-Authenticate")) + }) + + t.Run("the tech space is denied unless explicitly granted", func(t *testing.T) { + // this gate runs BEFORE the service's ensureSpace, which admits the + // tech space as an ordinary space id — so a grant without it must be + // stopped here + w := serveGrant(t, readWriteGrant("space1"), "GET", "/v2/spaces/techSpace1/objects") + require.Equal(t, http.StatusForbidden, w.Code) + assert.Contains(t, w.Body.String(), `"space_not_granted"`) + + granted := serveGrant(t, readWriteGrant("space1", "techSpace1"), "GET", "/v2/spaces/techSpace1/objects") + require.Equal(t, http.StatusOK, granted.Code) + }) +} + +func TestV2RouteAuthzTable(t *testing.T) { + t.Run("every no-space entry carries an explicit global class", func(t *testing.T) { + for key, authz := range RouteAuthzTable() { + // the registry key is "METHOD /path" + if !containsSpaceParam(key) { + assert.NotEmpty(t, authz.Global, "%s has no :space_id and must carry a global class", key) + } else { + assert.Empty(t, authz.Global, "%s is space-scoped and must not carry a global class", key) + } + } + }) + + t.Run("the non-obvious verb calls hold", func(t *testing.T) { + want := map[string]RouteVerb{ + "POST /v2/validate": RouteVerbRead, + "POST /v2/search": RouteVerbRead, + "POST /v2/spaces/:space_id/search": RouteVerbRead, + "POST /v2/spaces/:space_id/chats/:chat_id/read": RouteVerbWrite, + "POST /v2/spaces": RouteVerbWrite, + } + table := RouteAuthzTable() + for key, verb := range want { + entry, ok := table[key] + require.True(t, ok, "%s must be classified", key) + assert.Equal(t, verb, entry.Verb, key) + } + }) +} + +func containsSpaceParam(routeKey string) bool { + return strings.Contains(routeKey, "/:"+SpaceParam+"/") || strings.HasSuffix(routeKey, "/:"+SpaceParam) +} diff --git a/core/api/v2/doc.go b/core/api/v2/doc.go new file mode 100644 index 0000000000..b2b3addc52 --- /dev/null +++ b/core/api/v2/doc.go @@ -0,0 +1,68 @@ +// Package apiv2 registers and serves the Anytype local API v2. +// +// doc.go carries nothing but the OpenAPI general-info block for the v2 +// document. `make openapi` runs swag twice — once over core/api with +// core/api/v2 excluded (the v1 document, general info in core/api/service.go) +// and once over core/api/v2 with this file as -g. Keeping the two blocks in +// two packages is why a v2 reader never scrolls past a v1 endpoint. +// +// @title Anytype API v2 +// @version 2025-11-08 +// @description The agent-oriented Anytype local API. An object is one AnyBlock JSON document rather than a tree of blocks, so a single GET returns a whole editable document and a single PATCH edits it. +// @description Everything this API names for itself is snake_case: path and query parameters, request and response fields, and the names of the PATCH ops. Things are addressed by name rather than by id: property keys, option names, and `type` as a type key. Inside an object's `blocks` and `properties` you are reading the AnyBlock format's own vocabulary, which passes through unchanged. +// @description Responses are compact. A list or search row carries id, name, type and the properties you asked for, and never embeds a type object. Object references are always full and inline. An object read relabels machine-minted block ids to short document-local suffixes; `?ids=full` returns the export shape, with full ids everywhere, which is the shape to store and the shape to clone from. +// @description Wherever a block or a view is addressed by id, a full id or a unique suffix of one is accepted. That is what lets a document read back in the compact shape be edited exactly as it came back. A suffix that matches several elements is refused, and the refusal lists the candidates. +// @description A read never fails on content it cannot represent. Whatever a representation cannot express is reported in `warnings` beside the result. +// @description Every error has one shape: {status, code, message, issues:[{path, message, hint}]}. Each issue is addressed by path and names the values that would have been accepted, so a failed call tells you how to repair it. +// @description Authentication is a bearer token in the Authorization header. It is never read from a query or body parameter. An unknown, revoked or expired key is a 401. +// @description An object read returns an `etag` in the body and an ETag header. A mutation takes that etag back in `If-Match`, where it is advisory: without the header the last write wins, and a stale one is a 409 carrying the current etag. Chats are the exception. They have no etag, because their order ids and `last_state_id` do that job. +// @description Every mutation accepts an `Idempotency-Key` header. The same key with the same body replays the stored response instead of repeating the write. Search is a read carried by POST and takes no key. +// @description Every mutation accepts `?dry_run=true`. It validates the request, reports what would have happened and writes nothing, answering 200 where the real call would answer 201. Where a dry run cannot tell the whole truth, the operation says so. +// @description Every list is paginated with `?offset=` and `?limit=`, 25 rows by default. The response carries `total`, `has_more` and, when it truncated, a hint for narrowing the request. Chat messages page by order-id cursor instead. +// @description Request bodies bind strictly: an unknown field is a 400 naming the field, never a value silently dropped. A document body is capped at 10 MiB, a structured body at 1 MiB. +// @description Deleting an object, a type or a property archives it: it moves to Bin, and the Anytype app can restore it. Deleting a chat message is not an archive, and neither is the attachment cleanup that can follow it. +// @description Schemas are discoverable at runtime, and strict enough to decode against: GET /v2/schemas lists the kinds, GET /v2/schemas/{kind} returns one, and GET /v2/schemas/ops/{op} returns the schema of a single edit op. +// @description A space is served by a short reference: the last six characters of the first half of its id. Every route that takes a space accepts either that short reference or the full `.` id. Resolution tries an exact id first, then a unique suffix, among the spaces the key can see. An ambiguous reference is a 400 listing the candidates, and two spaces whose tails collide are both served in full. +// @description A short reference is an addressing convenience, not a stable identifier. It is unique only against the spaces the key can currently see, so joining a space whose tail collides retires it. `?ids=full` spells every space id in the response out in full. Use it whenever a reference will be stored outside this API: a config file, a script, a log line, another system. +// @tag.name Auth +// @tag.description What the calling key may do. Ask this before discovering the limits through 403s. +// @tag.name Spaces +// @tag.description The containers everything else lives in. Nearly every other route is scoped to one. +// @tag.name Objects +// @tag.description Read and write whole AnyBlock documents: one GET returns an editable document, one PATCH edits it. +// @tag.name Search +// @tag.description Find objects by query, filter and sort, within one space or across all of them. +// @tag.name Types +// @tag.description An object's shape: the properties it recommends and the views it opens with. +// @tag.name Properties +// @tag.description The typed key-value fields objects carry, and the option vocabularies select fields draw from. +// @tag.name Lists +// @tag.description Sets (a live query over a type) and collections (a hand-curated list), with their views. +// @tag.name Chat +// @tag.description Messages, reactions and read state. Chats store messages outside blocks, paged by order-id cursors. +// @tag.name Members +// @tag.description Who is in a space, and which of them you are. +// @tag.name Files +// @tag.description Upload bytes and get the id that file blocks and chat attachments reference. +// @tag.name Templates +// @tag.description Starting documents for a type. +// @tag.name Schemas +// @tag.description The format itself: what a valid document looks like, what each PATCH op accepts, and a validator to check one against them. Read these before writing. +// @termsOfService https://anytype.io/terms_of_use +// @contact.name Anytype Support +// @contact.url https://anytype.io/contact +// @contact.email support@anytype.io +// @license.name Any Source Available License 1.0 +// @license.url https://github.com/anyproto/anytype-api/blob/main/LICENSE.md +// @host http://127.0.0.1:31009 +// @securitydefinitions.bearerauth BearerAuth +// @externalDocs.description OpenAPI +// @externalDocs.url https://swagger.io/resources/open-api/ +// +// The version above is deliberately the same date as v1's: it is the value of +// the `Anytype-Version` response header (server.ApiVersion), which one gin +// engine sets for both route groups (C1). Bump all three together or none. +// (Nothing below the annotation block may start a line with an at-sign — +// swag reads every comment group in this file and would take it as an +// attribute.) +package apiv2 diff --git a/core/api/v2/handler/chat.go b/core/api/v2/handler/chat.go new file mode 100644 index 0000000000..de4099727f --- /dev/null +++ b/core/api/v2/handler/chat.go @@ -0,0 +1,311 @@ +package v2handler + +// chat.go holds the Phase-6 chat handlers (APIV2.md §8.7). Message text +// is §8 inline markup in BOTH directions (the D′1 caveat applies verbatim: +// find/replace-style specials mint real marks). C7 etag/If-Match does NOT +// apply to chats — order ids and last_state_id are the stream's native +// concurrency vocabulary; this is a deliberate, documented exemption like +// search's C8/C9 one. All mutations honor Idempotency-Key (C8) and +// ?dry_run=true (C9). + +import ( + "fmt" + "net/http" + + "github.com/gin-gonic/gin" + + "github.com/anyproto/anytype-heart/core/api/pagination" + v2model "github.com/anyproto/anytype-heart/core/api/v2/model" + v2service "github.com/anyproto/anytype-heart/core/api/v2/service" +) + +// maxChatRequestBody caps chat mutation bodies. A message is text (≤ the +// middleware's message-length cap) plus a bounded attachment list — 1 MiB +// is orders of magnitude above any legitimate body. +const maxChatRequestBody = 1 << 20 // 1 MiB + +// decodeChatBody decodes a chat request body strictly (unknown fields are +// 400s with the field named — C13's spirit at the request layer, matching +// search). A false return means the error response was already written. +func decodeChatBody(c *gin.Context, into any, hint string) bool { + return decodeStrictJSONBody(c, into, hint, maxChatRequestBody, "chat") +} + +// respondChatMutation writes a chat mutation result: createdStatus on a real +// mutation, 200 on dry runs. +func respondChatMutation(c *gin.Context, dryRun bool, createdStatus int, payload any) { + status := createdStatus + if dryRun { + status = http.StatusOK + } + c.JSON(status, payload) +} + +// ListChatsHandler lists the space's chats as C5 rows +// +// @Summary List the chats in a space +// @Description A row carries no unread counters. Per-chat unread state comes back with the messages read instead. +// @Id list_chats +// @Tags Chat +// @Produce json +// @Param space_id path string true "Space id" +// @Param offset query int false "Rows to skip" default(0) +// @Param limit query int false "Rows to return" default(25) +// @Success 200 {object} v2model.ListResponse[v2model.ChatRow] "Chat rows" +// @Failure 404 {object} v2model.Error "Space not found" +// @Security bearerauth +// @Router /v2/spaces/{space_id}/chats [get] +func ListChatsHandler(s *v2service.Service) gin.HandlerFunc { + return func(c *gin.Context) { + offset := c.GetInt(pagination.QueryParamOffset) + limit := c.GetInt(pagination.QueryParamLimit) + rows, total, hasMore, err := s.ListChats(c.Request.Context(), c.Param("space_id"), offset, limit) + if err != nil { + RespondError(c, err) + return + } + c.JSON(http.StatusOK, v2model.NewListResponse(rows, total, offset, limit, hasMore, + "request the next offset")) + } +} + +// CreateChatHandler creates a chat +// +// @Summary Create a chat +// @Description Messages are not blocks. Add them through the messages route; a document edit cannot reach them. +// @Id create_chat +// @Tags Chat +// @Accept json +// @Produce json +// @Param space_id path string true "Space id" +// @Param dry_run query bool false "Validate and report without committing" +// @Param Idempotency-Key header string false "Replay guard: the same key with the same body replays the stored response" +// @Param request body v2model.CreateChatRequest true "The chat to create" +// @Success 201 {object} v2model.ChatResult "Created chat row" +// @Failure 400 {object} v2model.Error "Validation failure" +// @Security bearerauth +// @Router /v2/spaces/{space_id}/chats [post] +func CreateChatHandler(s *v2service.Service) gin.HandlerFunc { + return func(c *gin.Context) { + var req v2model.CreateChatRequest + if !decodeChatBody(c, &req, "the chat body takes name") { + return + } + result, err := s.CreateChat(c.Request.Context(), c.Param("space_id"), req, isV2DryRun(c)) + if err != nil { + RespondError(c, err) + return + } + respondChatMutation(c, result.DryRun, http.StatusCreated, result) + } +} + +// GetChatMessagesHandler reads messages with the state passthrough +// +// @Summary List chat messages +// @Description `after` on its own walks forward, oldest first, continuing from `next_after`. Every other query, including `after` together with `before`, is anchored at the newest end of the range and walks backward from `next_before`. Both bounds are exclusive. `message_count` is the chat's total since it began, not the size of the range. Offset paging does not apply here, and `offset` is refused. +// @Id get_chat_messages +// @Tags Chat +// @Produce json +// @Param space_id path string true "Space id" +// @Param chat_id path string true "Chat object id" +// @Param after query string false "Return messages after this order id (exclusive)" +// @Param before query string false "Return messages before this order id (exclusive)" +// @Param limit query int false "Messages to return" default(25) +// @Param reactions query string false "counts (default) returns the emoji counts; full adds the participant ids behind each count" +// @Success 200 {object} v2model.ChatMessagesResponse "Messages + state + message_count" +// @Failure 400 {object} v2model.Error "Not a chat, or invalid params" +// @Failure 404 {object} v2model.Error "Chat not found" +// @Security bearerauth +// @Router /v2/spaces/{space_id}/chats/{chat_id}/messages [get] +func GetChatMessagesHandler(s *v2service.Service) gin.HandlerFunc { + return func(c *gin.Context) { + if c.Query("offset") != "" { + RespondError(c, v2model.ValidationFailed("offset does not apply to the messages read", + v2model.Issue{Path: "offset", Message: "messages are cursor-paged", Hint: "page with ?after= / ?before= order ids from a previous read"})) + return + } + fullReactions := false + switch c.Query("reactions") { + case "", v2model.ReactionsCounts: + case v2model.ReactionsFull: + fullReactions = true + default: + RespondError(c, v2model.ValidationFailed("invalid reactions value", + v2model.Issue{Path: "reactions", Message: fmt.Sprintf("unknown value %q", c.Query("reactions")), Hint: "allowed: counts, full"})) + return + } + result, err := s.GetChatMessages(c.Request.Context(), c.Param("space_id"), c.Param("chat_id"), v2service.ChatMessagesQuery{ + After: c.Query("after"), + Before: c.Query("before"), + Limit: c.GetInt(pagination.QueryParamLimit), + FullReactions: fullReactions, + }) + if err != nil { + RespondError(c, err) + return + } + c.JSON(http.StatusOK, result) + } +} + +// AddChatMessageHandler sends a message +// +// @Summary Send a chat message +// @Description The text is markup source, so `*`, `[` and a mention tag mint real marks; escape a literal one with a backslash. The cap is 8000 UTF-16 code units, where one emoji can cost two or more. Attachments are object ids, at most 32, and each one's kind is taken from the target's layout. +// @Id add_chat_message +// @Tags Chat +// @Accept json +// @Produce json +// @Param space_id path string true "Space id" +// @Param chat_id path string true "Chat object id" +// @Param dry_run query bool false "Validate and report without committing" +// @Param Idempotency-Key header string false "Replay guard: the same key with the same body replays the stored response" +// @Param request body v2model.AddChatMessageRequest true "The message to send" +// @Success 201 {object} v2model.ChatMessageResult "Created message id" +// @Failure 400 {object} v2model.Error "Validation failure" +// @Failure 404 {object} v2model.Error "Chat not found" +// @Security bearerauth +// @Router /v2/spaces/{space_id}/chats/{chat_id}/messages [post] +func AddChatMessageHandler(s *v2service.Service) gin.HandlerFunc { + return func(c *gin.Context) { + var req v2model.AddChatMessageRequest + if !decodeChatBody(c, &req, "the message body takes text, reply_to, attachments") { + return + } + result, err := s.AddChatMessage(c.Request.Context(), c.Param("space_id"), c.Param("chat_id"), req, isV2DryRun(c)) + if err != nil { + RespondError(c, err) + return + } + respondChatMutation(c, result.DryRun, http.StatusCreated, result) + } +} + +// EditChatMessageHandler edits a message's text +// +// @Summary Replace a chat message's text +// @Description Every mark is re-derived from the text you send, so a mark the old text carried and the new text does not spell out is lost. Attachments, the reply target and the style survive. Editing another member's message is a 403. +// @Id edit_chat_message +// @Tags Chat +// @Accept json +// @Produce json +// @Param space_id path string true "Space id" +// @Param chat_id path string true "Chat object id" +// @Param message_id path string true "Message id" +// @Param dry_run query bool false "Validate and report without committing" +// @Param Idempotency-Key header string false "Replay guard: the same key with the same body replays the stored response" +// @Param request body v2model.EditChatMessageRequest true "The replacement text" +// @Success 200 {object} v2model.ChatMessageResult "Edited message id" +// @Failure 400 {object} v2model.Error "Validation failure" +// @Failure 404 {object} v2model.Error "Chat or message not found" +// @Security bearerauth +// @Router /v2/spaces/{space_id}/chats/{chat_id}/messages/{message_id} [patch] +func EditChatMessageHandler(s *v2service.Service) gin.HandlerFunc { + return func(c *gin.Context) { + var req v2model.EditChatMessageRequest + if !decodeChatBody(c, &req, "the edit body takes text") { + return + } + result, err := s.EditChatMessage(c.Request.Context(), c.Param("space_id"), c.Param("chat_id"), c.Param("message_id"), req, isV2DryRun(c)) + if err != nil { + RespondError(c, err) + return + } + respondChatMutation(c, result.DryRun, http.StatusOK, result) + } +} + +// DeleteChatMessageHandler deletes a message +// +// @Summary Delete a chat message +// @Description An attachment whose only reference was this message is erased for good afterwards, not moved to Bin. The response names those ids in `warnings`, and a dry run reports the same list without deleting anything. A message that does not exist is a 404 on the dry run too. +// @Id delete_chat_message +// @Tags Chat +// @Produce json +// @Param space_id path string true "Space id" +// @Param chat_id path string true "Chat object id" +// @Param message_id path string true "Message id" +// @Param dry_run query bool false "Report what would be deleted, attachments included, without committing" +// @Param Idempotency-Key header string false "Replay guard: the same key with the same body replays the stored response" +// @Success 200 {object} v2model.ChatMessageResult "Deleted message id" +// @Failure 404 {object} v2model.Error "Chat or message not found" +// @Security bearerauth +// @Router /v2/spaces/{space_id}/chats/{chat_id}/messages/{message_id} [delete] +func DeleteChatMessageHandler(s *v2service.Service) gin.HandlerFunc { + return func(c *gin.Context) { + result, err := s.DeleteChatMessage(c.Request.Context(), c.Param("space_id"), c.Param("chat_id"), c.Param("message_id"), isV2DryRun(c)) + if err != nil { + RespondError(c, err) + return + } + c.JSON(http.StatusOK, result) + } +} + +// ToggleChatReactionHandler toggles a reaction +// +// @Summary Toggle a reaction on a chat message +// @Description `added` says which way the toggle went. A dry run predicts it, but when there is no account identity to predict with it omits the field and says so in `warnings`. +// @Id toggle_chat_reaction +// @Tags Chat +// @Accept json +// @Produce json +// @Param space_id path string true "Space id" +// @Param chat_id path string true "Chat object id" +// @Param message_id path string true "Message id" +// @Param dry_run query bool false "Report the would-be outcome without committing" +// @Param Idempotency-Key header string false "Replay guard: the same key with the same body replays the stored response" +// @Param request body v2model.ChatReactionRequest true "The emoji to toggle" +// @Success 200 {object} v2model.ChatReactionResult "Toggle outcome" +// @Failure 400 {object} v2model.Error "Validation failure" +// @Failure 404 {object} v2model.Error "Chat or message not found" +// @Security bearerauth +// @Router /v2/spaces/{space_id}/chats/{chat_id}/messages/{message_id}/reactions [post] +func ToggleChatReactionHandler(s *v2service.Service) gin.HandlerFunc { + return func(c *gin.Context) { + var req v2model.ChatReactionRequest + if !decodeChatBody(c, &req, "the reaction body takes emoji") { + return + } + result, err := s.ToggleChatReaction(c.Request.Context(), c.Param("space_id"), c.Param("chat_id"), c.Param("message_id"), req, isV2DryRun(c)) + if err != nil { + RespondError(c, err) + return + } + c.JSON(http.StatusOK, result) + } +} + +// ReadChatHandler moves the read watermark +// +// @Summary Move a chat's read watermark +// @Description `up_to` is inclusive, and it and `last_state_id` both come from one messages read: the newest message's order, and the state's own id. An empty value for either would silently mark nothing, so it is refused. Messages that arrived after that state stay unread. The reactions scope marks every unread reaction and takes neither field. +// @Id read_chat +// @Tags Chat +// @Accept json +// @Produce json +// @Param space_id path string true "Space id" +// @Param chat_id path string true "Chat object id" +// @Param dry_run query bool false "Validate and report without committing" +// @Param Idempotency-Key header string false "Replay guard: the same key with the same body replays the stored response" +// @Param request body v2model.ChatReadRequest true "The watermark move" +// @Success 200 {object} v2model.ChatReadResult "Watermark moved" +// @Failure 400 {object} v2model.Error "Validation failure" +// @Failure 404 {object} v2model.Error "Chat not found" +// @Security bearerauth +// @Router /v2/spaces/{space_id}/chats/{chat_id}/read [post] +func ReadChatHandler(s *v2service.Service) gin.HandlerFunc { + return func(c *gin.Context) { + var req v2model.ChatReadRequest + if !decodeChatBody(c, &req, "the read body takes up_to, last_state_id, scope") { + return + } + result, err := s.ReadChat(c.Request.Context(), c.Param("space_id"), c.Param("chat_id"), req, isV2DryRun(c)) + if err != nil { + RespondError(c, err) + return + } + c.JSON(http.StatusOK, result) + } +} diff --git a/core/api/v2/handler/chat_test.go b/core/api/v2/handler/chat_test.go new file mode 100644 index 0000000000..3bec06164f --- /dev/null +++ b/core/api/v2/handler/chat_test.go @@ -0,0 +1,234 @@ +package v2handler + +// chat_test.go covers the Phase-6 chat HTTP layer — the review +// found the whole layer untested: ?reactions= and dry_run could be broken +// with every suite green, and dry_run=true regressing silently would SEND A +// REAL MESSAGE into the user's chat. Each case here pins one behavior the +// swagger text and §8.7 assert. + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + + "github.com/anyproto/anytype-heart/core/api/pagination" + v2model "github.com/anyproto/anytype-heart/core/api/v2/model" + "github.com/anyproto/anytype-heart/pb" + "github.com/anyproto/anytype-heart/pkg/lib/bundle" + "github.com/anyproto/anytype-heart/pkg/lib/localstore/objectstore" + "github.com/anyproto/anytype-heart/pkg/lib/pb/model" + + "github.com/anyproto/anytype-heart/core/domain" +) + +// chatRouterFixture mounts every chat route with the C10 pagination +// middleware (the handlers read the parsed limit from the context) and +// registers one chat object in space1. +func chatRouterFixture(t *testing.T) *v2HandlerFixture { + fx := newV2HandlerFixture(t) + fx.store.AddObjects(t, "space1", []objectstore.TestObject{{ + bundle.RelationKeyId: domain.String("chat1"), + bundle.RelationKeyName: domain.String("Team chat"), + bundle.RelationKeyResolvedLayout: domain.Int64(int64(model.ObjectType_chatDerived)), + }}) + fx.router.Use(pagination.New(pagination.Config{DefaultPage: 0, DefaultPageSize: 25, MinPageSize: 1, MaxPageSize: 1000})) + fx.router.Use(withDryRunFlag()) + fx.router.GET("/v2/spaces/:space_id/chats/:chat_id/messages", GetChatMessagesHandler(fx.svc)) + fx.router.POST("/v2/spaces/:space_id/chats", CreateChatHandler(fx.svc)) + fx.router.POST("/v2/spaces/:space_id/chats/:chat_id/messages", AddChatMessageHandler(fx.svc)) + fx.router.PATCH("/v2/spaces/:space_id/chats/:chat_id/messages/:message_id", EditChatMessageHandler(fx.svc)) + fx.router.DELETE("/v2/spaces/:space_id/chats/:chat_id/messages/:message_id", DeleteChatMessageHandler(fx.svc)) + fx.router.POST("/v2/spaces/:space_id/chats/:chat_id/messages/:message_id/reactions", ToggleChatReactionHandler(fx.svc)) + fx.router.POST("/v2/spaces/:space_id/chats/:chat_id/read", ReadChatHandler(fx.svc)) + return fx +} + +func chatHandlerTestMessage() *model.ChatMessage { + return &model.ChatMessage{ + Id: "msg1", + OrderId: "00a1", + Creator: "identityA", + Message: &model.ChatMessageMessageContent{Text: "hello"}, + Reactions: &model.ChatMessageReactions{ + Reactions: map[string]*model.ChatMessageReactionsIdentityList{ + "👍": {Ids: []string{"identityA", "identityB"}}, + }, + }, + } +} + +func serveChat(fx *v2HandlerFixture, method, target, body string) *httptest.ResponseRecorder { + var reader *strings.Reader + if body == "" { + reader = strings.NewReader("") + } else { + reader = strings.NewReader(body) + } + req := httptest.NewRequest(method, target, reader) + w := httptest.NewRecorder() + fx.router.ServeHTTP(w, req) + return w +} + +func TestGetChatMessagesV2HandlerQueryPlumbing(t *testing.T) { + t.Run("?reactions=full reaches the service — reacted_by appears, counts keep their slot", func(t *testing.T) { + // given + fx := chatRouterFixture(t) + fx.mwMock.EXPECT().ChatGetMessages(mock.Anything, mock.Anything). + Return(&pb.RpcChatGetMessagesResponse{Messages: []*model.ChatMessage{chatHandlerTestMessage()}}).Times(2) + + // when + wFull := serveChat(fx, "GET", "/v2/spaces/space1/chats/chat1/messages?reactions=full", "") + wDefault := serveChat(fx, "GET", "/v2/spaces/space1/chats/chat1/messages", "") + + // then + require.Equal(t, http.StatusOK, wFull.Code) + assert.Contains(t, wFull.Body.String(), `"reacted_by"`, + "?reactions=full must plumb through to the DTO — the whole Q4 feature dies silently otherwise") + require.Equal(t, http.StatusOK, wDefault.Code) + assert.NotContains(t, wDefault.Body.String(), `"reacted_by"`) + assert.Contains(t, wDefault.Body.String(), `"reactions":{"👍":2}`) + }) + + t.Run("?reactions=garbage is a 400 naming the allowed values", func(t *testing.T) { + // given: no RPC expectation + fx := chatRouterFixture(t) + + // when + w := serveChat(fx, "GET", "/v2/spaces/space1/chats/chat1/messages?reactions=wat", "") + + // then + require.Equal(t, http.StatusBadRequest, w.Code) + assert.Contains(t, w.Body.String(), "counts, full") + }) + + t.Run("?after and ?limit reach the RPC — with the has_more +1", func(t *testing.T) { + // given + fx := chatRouterFixture(t) + fx.mwMock.EXPECT().ChatGetMessages(mock.Anything, mock.MatchedBy(func(req *pb.RpcChatGetMessagesRequest) bool { + return req.AfterOrderId == "0090" && req.Limit == 3 + })).Return(&pb.RpcChatGetMessagesResponse{}) + + // when + w := serveChat(fx, "GET", "/v2/spaces/space1/chats/chat1/messages?after=0090&limit=2", "") + + // then + require.Equal(t, http.StatusOK, w.Code) + }) + + t.Run("?offset is rejected with cursor steering", func(t *testing.T) { + fx := chatRouterFixture(t) + w := serveChat(fx, "GET", "/v2/spaces/space1/chats/chat1/messages?offset=5", "") + require.Equal(t, http.StatusBadRequest, w.Code) + assert.Contains(t, w.Body.String(), "cursor") + }) +} + +func TestChatMutationDryRunPlumbing(t *testing.T) { + // Every subtest registers NO mutation RPC expectation: were dry_run + // dropped from the handler → service call, the strict mock would fail + // the test AND the status would flip — the exact break the review + // performed with every suite staying green. + + t.Run("POST message with dry_run sends NOTHING and answers 200, without dry_run commits with 201", func(t *testing.T) { + // given + fx := chatRouterFixture(t) + + // when: dry run — no ChatAddMessage expectation, a send would fail + wDry := serveChat(fx, "POST", "/v2/spaces/space1/chats/chat1/messages?dry_run=true", `{"text":"hi"}`) + + // then + require.Equal(t, http.StatusOK, wDry.Code, "a dry run is not a create — 200, not 201") + assert.Contains(t, wDry.Body.String(), `"dry_run":true`) + + // and when: the real send + fx.mwMock.EXPECT().ChatAddMessage(mock.Anything, mock.Anything). + Return(&pb.RpcChatAddMessageResponse{MessageId: "msgNew"}) + wReal := serveChat(fx, "POST", "/v2/spaces/space1/chats/chat1/messages", `{"text":"hi"}`) + + // then + require.Equal(t, http.StatusCreated, wReal.Code) + assert.Contains(t, wReal.Body.String(), `"id":"msgNew"`) + }) + + t.Run("POST chat with dry_run creates nothing and answers 200", func(t *testing.T) { + fx := chatRouterFixture(t) + w := serveChat(fx, "POST", "/v2/spaces/space1/chats?dry_run=true", `{"name":"New chat"}`) + require.Equal(t, http.StatusOK, w.Code) + assert.Contains(t, w.Body.String(), `"dry_run":true`) + }) + + t.Run("PATCH message with dry_run stops after the existence check", func(t *testing.T) { + fx := chatRouterFixture(t) + fx.mwMock.EXPECT().ChatGetMessagesByIds(mock.Anything, mock.Anything). + Return(&pb.RpcChatGetMessagesByIdsResponse{Messages: []*model.ChatMessage{chatHandlerTestMessage()}}) + w := serveChat(fx, "PATCH", "/v2/spaces/space1/chats/chat1/messages/msg1?dry_run=true", `{"text":"updated"}`) + require.Equal(t, http.StatusOK, w.Code) + assert.Contains(t, w.Body.String(), `"dry_run":true`) + }) + + t.Run("DELETE message with dry_run deletes nothing", func(t *testing.T) { + fx := chatRouterFixture(t) + fx.mwMock.EXPECT().ChatGetMessagesByIds(mock.Anything, mock.Anything). + Return(&pb.RpcChatGetMessagesByIdsResponse{Messages: []*model.ChatMessage{chatHandlerTestMessage()}}) + w := serveChat(fx, "DELETE", "/v2/spaces/space1/chats/chat1/messages/msg1?dry_run=true", "") + require.Equal(t, http.StatusOK, w.Code) + assert.Contains(t, w.Body.String(), `"dry_run":true`) + }) + + t.Run("POST reaction with dry_run toggles nothing and predicts the outcome", func(t *testing.T) { + fx := chatRouterFixture(t) + fx.mwMock.EXPECT().ChatGetMessagesByIds(mock.Anything, mock.Anything). + Return(&pb.RpcChatGetMessagesByIdsResponse{Messages: []*model.ChatMessage{chatHandlerTestMessage()}}) + w := serveChat(fx, "POST", "/v2/spaces/space1/chats/chat1/messages/msg1/reactions?dry_run=true", `{"emoji":"🎉"}`) + require.Equal(t, http.StatusOK, w.Code) + assert.Contains(t, w.Body.String(), `"added":true`, + "the fixture account carries no 🎉 — the predicted outcome is added") + assert.Contains(t, w.Body.String(), `"dry_run":true`) + }) + + t.Run("POST read with dry_run forwards nothing", func(t *testing.T) { + fx := chatRouterFixture(t) + w := serveChat(fx, "POST", "/v2/spaces/space1/chats/chat1/read?dry_run=true", `{"up_to":"00a1","last_state_id":"state42"}`) + require.Equal(t, http.StatusOK, w.Code) + assert.Contains(t, w.Body.String(), `"dry_run":true`) + }) +} + +func TestChatBodyDecoding(t *testing.T) { + t.Run("an unknown field is a 400 naming the field", func(t *testing.T) { + // given: strict decoding (C13's spirit at the request layer) + fx := chatRouterFixture(t) + + // when + w := serveChat(fx, "POST", "/v2/spaces/space1/chats/chat1/messages", `{"message":"hi"}`) + + // then + require.Equal(t, http.StatusBadRequest, w.Code) + var got v2model.Error + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &got)) + assert.Equal(t, v2model.CodeValidationFailed, got.Code) + require.NotEmpty(t, got.Issues) + assert.Equal(t, "/message", got.Issues[0].Path, "the unknown field must be named, path-addressed") + }) + + t.Run("an oversized body is a 413 request_too_large", func(t *testing.T) { + fx := chatRouterFixture(t) + w := serveChat(fx, "POST", "/v2/spaces/space1/chats/chat1/messages", strings.Repeat("x", 1<<20+1)) + require.Equal(t, http.StatusRequestEntityTooLarge, w.Code) + assert.Contains(t, w.Body.String(), v2model.CodeRequestTooLarge) + }) + + t.Run("an empty body is a 400 carrying the shape hint", func(t *testing.T) { + fx := chatRouterFixture(t) + w := serveChat(fx, "POST", "/v2/spaces/space1/chats/chat1/messages", "") + require.Equal(t, http.StatusBadRequest, w.Code) + assert.Contains(t, w.Body.String(), "text, reply_to, attachments") + }) +} diff --git a/core/api/v2/handler/create.go b/core/api/v2/handler/create.go new file mode 100644 index 0000000000..21053dd737 --- /dev/null +++ b/core/api/v2/handler/create.go @@ -0,0 +1,536 @@ +package v2handler + +// create.go holds the Phase-2 create/update handlers (APIV2.md §2). All +// POST routes run behind the C8 idempotency middleware; every mutation +// honors ?dry_run=true (C9) via the context flag the dry-run middleware +// sets. + +import ( + "io" + "net/http" + "os" + "path/filepath" + "strings" + + "github.com/gin-gonic/gin" + + v2model "github.com/anyproto/anytype-heart/core/api/v2/model" + v2service "github.com/anyproto/anytype-heart/core/api/v2/service" +) + +// v2DryRunContextKey mirrors the key the server's ensureDryRun middleware +// sets (C9); the handler package cannot import server (import cycle). +const v2DryRunContextKey = "dry_run" + +// isV2DryRun reports whether the request asked for a dry run. +func isV2DryRun(c *gin.Context) bool { + return c.GetBool(v2DryRunContextKey) +} + +// v2CreateMissingOptionsContextKey mirrors the key the server's +// ensureCreateMissingOptions middleware sets. +const v2CreateMissingOptionsContextKey = "create_missing_options" + +// mayCreateMissingOptions reports whether this request consented to minting select +// options for names that match nothing (A2). Absent means no. +func mayCreateMissingOptions(c *gin.Context) bool { + return c.GetBool(v2CreateMissingOptionsContextKey) +} + +// maxV2CreateBodySize bounds create request bodies (matches /v2/validate). +const maxV2CreateBodySize = 10 << 20 // 10 MiB + +// maxV2StructuredBodySize caps the five typed Phase-2 JSON bodies (property +// create/update, set, collection, file-by-URL): each is a small descriptor, +// nothing like the 10 MiB document channel. Before these routes went through +// decodeStrictJSONBody they bound with ShouldBindJSON — unknown fields +// silently dropped while GET /v2/schemas advertises +// additionalProperties:false for exactly these kinds — and read UNBOUNDED +// bodies whenever no Idempotency-Key engaged the middleware cap (surface +// review M6). +const maxV2StructuredBodySize = 1 << 20 // 1 MiB + +// readV2Body reads a bounded request body; a nil return means the error +// response was already written. +func readV2Body(c *gin.Context) []byte { + body, err := io.ReadAll(io.LimitReader(c.Request.Body, maxV2CreateBodySize+1)) + if err != nil { + RespondError(c, v2model.ValidationFailed("read request body: "+err.Error())) + return nil + } + if len(body) > maxV2CreateBodySize { + RespondError(c, v2model.RequestTooLarge("request body exceeds the 10 MiB limit")) + return nil + } + return body +} + +// respondV2Create writes a create/update result: 201 on a real create, 200 +// on dry runs and updates, with the ETag header when known. +func respondV2Create(c *gin.Context, result *v2model.CreateResult, createdStatus int) { + if result.Etag != "" { + c.Header("ETag", v2service.QuoteEtag(result.Etag)) + } + status := createdStatus + if result.DryRun { + status = http.StatusOK + } + c.JSON(status, result) +} + +// CreateObjectHandler creates an object from an AnyBlock document or the shortcut +// +// @Summary Create an object +// @Description A select value naming an option the property does not hold is refused unless `create_missing_options=true` is set. An unknown type or property key is rejected either way, with the closest matches named. The body is either a full AnyBlock document or the shortcut {type, name, properties, markdown}; `version` or `blocks` picks the document form. +// @Id create_object +// @Tags Objects +// @Accept json +// @Produce json +// @Param space_id path string true "Space id" +// @Param dry_run query bool false "Validate and report without committing" +// @Param create_missing_options query bool false "Create select options for names the property does not hold yet (default false: an unmatched name is refused)" +// @Success 201 {object} v2model.CreateResult "Created object id + etag" +// @Failure 400 {object} v2model.Error "Validation or reference failure" +// @Security bearerauth +// @Router /v2/spaces/{space_id}/objects [post] +func CreateObjectHandler(s *v2service.Service) gin.HandlerFunc { + return func(c *gin.Context) { + body := readV2Body(c) + if body == nil { + return + } + result, err := s.CreateObject(c.Request.Context(), c.Param("space_id"), body, isV2DryRun(c), mayCreateMissingOptions(c)) + if err != nil { + RespondError(c, err) + return + } + respondV2Create(c, result, http.StatusCreated) + } +} + +// CreateTemplateHandler creates a template from an AnyBlock document +// +// @Summary Create a template +// @Description `template_for` names the type key this template starts an object of. +// @Id create_template +// @Tags Templates +// @Accept json +// @Produce json +// @Param space_id path string true "Space id" +// @Param dry_run query bool false "Validate and report without committing" +// @Param create_missing_options query bool false "Create select options for names the property does not hold yet (default false: an unmatched name is refused)" +// @Success 201 {object} v2model.CreateResult "Created template id" +// @Failure 400 {object} v2model.Error "Validation or reference failure" +// @Security bearerauth +// @Router /v2/spaces/{space_id}/templates [post] +func CreateTemplateHandler(s *v2service.Service) gin.HandlerFunc { + return func(c *gin.Context) { + body := readV2Body(c) + if body == nil { + return + } + result, err := s.CreateTemplate(c.Request.Context(), c.Param("space_id"), body, isV2DryRun(c), mayCreateMissingOptions(c)) + if err != nil { + RespondError(c, err) + return + } + respondV2Create(c, result, http.StatusCreated) + } +} + +// CreateTypeHandler creates a type from a kind:"object_type" document +// +// @Summary Create a type +// @Description A `type_settings.property_definitions` entry naming a property that does not exist creates it alongside the type. The body is an AnyBlock document with kind "object_type"; the type's api key, layout and plural name live in `type_settings`. +// @Id create_type +// @Tags Types +// @Accept json +// @Produce json +// @Param space_id path string true "Space id" +// @Param dry_run query bool false "Validate and report without committing" +// @Param create_missing_options query bool false "Create select options for names the property does not hold yet (default false: an unmatched name is refused)" +// @Success 201 {object} v2model.CreateResult "Created type id + key" +// @Failure 400 {object} v2model.Error "Validation failure" +// @Security bearerauth +// @Router /v2/spaces/{space_id}/types [post] +func CreateTypeHandler(s *v2service.Service) gin.HandlerFunc { + return func(c *gin.Context) { + body := readV2Body(c) + if body == nil { + return + } + result, err := s.CreateType(c.Request.Context(), c.Param("space_id"), body, isV2DryRun(c), mayCreateMissingOptions(c)) + if err != nil { + RespondError(c, err) + return + } + respondV2Create(c, result, http.StatusCreated) + } +} + +// UpdateTypeHandler updates a type (type-document semantics) +// +// @Summary Update a type +// @Description `type_settings.property_definitions`, when present, replaces the recommended property lists rather than adding to them, and creates any property that does not exist yet. `properties` takes name and description; the layout is `type_settings.layout` and the icon is the typed envelope `icon`. Any other key is refused. +// @Id update_type +// @Tags Types +// @Accept json +// @Produce json +// @Param space_id path string true "Space id" +// @Param type path string true "Type key" +// @Success 200 {object} v2model.CreateResult "Updated type" +// @Failure 404 {object} v2model.Error "Type not found" +// @Security bearerauth +// @Router /v2/spaces/{space_id}/types/{type} [patch] +func UpdateTypeHandler(s *v2service.Service) gin.HandlerFunc { + return func(c *gin.Context) { + body := readV2Body(c) + if body == nil { + return + } + result, err := s.UpdateType(c.Request.Context(), c.Param("space_id"), c.Param("type"), body, isV2DryRun(c), mayCreateMissingOptions(c)) + if err != nil { + RespondError(c, err) + return + } + respondV2Create(c, result, http.StatusOK) + } +} + +// DeleteTypeHandler archives a type +// +// @Summary Delete a type +// @Id delete_type +// @Tags Types +// @Produce json +// @Param space_id path string true "Space id" +// @Param type path string true "Type key" +// @Success 200 {object} v2model.CreateResult "Archived type" +// @Failure 404 {object} v2model.Error "No live type with this key. A type that is already deleted is a 404 too, not a second delete." +// @Security bearerauth +// @Router /v2/spaces/{space_id}/types/{type} [delete] +func DeleteTypeHandler(s *v2service.Service) gin.HandlerFunc { + return func(c *gin.Context) { + result, err := s.DeleteType(c.Request.Context(), c.Param("space_id"), c.Param("type"), isV2DryRun(c)) + if err != nil { + RespondError(c, err) + return + } + respondV2Create(c, result, http.StatusOK) + } +} + +// CreatePropertyHandler creates a property +// +// @Summary Create a property +// @Id create_property +// @Tags Properties +// @Accept json +// @Produce json +// @Param space_id path string true "Space id" +// @Param dry_run query bool false "Validate and report without committing" +// @Success 201 {object} v2model.CreateResult "Created property id + key" +// @Failure 400 {object} v2model.Error "Validation failure" +// @Failure 413 {object} v2model.Error "Request body exceeds the 1 MiB cap" +// @Security bearerauth +// @Router /v2/spaces/{space_id}/properties [post] +func CreatePropertyHandler(s *v2service.Service) gin.HandlerFunc { + return func(c *gin.Context) { + var req v2model.CreatePropertyRequest + if !decodeStrictJSONBody(c, &req, + "the property body is {key?, name, format, options?} — GET /v2/schemas/property for the schema", + maxV2StructuredBodySize, "property") { + return + } + result, err := s.CreateProperty(c.Request.Context(), c.Param("space_id"), req, isV2DryRun(c)) + if err != nil { + RespondError(c, err) + return + } + respondV2Create(c, result, http.StatusCreated) + } +} + +// UpdatePropertyHandler updates a property +// +// @Summary Update a property +// @Description Only the display name can change. The key is the property's identity, and its format is fixed once it exists. +// @Id update_property +// @Tags Properties +// @Accept json +// @Produce json +// @Param space_id path string true "Space id" +// @Param key path string true "Property key" +// @Success 200 {object} v2model.CreateResult "Updated property" +// @Failure 400 {object} v2model.Error "Validation failure" +// @Failure 404 {object} v2model.Error "Property not found" +// @Failure 413 {object} v2model.Error "Request body exceeds the 1 MiB cap" +// @Security bearerauth +// @Router /v2/spaces/{space_id}/properties/{key} [patch] +func UpdatePropertyHandler(s *v2service.Service) gin.HandlerFunc { + return func(c *gin.Context) { + var req v2model.UpdatePropertyRequest + if !decodeStrictJSONBody(c, &req, + "the property patch takes name — the key is identity and cannot change", + maxV2StructuredBodySize, "property") { + return + } + result, err := s.UpdateProperty(c.Request.Context(), c.Param("space_id"), c.Param("key"), req, isV2DryRun(c)) + if err != nil { + RespondError(c, err) + return + } + respondV2Create(c, result, http.StatusOK) + } +} + +// DeletePropertyHandler archives a property +// +// @Summary Delete a property +// @Id delete_property +// @Tags Properties +// @Produce json +// @Param space_id path string true "Space id" +// @Param key path string true "Property key" +// @Success 200 {object} v2model.CreateResult "Archived property" +// @Failure 404 {object} v2model.Error "No live property with this key. A property that is already deleted is a 404 too, not a second delete." +// @Security bearerauth +// @Router /v2/spaces/{space_id}/properties/{key} [delete] +func DeletePropertyHandler(s *v2service.Service) gin.HandlerFunc { + return func(c *gin.Context) { + result, err := s.DeleteProperty(c.Request.Context(), c.Param("space_id"), c.Param("key"), isV2DryRun(c)) + if err != nil { + RespondError(c, err) + return + } + respondV2Create(c, result, http.StatusOK) + } +} + +// CreateSetHandler creates a set with its views in one change set +// +// @Summary Create a set +// @Description Filter and sort property keys are checked against the type the set queries; a key that type does not carry is refused. +// @Id create_set +// @Tags Lists +// @Accept json +// @Produce json +// @Param space_id path string true "Space id" +// @Param dry_run query bool false "Validate and report without committing" +// @Param create_missing_options query bool false "Create select options for names the property does not hold yet (default false: an unmatched name is refused)" +// @Success 201 {object} v2model.CreateResult "Created set id" +// @Failure 400 {object} v2model.Error "Validation or reference failure" +// @Failure 413 {object} v2model.Error "Request body exceeds the 1 MiB cap" +// @Security bearerauth +// @Router /v2/spaces/{space_id}/sets [post] +func CreateSetHandler(s *v2service.Service) gin.HandlerFunc { + return func(c *gin.Context) { + var req v2model.CreateSetRequest + if !decodeStrictJSONBody(c, &req, + "the set body is {name, type, filter?/filters?, sorts?, views?} — GET /v2/schemas/set for the schema", + maxV2StructuredBodySize, "set") { + return + } + result, err := s.CreateSet(c.Request.Context(), c.Param("space_id"), req, isV2DryRun(c), mayCreateMissingOptions(c)) + if err != nil { + RespondError(c, err) + return + } + respondV2Create(c, result, http.StatusCreated) + } +} + +// CreateCollectionHandler creates a collection +// +// @Summary Create a collection +// @Description Item ids are checked against the space; an id that does not resolve there is refused rather than dropped. +// @Id create_collection +// @Tags Lists +// @Accept json +// @Produce json +// @Param space_id path string true "Space id" +// @Param dry_run query bool false "Validate and report without committing" +// @Param create_missing_options query bool false "Create select options for names the property does not hold yet (default false: an unmatched name is refused)" +// @Success 201 {object} v2model.CreateResult "Created collection id" +// @Failure 400 {object} v2model.Error "Validation or reference failure" +// @Failure 413 {object} v2model.Error "Request body exceeds the 1 MiB cap" +// @Security bearerauth +// @Router /v2/spaces/{space_id}/collections [post] +func CreateCollectionHandler(s *v2service.Service) gin.HandlerFunc { + return func(c *gin.Context) { + var req v2model.CreateCollectionRequest + if !decodeStrictJSONBody(c, &req, + "the collection body is {name, items?} — GET /v2/schemas/collection for the schema", + maxV2StructuredBodySize, "collection") { + return + } + result, err := s.CreateCollection(c.Request.Context(), c.Param("space_id"), req, isV2DryRun(c)) + if err != nil { + RespondError(c, err) + return + } + respondV2Create(c, result, http.StatusCreated) + } +} + +// UploadFileHandler uploads a file (multipart or URL) +// +// @Summary Upload a file +// @Description Send multipart/form-data with a `file` field, or JSON {"url": …}. A source that refuses the fetch, or a URL that cannot be fetched, is a 400 naming /url; only a genuine server fault answers 500. The id that comes back is the one file blocks, image blocks and icon_image values reference. +// @Id upload_file +// @Tags Files +// @Accept multipart/form-data +// @Produce json +// @Param space_id path string true "Space id" +// @Param dry_run query bool false "Validate and report without committing" +// @Success 201 {object} v2model.FileUploadResult "Created file object id" +// @Failure 400 {object} v2model.Error "Validation failure, or a source URL that did not yield the file" +// @Failure 413 {object} v2model.Error "JSON request body exceeds the 1 MiB cap" +// @Security bearerauth +// @Router /v2/spaces/{space_id}/files [post] +func UploadFileHandler(s *v2service.Service) gin.HandlerFunc { + return func(c *gin.Context) { + spaceId := c.Param("space_id") + dryRun := isV2DryRun(c) + + if strings.HasPrefix(c.ContentType(), "multipart/") { + localPath, cleanup, ok := stageV2Upload(c) + if !ok { + return + } + defer cleanup() + result, err := s.UploadFile(c.Request.Context(), spaceId, localPath, "", dryRun) + if err != nil { + RespondError(c, err) + return + } + respondUpload(c, result) + return + } + + var req v2model.UploadFileRequest + if !decodeStrictJSONBody(c, &req, + "send multipart/form-data with a file field, or JSON {\"url\": …} — GET /v2/schemas/file for the schema", + maxV2StructuredBodySize, "file") { + return + } + result, err := s.UploadFile(c.Request.Context(), spaceId, "", req.Url, dryRun) + if err != nil { + RespondError(c, err) + return + } + respondUpload(c, result) + } +} + +func respondUpload(c *gin.Context, result *v2model.FileUploadResult) { + status := http.StatusCreated + if result.DryRun { + status = http.StatusOK + } + c.JSON(status, result) +} + +// stageV2Upload copies the multipart file into a private temp dir so the +// upload pipeline sees the caller-supplied filename (mirrors the v1 +// handler's staging, including the path-traversal guard). +func stageV2Upload(c *gin.Context) (localPath string, cleanup func(), ok bool) { + fileHeader, err := c.FormFile("file") + if err != nil { + RespondError(c, v2model.ValidationFailed("missing file field in multipart form")) + return "", nil, false + } + file, err := fileHeader.Open() + if err != nil { + RespondError(c, v2model.ValidationFailed("read uploaded file: "+err.Error())) + return "", nil, false + } + defer file.Close() + + tempDir, err := os.MkdirTemp("", "anytype-upload-") + if err != nil { + RespondError(c, err) + return "", nil, false + } + cleanup = func() { _ = os.RemoveAll(tempDir) } + + name := filepath.Base(fileHeader.Filename) + if name == "." || name == ".." || name == "" || strings.ContainsRune(name, 0) { + name = "upload" + } + tempPath := filepath.Join(tempDir, name) + tempFile, err := os.Create(tempPath) + if err != nil { + cleanup() + RespondError(c, err) + return "", nil, false + } + _, err = io.Copy(tempFile, file) + tempFile.Close() + if err != nil { + cleanup() + RespondError(c, err) + return "", nil, false + } + return tempPath, cleanup, true +} + +// SchemaIndexHandler lists the discoverable schemas +// +// @Summary List the available schemas +// @Id list_schemas +// @Tags Schemas +// @Produce json +// @Success 200 {object} v2model.SchemaIndex "Schema index" +// @Security bearerauth +// @Router /v2/schemas [get] +func SchemaIndexHandler(s *v2service.Service) gin.HandlerFunc { + return func(c *gin.Context) { + c.JSON(http.StatusOK, s.SchemaIndex()) + } +} + +// SchemaKindHandler serves one kind's schema + worked example +// +// @Summary Get the schema for one kind +// @Id get_schema +// @Tags Schemas +// @Produce json +// @Param kind path string true "Schema kind, as listed by GET /v2/schemas" +// @Success 200 {object} v2model.SchemaEntry "Schema + example" +// @Failure 404 {object} v2model.Error "Unknown kind" +// @Security bearerauth +// @Router /v2/schemas/{kind} [get] +func SchemaKindHandler(s *v2service.Service) gin.HandlerFunc { + return func(c *gin.Context) { + entry, err := s.SchemaKind(c.Param("kind")) + if err != nil { + RespondError(c, err) + return + } + c.JSON(http.StatusOK, entry) + } +} + +// SchemaOpHandler serves one PATCH op's schema + minimal example +// +// @Summary Get the schema for one edit op +// @Description The example is a single op object, ready to drop into an edit request's `ops` array, not a whole request body. +// @Id get_op_schema +// @Tags Schemas +// @Produce json +// @Param op path string true "Op name: set_properties, update_block, replace_subtree, insert_blocks, move_block, delete_block, replace_text, set_cell, update_view, insert_view, move_view, delete_view, add_items, remove_items" +// @Success 200 {object} v2model.SchemaEntry "Schema + example" +// @Failure 404 {object} v2model.Error "Unknown op" +// @Security bearerauth +// @Router /v2/schemas/ops/{op} [get] +func SchemaOpHandler(s *v2service.Service) gin.HandlerFunc { + return func(c *gin.Context) { + entry, err := s.SchemaOp(c.Param("op")) + if err != nil { + RespondError(c, err) + return + } + c.JSON(http.StatusOK, entry) + } +} diff --git a/core/api/v2/handler/create_test.go b/core/api/v2/handler/create_test.go new file mode 100644 index 0000000000..3771daad55 --- /dev/null +++ b/core/api/v2/handler/create_test.go @@ -0,0 +1,306 @@ +package v2handler + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + + apicore "github.com/anyproto/anytype-heart/core/api/core" + v2model "github.com/anyproto/anytype-heart/core/api/v2/model" + "github.com/anyproto/anytype-heart/pkg/lib/pb/model" +) + +// withDryRunFlag mimics the server's ensureDryRun middleware for handler +// tests: it parses ?dry_run into the context flag the handlers read. +func withDryRunFlag() gin.HandlerFunc { + return func(c *gin.Context) { + c.Set(v2DryRunContextKey, c.Query("dry_run") == "true") + c.Next() + } +} + +func TestCreateObjectHandler(t *testing.T) { + t.Run("a created object responds 201 with id and etag", func(t *testing.T) { + // given + fx := newV2HandlerFixture(t) + fx.router.POST("/v2/spaces/:space_id/objects", withDryRunFlag(), CreateObjectHandler(fx.svc)) + fx.creatorMock.EXPECT().CreateObjectFromSnapshot(mock.Anything, "space1", mock.Anything).Return("newObj", nil) + fx.readerMock.EXPECT().ReadObject(mock.Anything, "space1", "newObj"). + Return(apicore.ObjectRead{Heads: []string{"h"}}, nil) + + // when + req := httptest.NewRequest(http.MethodPost, "/v2/spaces/space1/objects", + strings.NewReader(`{"type":"task","name":"Buy milk"}`)) + w := httptest.NewRecorder() + fx.router.ServeHTTP(w, req) + + // then + require.Equal(t, http.StatusCreated, w.Code) + var got v2model.CreateResult + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &got)) + assert.Equal(t, "newObj", got.Id) + assert.NotEmpty(t, got.Etag) + assert.NotEmpty(t, w.Header().Get("ETag")) + }) + + t.Run("dry_run responds 200 and commits nothing", func(t *testing.T) { + // given: no creator expectations — a create call would fail the test + fx := newV2HandlerFixture(t) + fx.router.POST("/v2/spaces/:space_id/objects", withDryRunFlag(), CreateObjectHandler(fx.svc)) + + // when + req := httptest.NewRequest(http.MethodPost, "/v2/spaces/space1/objects?dry_run=true", + strings.NewReader(`{"type":"task","name":"Buy milk"}`)) + w := httptest.NewRecorder() + fx.router.ServeHTTP(w, req) + + // then + require.Equal(t, http.StatusOK, w.Code) + var got v2model.CreateResult + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &got)) + assert.True(t, got.DryRun) + assert.Empty(t, got.Id) + }) + + t.Run("validation failures respond with the C6 envelope", func(t *testing.T) { + // given + fx := newV2HandlerFixture(t) + fx.router.POST("/v2/spaces/:space_id/objects", withDryRunFlag(), CreateObjectHandler(fx.svc)) + + // when + req := httptest.NewRequest(http.MethodPost, "/v2/spaces/space1/objects", + strings.NewReader(`{"version":1,"blocks":[{"type":"wat"}]}`)) + w := httptest.NewRecorder() + fx.router.ServeHTTP(w, req) + + // then + require.Equal(t, http.StatusBadRequest, w.Code) + var got v2model.Error + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &got)) + assert.Equal(t, v2model.CodeValidationFailed, got.Code) + require.NotEmpty(t, got.Issues) + }) +} + +func TestCreatePropertyHandler(t *testing.T) { + t.Run("malformed body is a 400", func(t *testing.T) { + // given + fx := newV2HandlerFixture(t) + fx.router.POST("/v2/spaces/:space_id/properties", withDryRunFlag(), CreatePropertyHandler(fx.svc)) + + // when + req := httptest.NewRequest(http.MethodPost, "/v2/spaces/space1/properties", strings.NewReader(`{"name":`)) + w := httptest.NewRecorder() + fx.router.ServeHTTP(w, req) + + // then + require.Equal(t, http.StatusBadRequest, w.Code) + }) + + t.Run("dry run reports the would-be property", func(t *testing.T) { + // given + fx := newV2HandlerFixture(t) + fx.router.POST("/v2/spaces/:space_id/properties", withDryRunFlag(), CreatePropertyHandler(fx.svc)) + + // when + req := httptest.NewRequest(http.MethodPost, "/v2/spaces/space1/properties?dry_run=true", + strings.NewReader(`{"key":"vibe","name":"Vibe","format":"select"}`)) + w := httptest.NewRecorder() + fx.router.ServeHTTP(w, req) + + // then + require.Equal(t, http.StatusOK, w.Code) + var got v2model.CreateResult + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &got)) + assert.True(t, got.DryRun) + require.NotNil(t, got.Created) + require.Len(t, got.Created.Properties, 1) + }) +} + +// TestPhase2StrictBinding pins M6 (surface review): the five typed Phase-2 +// bodies bind strictly — a typo'd or unknown field 400s with the field +// named instead of silently dropping agent intent (the reproduced failing +// input: "option" for "options" created a property with no options and no +// signal, while GET /v2/schemas promised additionalProperties:false), the +// error speaks C6 (issues array, not gin text), and the previously +// unbounded bodies are capped. No service mocks carry expectations, so a +// reverted strict bind that reaches the service fails the test twice over. +func TestPhase2StrictBinding(t *testing.T) { + decode := func(t *testing.T, w *httptest.ResponseRecorder) v2model.Error { + t.Helper() + var got v2model.Error + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &got)) + return got + } + + post := func(t *testing.T, fx *v2HandlerFixture, path, body string) *httptest.ResponseRecorder { + t.Helper() + req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(body)) + w := httptest.NewRecorder() + fx.router.ServeHTTP(w, req) + return w + } + + t.Run("a typo'd options field on POST properties is a 400 naming it", func(t *testing.T) { + fx := newV2HandlerFixture(t) + fx.router.POST("/v2/spaces/:space_id/properties", withDryRunFlag(), CreatePropertyHandler(fx.svc)) + + w := post(t, fx, "/v2/spaces/space1/properties", + `{"name":"Priority","format":"select","option":[{"name":"High"}],"bogus":123}`) + + require.Equal(t, http.StatusBadRequest, w.Code) + got := decode(t, w) + assert.Equal(t, v2model.CodeValidationFailed, got.Code) + require.NotEmpty(t, got.Issues, "bind failures must speak C6 — the gin text had no issues array") + assert.Equal(t, "/option", got.Issues[0].Path, "the unknown field must be named") + }) + + t.Run("an unknown field on PATCH properties is a 400", func(t *testing.T) { + fx := newV2HandlerFixture(t) + fx.router.PATCH("/v2/spaces/:space_id/properties/:key", withDryRunFlag(), UpdatePropertyHandler(fx.svc)) + + req := httptest.NewRequest(http.MethodPatch, "/v2/spaces/space1/properties/priority", + strings.NewReader(`{"nmae":"Priority"}`)) + w := httptest.NewRecorder() + fx.router.ServeHTTP(w, req) + + require.Equal(t, http.StatusBadRequest, w.Code) + got := decode(t, w) + require.NotEmpty(t, got.Issues) + assert.Equal(t, "/nmae", got.Issues[0].Path) + }) + + t.Run("an unknown field on POST sets is a 400", func(t *testing.T) { + fx := newV2HandlerFixture(t) + fx.router.POST("/v2/spaces/:space_id/sets", withDryRunFlag(), CreateSetHandler(fx.svc)) + + w := post(t, fx, "/v2/spaces/space1/sets", `{"name":"Open","type":"task","filtre":"done = false"}`) + + require.Equal(t, http.StatusBadRequest, w.Code) + got := decode(t, w) + require.NotEmpty(t, got.Issues) + assert.Equal(t, "/filtre", got.Issues[0].Path) + }) + + t.Run("an unknown field on POST collections is a 400", func(t *testing.T) { + fx := newV2HandlerFixture(t) + fx.router.POST("/v2/spaces/:space_id/collections", withDryRunFlag(), CreateCollectionHandler(fx.svc)) + + w := post(t, fx, "/v2/spaces/space1/collections", `{"name":"List","item":["obj1"]}`) + + require.Equal(t, http.StatusBadRequest, w.Code) + got := decode(t, w) + require.NotEmpty(t, got.Issues) + assert.Equal(t, "/item", got.Issues[0].Path) + }) + + t.Run("an unknown field on the JSON upload body is a 400", func(t *testing.T) { + fx := newV2HandlerFixture(t) + fx.router.POST("/v2/spaces/:space_id/files", withDryRunFlag(), UploadFileHandler(fx.svc)) + + w := post(t, fx, "/v2/spaces/space1/files", `{"uri":"https://example.org/a.pdf"}`) + + require.Equal(t, http.StatusBadRequest, w.Code) + got := decode(t, w) + require.NotEmpty(t, got.Issues) + assert.Equal(t, "/uri", got.Issues[0].Path) + }) + + t.Run("an oversized property body is a 413 even without an Idempotency-Key", func(t *testing.T) { + // before M6 the body cap only engaged when the idempotency + // middleware saw a key — a keyless request was read unbounded + fx := newV2HandlerFixture(t) + fx.router.POST("/v2/spaces/:space_id/properties", withDryRunFlag(), CreatePropertyHandler(fx.svc)) + + body := `{"name":"` + strings.Repeat("x", maxV2StructuredBodySize) + `","format":"text"}` + w := post(t, fx, "/v2/spaces/space1/properties", body) + + require.Equal(t, http.StatusRequestEntityTooLarge, w.Code) + assert.Equal(t, v2model.CodeRequestTooLarge, decode(t, w).Code) + }) + + t.Run("an oversized JSON upload body is a 413", func(t *testing.T) { + fx := newV2HandlerFixture(t) + fx.router.POST("/v2/spaces/:space_id/files", withDryRunFlag(), UploadFileHandler(fx.svc)) + + body := `{"url":"https://example.org/` + strings.Repeat("x", maxV2StructuredBodySize) + `"}` + w := post(t, fx, "/v2/spaces/space1/files", body) + + require.Equal(t, http.StatusRequestEntityTooLarge, w.Code) + }) +} + +func TestUploadFileHandler(t *testing.T) { + t.Run("json body without url is a 400", func(t *testing.T) { + // given + fx := newV2HandlerFixture(t) + fx.router.POST("/v2/spaces/:space_id/files", withDryRunFlag(), UploadFileHandler(fx.svc)) + + // when + req := httptest.NewRequest(http.MethodPost, "/v2/spaces/space1/files", strings.NewReader(`{}`)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + fx.router.ServeHTTP(w, req) + + // then + require.Equal(t, http.StatusBadRequest, w.Code) + }) +} + +func TestSchemaV2Handlers(t *testing.T) { + t.Run("index and kind round-trip", func(t *testing.T) { + // given + fx := newV2HandlerFixture(t) + fx.router.GET("/v2/schemas", SchemaIndexHandler(fx.svc)) + fx.router.GET("/v2/schemas/:kind", SchemaKindHandler(fx.svc)) + + // when: index + w := httptest.NewRecorder() + fx.router.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/v2/schemas", nil)) + require.Equal(t, http.StatusOK, w.Code) + var index v2model.SchemaIndex + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &index)) + require.NotEmpty(t, index.Kinds) + + // when: first kind + w = httptest.NewRecorder() + fx.router.ServeHTTP(w, httptest.NewRequest(http.MethodGet, index.Kinds[0].Url, nil)) + require.Equal(t, http.StatusOK, w.Code) + + // when: unknown kind + w = httptest.NewRecorder() + fx.router.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/v2/schemas/wat", nil)) + require.Equal(t, http.StatusNotFound, w.Code) + }) +} + +// contextCancelGuard: the create handlers must pass the request context +// through so client disconnects propagate to the middleware calls. +func TestCreateObjectV2HandlerContext(t *testing.T) { + fx := newV2HandlerFixture(t) + fx.router.POST("/v2/spaces/:space_id/objects", withDryRunFlag(), CreateObjectHandler(fx.svc)) + var gotCtx context.Context + fx.creatorMock.EXPECT().CreateObjectFromSnapshot(mock.Anything, "space1", mock.Anything). + RunAndReturn(func(ctx context.Context, spaceId string, snapshot *model.SmartBlockSnapshotBase) (string, error) { + gotCtx = ctx + return "obj", nil + }) + fx.readerMock.EXPECT().ReadObject(mock.Anything, "space1", "obj"). + Return(apicore.ObjectRead{Heads: []string{"h"}}, nil) + + req := httptest.NewRequest(http.MethodPost, "/v2/spaces/space1/objects", strings.NewReader(`{"type":"page"}`)) + w := httptest.NewRecorder() + fx.router.ServeHTTP(w, req) + + require.Equal(t, http.StatusCreated, w.Code) + require.NotNil(t, gotCtx) +} diff --git a/core/api/v2/handler/discovery.go b/core/api/v2/handler/discovery.go new file mode 100644 index 0000000000..71060f150a --- /dev/null +++ b/core/api/v2/handler/discovery.go @@ -0,0 +1,202 @@ +package v2handler + +import ( + "net/http" + + "github.com/gin-gonic/gin" + + "github.com/anyproto/anytype-heart/core/api/pagination" + v2model "github.com/anyproto/anytype-heart/core/api/v2/model" + v2service "github.com/anyproto/anytype-heart/core/api/v2/service" +) + +// ListSpacesHandler lists spaces as minimal rows +// +// @Summary List the account's spaces +// @Description Only live spaces are listed. A space that is deleted, left, or still joining does not appear. +// @Id list_spaces +// @Tags Spaces +// @Produce json +// @Param ids query string false "compact (default) is the short space reference; full is the whole . id, and the spelling to store outside this API" +// @Success 200 {object} v2model.ListResponse[v2model.SpaceRow] "Minimal space rows" +// @Security bearerauth +// @Router /v2/spaces [get] +func ListSpacesHandler(s *v2service.Service) gin.HandlerFunc { + return func(c *gin.Context) { + offset := c.GetInt(pagination.QueryParamOffset) + limit := c.GetInt(pagination.QueryParamLimit) + rows, total, hasMore, err := s.ListSpaces(c.Request.Context(), offset, limit) + if err != nil { + RespondError(c, err) + return + } + c.JSON(http.StatusOK, v2model.NewListResponse(rows, total, offset, limit, hasMore, + "request the next offset")) + } +} + +// ListMembersHandler lists space members as minimal rows +// +// @Summary List the members of a space +// @Id list_members +// @Tags Members +// @Produce json +// @Param space_id path string true "Space id" +// @Success 200 {object} v2model.ListResponse[v2model.MemberRow] "Minimal member rows" +// @Security bearerauth +// @Router /v2/spaces/{space_id}/members [get] +func ListMembersHandler(s *v2service.Service) gin.HandlerFunc { + return func(c *gin.Context) { + offset := c.GetInt(pagination.QueryParamOffset) + limit := c.GetInt(pagination.QueryParamLimit) + rows, total, hasMore, err := s.ListMembers(c.Request.Context(), c.Param("space_id"), offset, limit) + if err != nil { + RespondError(c, err) + return + } + c.JSON(http.StatusOK, v2model.NewListResponse(rows, total, offset, limit, hasMore, + "request the next offset")) + } +} + +// GetMemberMeHandler returns the caller's own member row +// +// @Summary Get the calling member +// @Description The identity is taken from the account this API runs against; there is no member id to send. +// @Id get_member_me +// @Tags Members +// @Produce json +// @Param space_id path string true "Space id" +// @Success 200 {object} v2model.MemberRow "The caller's member row" +// @Failure 404 {object} v2model.Error "Space not found, or no account identity" +// @Security bearerauth +// @Router /v2/spaces/{space_id}/members/me [get] +func GetMemberMeHandler(s *v2service.Service) gin.HandlerFunc { + return func(c *gin.Context) { + row, err := s.GetMemberMe(c.Request.Context(), c.Param("space_id")) + if err != nil { + RespondError(c, err) + return + } + c.JSON(http.StatusOK, row) + } +} + +// ListTypesHandler lists type keys and names +// +// @Summary List the types in a space +// @Id list_types +// @Tags Types +// @Produce json +// @Param space_id path string true "Space id" +// @Success 200 {object} v2model.ListResponse[v2model.TypeRow] "Type rows" +// @Security bearerauth +// @Router /v2/spaces/{space_id}/types [get] +func ListTypesHandler(s *v2service.Service) gin.HandlerFunc { + return func(c *gin.Context) { + offset := c.GetInt(pagination.QueryParamOffset) + limit := c.GetInt(pagination.QueryParamLimit) + rows, total, hasMore, err := s.ListTypes(c.Request.Context(), c.Param("space_id"), offset, limit) + if err != nil { + RespondError(c, err) + return + } + c.JSON(http.StatusOK, v2model.NewListResponse(rows, total, offset, limit, hasMore, + "request the next offset")) + } +} + +// GetTypeHandler reads one type as its AnyBlock document +// +// @Summary Read a type as an AnyBlock document +// @Id get_type +// @Tags Types +// @Produce json +// @Param space_id path string true "Space id" +// @Param type path string true "Type key" +// @Param ids query string false "compact (default) is the edit shape, with short labels for minted view and block ids; full is the export shape, with full ids" +// @Success 200 {object} map[string]any "The kind:objectType AnyBlock document + etag" +// @Failure 404 {object} v2model.Error "Type not found" +// @Security bearerauth +// @Router /v2/spaces/{space_id}/types/{type} [get] +func GetTypeHandler(s *v2service.Service) gin.HandlerFunc { + return func(c *gin.Context) { + body, etag, err := s.GetType(c.Request.Context(), c.Param("space_id"), c.Param("type"), + v2service.ObjectQuery{Ids: c.Query("ids")}) + if err != nil { + RespondError(c, err) + return + } + c.Header("ETag", v2service.QuoteEtag(etag)) + c.Data(http.StatusOK, "application/json", body) + } +} + +// GetTypeSchemaHandler is the [build] GenerateSchema endpoint stub +// +// @Summary Get a JSON Schema for a type +// @Description Not implemented. Every request answers 501. +// @Id get_type_schema +// @Tags Types +// @Produce json +// @Param space_id path string true "Space id" +// @Param type path string true "Type key" +// @Failure 501 {object} v2model.Error "Not implemented yet" +// @Security bearerauth +// @Router /v2/spaces/{space_id}/types/{type}/schema [get] +func GetTypeSchemaHandler(s *v2service.Service) gin.HandlerFunc { + return func(c *gin.Context) { + RespondError(c, s.GetTypeSchema(c.Request.Context(), c.Param("space_id"), c.Param("type"))) + } +} + +// ListPropertiesHandler lists properties as key/name/format rows +// +// @Summary List the properties in a space +// @Id list_properties +// @Tags Properties +// @Produce json +// @Param space_id path string true "Space id" +// @Success 200 {object} v2model.ListResponse[v2model.PropertyRow] "Property rows" +// @Security bearerauth +// @Router /v2/spaces/{space_id}/properties [get] +func ListPropertiesHandler(s *v2service.Service) gin.HandlerFunc { + return func(c *gin.Context) { + offset := c.GetInt(pagination.QueryParamOffset) + limit := c.GetInt(pagination.QueryParamLimit) + rows, total, hasMore, err := s.ListProperties(c.Request.Context(), c.Param("space_id"), offset, limit) + if err != nil { + RespondError(c, err) + return + } + c.JSON(http.StatusOK, v2model.NewListResponse(rows, total, offset, limit, hasMore, + "request the next offset")) + } +} + +// ListPropertyOptionsHandler lists option names of one property +// +// @Summary List a property's options +// @Id list_property_options +// @Tags Properties +// @Produce json +// @Param space_id path string true "Space id" +// @Param key path string true "Property key" +// @Param prefix query string false "Case-insensitive name prefix filter" +// @Success 200 {object} v2model.ListResponse[v2model.OptionRow] "Option rows" +// @Failure 404 {object} v2model.Error "Property not found" +// @Security bearerauth +// @Router /v2/spaces/{space_id}/properties/{key}/options [get] +func ListPropertyOptionsHandler(s *v2service.Service) gin.HandlerFunc { + return func(c *gin.Context) { + offset := c.GetInt(pagination.QueryParamOffset) + limit := c.GetInt(pagination.QueryParamLimit) + rows, total, hasMore, err := s.ListPropertyOptions(c.Request.Context(), c.Param("space_id"), c.Param("key"), c.Query("prefix"), offset, limit) + if err != nil { + RespondError(c, err) + return + } + c.JSON(http.StatusOK, v2model.NewListResponse(rows, total, offset, limit, hasMore, + "narrow with prefix= or request the next offset")) + } +} diff --git a/core/api/v2/handler/discovery_test.go b/core/api/v2/handler/discovery_test.go new file mode 100644 index 0000000000..5101238016 --- /dev/null +++ b/core/api/v2/handler/discovery_test.go @@ -0,0 +1,100 @@ +package v2handler + +// discovery_test.go — handler-layer pins for the discovery routes. The +// GetType ?ids= regression shipped AT THIS LAYER (the handler hardcoded +// ObjectQuery{}), while the only test lived at the service layer — a +// handler that stops threading the query keeps every service test green. + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/gogo/protobuf/types" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + + apicore "github.com/anyproto/anytype-heart/core/api/core" + "github.com/anyproto/anytype-heart/core/domain" + "github.com/anyproto/anytype-heart/pkg/lib/bundle" + "github.com/anyproto/anytype-heart/pkg/lib/localstore/objectstore" + "github.com/anyproto/anytype-heart/pkg/lib/pb/model" + "github.com/anyproto/anytype-heart/util/pbtypes" +) + +// testTypeMintedBlockId relabels to "bbbb1" on the default (edit) shape. +const testTypeMintedBlockId = "0000000000000000000bbbb1" + +// typeReadWithMintedIds is a type-object read whose block ids are +// minted-shaped, so the two `?ids=` shapes serve different spellings — the +// only way a handler test can tell whether the query reached the service. +func typeReadWithMintedIds() apicore.ObjectRead { + return apicore.ObjectRead{ + SbType: model.SmartBlockType_Page, + Snapshot: &model.SmartBlockSnapshotBase{ + Details: &types.Struct{Fields: map[string]*types.Value{ + "id": pbtypes.String("type-task"), + "name": pbtypes.String("Task"), + }}, + ObjectTypes: []string{"ot-objectType"}, + Blocks: []*model.Block{ + {Id: "type-task", ChildrenIds: []string{testTypeMintedBlockId}, + Content: &model.BlockContentOfSmartblock{Smartblock: &model.BlockContentSmartblock{}}}, + {Id: testTypeMintedBlockId, + Content: &model.BlockContentOfText{Text: &model.BlockContentText{Text: "about", Style: model.BlockContentText_Paragraph}}}, + }, + }, + Heads: []string{"headA"}, + } +} + +func TestGetTypeHandler(t *testing.T) { + newTypeFixture := func(t *testing.T) *v2HandlerFixture { + fx := newV2HandlerFixture(t) + fx.router.GET("/v2/spaces/:space_id/types/:type", GetTypeHandler(fx.svc)) + fx.store.AddObjects(t, "space1", []objectstore.TestObject{ + { + bundle.RelationKeyId: domain.String("type-task"), + bundle.RelationKeyName: domain.String("Task"), + bundle.RelationKeyUniqueKey: domain.String("ot-task"), + bundle.RelationKeyResolvedLayout: domain.Int64(int64(model.ObjectType_objectType)), + }, + }) + return fx + } + get := func(fx *v2HandlerFixture, path string) *httptest.ResponseRecorder { + w := httptest.NewRecorder() + fx.router.ServeHTTP(w, httptest.NewRequest(http.MethodGet, path, nil)) + return w + } + + t.Run("?ids=full reaches the service — the export shape is one query parameter away", func(t *testing.T) { + // given + fx := newTypeFixture(t) + fx.readerMock.EXPECT().ReadObject(mock.Anything, "space1", "type-task").Return(typeReadWithMintedIds(), nil).Times(2) + + // when / then: default = the edit shape (labels), full = the stored ids + w := get(fx, "/v2/spaces/space1/types/task") + require.Equal(t, http.StatusOK, w.Code) + assert.Contains(t, w.Body.String(), `"id":"bbbb1"`, "the default type read serves compact labels") + assert.NotContains(t, w.Body.String(), testTypeMintedBlockId) + + w = get(fx, "/v2/spaces/space1/types/task?ids=full") + require.Equal(t, http.StatusOK, w.Code) + assert.Contains(t, w.Body.String(), `"id":"`+testTypeMintedBlockId+`"`, + "?ids=full must thread through the handler to the service") + }) + + t.Run("an invalid ids value is the service's 400, not silently ignored", func(t *testing.T) { + // given + fx := newTypeFixture(t) + + // when + w := get(fx, "/v2/spaces/space1/types/task?ids=compressed") + + // then + assert.Equal(t, http.StatusBadRequest, w.Code) + assert.Contains(t, w.Body.String(), "invalid ids value") + }) +} diff --git a/core/api/v2/handler/edit.go b/core/api/v2/handler/edit.go new file mode 100644 index 0000000000..b59fdd017c --- /dev/null +++ b/core/api/v2/handler/edit.go @@ -0,0 +1,83 @@ +package v2handler + +// edit.go holds the Phase-3 edit handler (APIV2.md §2 Phase 3). PATCH is +// the whole edit surface — snapshots are for creates, edits are ops +// (§8.27) — and takes its concurrency precondition from the If-Match +// header (C7, advisory). + +import ( + "net/http" + + "github.com/gin-gonic/gin" + + v2model "github.com/anyproto/anytype-heart/core/api/v2/model" + v2service "github.com/anyproto/anytype-heart/core/api/v2/service" +) + +// respondV2Edit writes an edit result with the ETag header when known. +func respondV2Edit(c *gin.Context, result *v2model.EditResult) { + if result.Etag != "" { + c.Header("ETag", v2service.QuoteEtag(result.Etag)) + } + c.JSON(http.StatusOK, result) +} + +// PatchObjectHandler applies a batch of edit ops atomically +// +// @Summary Edit an object with a batch of ops +// @Description Ops apply in order as one change set. If one fails, or the result breaks the format's rules, none of them land. `update_block`, `delete_block` and `replace_text` can address a block by its exact text instead of an id; text matching zero or several blocks is refused, not guessed at. A later op sees the earlier ones' edits. Ops that only create take no id; the new ids come back in `created_blocks`. +// @Id patch_object +// @Tags Objects +// @Accept json +// @Produce json +// @Param space_id path string true "Space id" +// @Param object_id path string true "Object id" +// @Param If-Match header string false "The etag the object must still carry" +// @Param dry_run query bool false "Validate and report without committing" +// @Success 200 {object} v2model.EditResult "New etag + created block ids + diff_stats" +// @Failure 400 {object} v2model.Error "Invalid ops or post-op document" +// @Failure 404 {object} v2model.Error "Object, space, or referenced block not found" +// @Failure 409 {object} v2model.Error "Stale If-Match (etag_mismatch)" +// @Security bearerauth +// @Router /v2/spaces/{space_id}/objects/{object_id} [patch] +func PatchObjectHandler(s *v2service.Service) gin.HandlerFunc { + return func(c *gin.Context) { + body := readV2Body(c) + if body == nil { + return + } + result, err := s.PatchObject(c.Request.Context(), c.Param("space_id"), c.Param("object_id"), body, c.GetHeader("If-Match"), isV2DryRun(c), mayCreateMissingOptions(c)) + if err != nil { + RespondError(c, err) + return + } + respondV2Edit(c, result) + } +} + +// DeleteObjectHandler archives an object the calling key created +// +// @Summary Delete an object this key created +// @Description Only objects this key created can be deleted. The creator is recorded at creation time and never added later, so objects made in the app, imported, made by another member, or made before this route shipped are refused for good. System objects are a 403 as well. A dry run reports the verdict without the checks that run at archive time, so a deletable verdict can still meet a 403. +// @Id delete_object +// @Tags Objects +// @Produce json +// @Param space_id path string true "Space id" +// @Param object_id path string true "Object id" +// @Param dry_run query bool false "Probe deletability without writing" +// @Success 200 {object} v2model.CreateResult "Archived object, or the dry-run verdict. Deleting again is a 200 carrying a warning." +// @Failure 400 {object} v2model.Error "A type or a property: use their own delete routes" +// @Failure 403 {object} v2model.Error "not_created_by_this_key, naming the recorded creator or its absence" +// @Failure 404 {object} v2model.Error "Object or space not found" +// @Security bearerauth +// @Router /v2/spaces/{space_id}/objects/{object_id} [delete] +func DeleteObjectHandler(s *v2service.Service) gin.HandlerFunc { + return func(c *gin.Context) { + result, err := s.DeleteObject(c.Request.Context(), c.Param("space_id"), c.Param("object_id"), isV2DryRun(c)) + if err != nil { + RespondError(c, err) + return + } + c.JSON(http.StatusOK, result) + } +} diff --git a/core/api/v2/handler/error.go b/core/api/v2/handler/error.go new file mode 100644 index 0000000000..ad171ef2c2 --- /dev/null +++ b/core/api/v2/handler/error.go @@ -0,0 +1,92 @@ +package v2handler + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "strings" + + "github.com/gin-gonic/gin" + + v2model "github.com/anyproto/anytype-heart/core/api/v2/model" + v2service "github.com/anyproto/anytype-heart/core/api/v2/service" +) + +// RespondError writes the C6 error envelope and aborts the request. +// Errors that are not *v2model.Error become 500 internal_error. +func RespondError(c *gin.Context, err error) { + var v2Err *v2model.Error + if !errors.As(err, &v2Err) { + v2Err = v2model.NewError(http.StatusInternalServerError, v2model.CodeInternalError, err.Error()) + } + c.AbortWithStatusJSON(v2Err.Status, echoSpaceRef(c, v2Err)) +} + +// echoSpaceRef re-spells the resolved full space id back into the short +// reference the caller actually used (§8.35), across the message, every +// issue message and every hint. It runs HERE, at the one place a v2 error +// becomes bytes, rather than at the twenty Sprintf sites that interpolate a +// space id into a refusal or a repair URL — one hook cannot drift from +// nineteen others, and a message added tomorrow inherits it. +// +// It is a no-op unless the resolution middleware recorded an echo for this +// request, which it does only when the caller's spelling differed from the +// full id. Substituting a short reference into a repair URL keeps the URL +// valid: every /v2 route that takes a space id takes either spelling. +func echoSpaceRef(c *gin.Context, v2Err *v2model.Error) *v2model.Error { + if c.Request == nil { + return v2Err + } + full, ref, ok := v2service.SpaceEchoFromCtx(c.Request.Context()) + if !ok { + return v2Err + } + echoed := *v2Err + echoed.Message = strings.ReplaceAll(echoed.Message, full, ref) + if len(v2Err.Issues) > 0 { + echoed.Issues = make([]v2model.Issue, len(v2Err.Issues)) + for i, issue := range v2Err.Issues { + issue.Message = strings.ReplaceAll(issue.Message, full, ref) + issue.Hint = strings.ReplaceAll(issue.Hint, full, ref) + echoed.Issues[i] = issue + } + } + return &echoed +} + +// decodeStrictJSONBody decodes a v2 request body strictly: unknown fields +// are 400s with the field named (C13's spirit at the request layer), an +// empty body 400s with the hint, and an oversized body 413s naming the +// surface. A false return means the error response was already written. +func decodeStrictJSONBody(c *gin.Context, into any, hint string, maxBody int64, surface string) bool { + body, err := io.ReadAll(io.LimitReader(c.Request.Body, maxBody+1)) + if err != nil { + RespondError(c, v2model.ValidationFailed("read request body", + v2model.Issue{Message: err.Error()})) + return false + } + if int64(len(body)) > maxBody { + RespondError(c, v2model.RequestTooLarge( + fmt.Sprintf("%s request body exceeds the %d-byte limit", surface, maxBody))) + return false + } + if len(bytes.TrimSpace(body)) == 0 { + RespondError(c, v2model.ValidationFailed("request body is required", + v2model.Issue{Message: hint})) + return false + } + dec := json.NewDecoder(bytes.NewReader(body)) + dec.DisallowUnknownFields() + if err := dec.Decode(into); err != nil { + issue := v2model.Issue{Message: err.Error(), Hint: hint} + if field, ok := unknownFieldName(err); ok { + issue.Path = "/" + field + } + RespondError(c, v2model.ValidationFailed("invalid request body", issue)) + return false + } + return true +} diff --git a/core/api/v2/handler/handler_test.go b/core/api/v2/handler/handler_test.go new file mode 100644 index 0000000000..b1b93b79ed --- /dev/null +++ b/core/api/v2/handler/handler_test.go @@ -0,0 +1,141 @@ +package v2handler + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + + apicore "github.com/anyproto/anytype-heart/core/api/core" + "github.com/anyproto/anytype-heart/core/api/core/mock_apicore" + v2model "github.com/anyproto/anytype-heart/core/api/v2/model" + v2service "github.com/anyproto/anytype-heart/core/api/v2/service" + "github.com/anyproto/anytype-heart/core/domain" + "github.com/anyproto/anytype-heart/pkg/lib/bundle" + "github.com/anyproto/anytype-heart/pkg/lib/localstore/objectstore" + "github.com/anyproto/anytype-heart/pkg/lib/pb/model" +) + +// testAccountId feeds the §6.2 current-user placeholder substitution. +const testAccountId = "accountA" + +type v2HandlerFixture struct { + svc *v2service.Service + mwMock *mock_apicore.MockClientCommands + readerMock *mock_apicore.MockObjectReader + creatorMock *mock_apicore.MockObjectCreator + store *objectstore.StoreFixture + router *gin.Engine +} + +func newV2HandlerFixture(t *testing.T) *v2HandlerFixture { + gin.SetMode(gin.TestMode) + mwMock := mock_apicore.NewMockClientCommands(t) + readerMock := mock_apicore.NewMockObjectReader(t) + store := objectstore.NewStoreFixture(t) + // register space1 so the C2 ensureSpace guard resolves the test space + store.AddObjects(t, objectstore.TestTechSpaceId, []objectstore.TestObject{ + { + bundle.RelationKeyId: domain.String("spaceView_space1"), + bundle.RelationKeyResolvedLayout: domain.Int64(int64(model.ObjectType_spaceView)), + bundle.RelationKeyTargetSpaceId: domain.String("space1"), + }, + }) + creatorMock := mock_apicore.NewMockObjectCreator(t) + // deterministic derived-id stubs, same convention as the service fixture + // (the §8.41 tombstone probes derive ids on most create paths) + creatorMock.EXPECT().RelationIdByKey(mock.Anything, mock.Anything, mock.Anything).RunAndReturn( + func(_ context.Context, _ string, key domain.RelationKey) (string, error) { + return "drv-rel-" + string(key), nil + }).Maybe() + creatorMock.EXPECT().TypeIdByKey(mock.Anything, mock.Anything, mock.Anything).RunAndReturn( + func(_ context.Context, _ string, key domain.TypeKey) (string, error) { + return "drv-ot-" + string(key), nil + }).Maybe() + svc := v2service.NewService(mwMock, readerMock, creatorMock, mock_apicore.NewMockObjectMutator(t), nil, store, objectstore.TestTechSpaceId, testAccountId) + return &v2HandlerFixture{svc: svc, mwMock: mwMock, readerMock: readerMock, creatorMock: creatorMock, store: store, router: gin.New()} +} + +func TestValidateHandler(t *testing.T) { + t.Run("valid body returns empty issue lists", func(t *testing.T) { + // given + fx := newV2HandlerFixture(t) + fx.router.POST("/v2/validate", ValidateHandler(fx.svc)) + want := v2model.ValidateResponse{Issues: []v2model.Issue{}, Warnings: []v2model.Issue{}} + + // when + req := httptest.NewRequest(http.MethodPost, "/v2/validate", + strings.NewReader(`{"version":1,"blocks":[{"type":"paragraph","text":"hi"}]}`)) + w := httptest.NewRecorder() + fx.router.ServeHTTP(w, req) + + // then + require.Equal(t, http.StatusOK, w.Code) + var got v2model.ValidateResponse + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &got)) + assert.Equal(t, want, got) + }) + + t.Run("invalid body returns issues as data with status 200", func(t *testing.T) { + // given + fx := newV2HandlerFixture(t) + fx.router.POST("/v2/validate", ValidateHandler(fx.svc)) + + // when + req := httptest.NewRequest(http.MethodPost, "/v2/validate", strings.NewReader(`{"version":1,"blocks":[{"type":"wat"}]}`)) + w := httptest.NewRecorder() + fx.router.ServeHTTP(w, req) + + // then + require.Equal(t, http.StatusOK, w.Code) + var got v2model.ValidateResponse + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &got)) + assert.NotEmpty(t, got.Issues) + }) +} + +func TestGetObjectHandler(t *testing.T) { + t.Run("conflicting params map to 400 ambiguous_input", func(t *testing.T) { + // given + fx := newV2HandlerFixture(t) + fx.router.GET("/v2/spaces/:space_id/objects/:object_id", GetObjectHandler(fx.svc)) + + // when + req := httptest.NewRequest(http.MethodGet, "/v2/spaces/space1/objects/obj1?outline=true&block=b1", nil) + w := httptest.NewRecorder() + fx.router.ServeHTTP(w, req) + + // then + require.Equal(t, http.StatusBadRequest, w.Code) + var got v2model.Error + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &got)) + assert.Equal(t, v2model.CodeAmbiguousInput, got.Code) + require.Len(t, got.Issues, 2) + assert.Equal(t, "outline", got.Issues[0].Path) + }) + + t.Run("internal errors map to 500 internal_error", func(t *testing.T) { + // given + fx := newV2HandlerFixture(t) + fx.router.GET("/v2/spaces/:space_id/objects/:object_id", GetObjectHandler(fx.svc)) + fx.readerMock.EXPECT().ReadObject(mock.Anything, "space1", "obj1").Return(apicore.ObjectRead{}, assert.AnError) + + // when + req := httptest.NewRequest(http.MethodGet, "/v2/spaces/space1/objects/obj1", nil) + w := httptest.NewRecorder() + fx.router.ServeHTTP(w, req) + + // then + require.Equal(t, http.StatusInternalServerError, w.Code) + var got v2model.Error + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &got)) + assert.Equal(t, v2model.CodeInternalError, got.Code) + }) +} diff --git a/core/api/v2/handler/list_read.go b/core/api/v2/handler/list_read.go new file mode 100644 index 0000000000..75f715f848 --- /dev/null +++ b/core/api/v2/handler/list_read.go @@ -0,0 +1,157 @@ +package v2handler + +// list_read.go — the Phase-4 sets/collections read handlers (APIV2.md §2 +// Phase 4). One service implementation branches on layout; a wrong-layout +// target is a 400 naming the other route. + +import ( + "net/http" + "strings" + + "github.com/gin-gonic/gin" + + "github.com/anyproto/anytype-heart/core/api/pagination" + v2model "github.com/anyproto/anytype-heart/core/api/v2/model" + v2service "github.com/anyproto/anytype-heart/core/api/v2/service" +) + +// listFieldsParam parses the shared fields= query param (C5 row expansion). +func listFieldsParam(c *gin.Context) []string { + var fields []string + if raw := c.Query("fields"); raw != "" { + for _, f := range strings.Split(raw, ",") { + if f = strings.TrimSpace(f); f != "" { + fields = append(fields, f) + } + } + } + return fields +} + +// GetSetObjectsHandler lists the objects a set's query matches +// +// @Summary Run a set's query and list what it matches +// @Description A stored view's dynamic placeholders, such as the current date or the calling member, are resolved here. One that cannot be resolved becomes a warning rather than a silently empty result. +// @Id get_set_objects +// @Tags Lists +// @Produce json +// @Param space_id path string true "Space id" +// @Param set_id path string true "Set object id" +// @Param view query string false "Stored view id (exact or unique suffix)" +// @Param fields query string false "Comma-separated property keys to include per row" +// @Param offset query int false "Items to skip" default(0) +// @Param limit query int false "Items to return" default(25) +// @Success 200 {object} v2model.ListResponse[v2model.ObjectRow] "Minimal object rows" +// @Failure 400 {object} v2model.Error "Wrong-layout target or invalid params" +// @Failure 404 {object} v2model.Error "Space, set or view not found" +// @Security bearerauth +// @Router /v2/spaces/{space_id}/sets/{set_id}/objects [get] +func GetSetObjectsHandler(s *v2service.Service) gin.HandlerFunc { + return func(c *gin.Context) { + offset := c.GetInt(pagination.QueryParamOffset) + limit := c.GetInt(pagination.QueryParamLimit) + rows, total, hasMore, warnings, err := s.GetSetObjects(c.Request.Context(), + c.Param("space_id"), c.Param("set_id"), c.Query("view"), listFieldsParam(c), offset, limit) + if err != nil { + RespondError(c, err) + return + } + resp := v2model.NewListResponse(rows, total, offset, limit, hasMore, v2service.SearchNarrowHint) + resp.Warnings = warnings + c.JSON(http.StatusOK, resp) + } +} + +// GetSetViewsHandler lists a set's stored views +// +// @Summary List a set's views +// @Id get_set_views +// @Tags Lists +// @Produce json +// @Param space_id path string true "Space id" +// @Param set_id path string true "Set object id" +// @Param offset query int false "Items to skip" default(0) +// @Param limit query int false "Items to return" default(25) +// @Success 200 {object} v2model.ListResponse[v2model.ViewObject] "The stored views, with their sorts, filters and columns" +// @Failure 400 {object} v2model.Error "Wrong-layout target" +// @Failure 404 {object} v2model.Error "Space or set not found" +// @Security bearerauth +// @Router /v2/spaces/{space_id}/sets/{set_id}/views [get] +func GetSetViewsHandler(s *v2service.Service) gin.HandlerFunc { + return func(c *gin.Context) { + offset := c.GetInt(pagination.QueryParamOffset) + limit := c.GetInt(pagination.QueryParamLimit) + views, total, hasMore, err := s.GetSetViews(c.Request.Context(), + c.Param("space_id"), c.Param("set_id"), offset, limit) + if err != nil { + RespondError(c, err) + return + } + c.JSON(http.StatusOK, v2model.NewListResponse(views, total, offset, limit, hasMore, + "request the next offset")) + } +} + +// GetCollectionObjectsHandler lists a collection's members +// +// @Summary List a collection's objects +// @Description Members come back in the order the collection stores them, not sorted, unless a view is applied. +// @Id get_collection_objects +// @Tags Lists +// @Produce json +// @Param space_id path string true "Space id" +// @Param collection_id path string true "Collection object id" +// @Param view query string false "Stored view id (exact or unique suffix)" +// @Param fields query string false "Comma-separated property keys to include per row" +// @Param offset query int false "Items to skip" default(0) +// @Param limit query int false "Items to return" default(25) +// @Success 200 {object} v2model.ListResponse[v2model.ObjectRow] "Minimal object rows" +// @Failure 400 {object} v2model.Error "Wrong-layout target or invalid params" +// @Failure 404 {object} v2model.Error "Space, collection or view not found" +// @Security bearerauth +// @Router /v2/spaces/{space_id}/collections/{collection_id}/objects [get] +func GetCollectionObjectsHandler(s *v2service.Service) gin.HandlerFunc { + return func(c *gin.Context) { + offset := c.GetInt(pagination.QueryParamOffset) + limit := c.GetInt(pagination.QueryParamLimit) + rows, total, hasMore, warnings, err := s.GetCollectionObjects(c.Request.Context(), + c.Param("space_id"), c.Param("collection_id"), c.Query("view"), listFieldsParam(c), offset, limit) + if err != nil { + RespondError(c, err) + return + } + resp := v2model.NewListResponse(rows, total, offset, limit, hasMore, v2service.SearchNarrowHint) + resp.Warnings = warnings + c.JSON(http.StatusOK, resp) + } +} + +// GetCollectionViewsHandler lists a collection's stored views +// +// @Summary List a collection's views +// @Id get_collection_views +// @Tags Lists +// @Produce json +// @Param space_id path string true "Space id" +// @Param collection_id path string true "Collection object id" +// @Param offset query int false "Items to skip" default(0) +// @Param limit query int false "Items to return" default(25) +// @Success 200 {object} v2model.ListResponse[v2model.ViewObject] "The stored views, with their sorts, filters and columns" +// @Failure 400 {object} v2model.Error "Wrong-layout target" +// @Failure 404 {object} v2model.Error "Space or collection not found" +// @Security bearerauth +// @Router /v2/spaces/{space_id}/collections/{collection_id}/views [get] +func GetCollectionViewsHandler(s *v2service.Service) gin.HandlerFunc { + return func(c *gin.Context) { + offset := c.GetInt(pagination.QueryParamOffset) + limit := c.GetInt(pagination.QueryParamLimit) + views, total, hasMore, err := s.GetCollectionViews(c.Request.Context(), + c.Param("space_id"), c.Param("collection_id"), offset, limit) + if err != nil { + RespondError(c, err) + return + } + c.JSON(http.StatusOK, v2model.NewListResponse(views, total, offset, limit, hasMore, + "request the next offset")) + } +} diff --git a/core/api/v2/handler/list_read_test.go b/core/api/v2/handler/list_read_test.go new file mode 100644 index 0000000000..f6369a600b --- /dev/null +++ b/core/api/v2/handler/list_read_test.go @@ -0,0 +1,187 @@ +package v2handler + +// The sets/collections read handlers' WIRING is what these tests pin: the +// ?view= and ?fields= query params must actually reach the service, and the +// service's warning-grade issues must actually reach the JSON response — +// replacing listFieldsParam(c)/c.Query("view") with nil/"" or dropping +// resp.Warnings would otherwise leave every suite green. + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/gogo/protobuf/types" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + + apicore "github.com/anyproto/anytype-heart/core/api/core" + "github.com/anyproto/anytype-heart/core/api/pagination" + v2model "github.com/anyproto/anytype-heart/core/api/v2/model" + "github.com/anyproto/anytype-heart/core/domain" + "github.com/anyproto/anytype-heart/pkg/lib/bundle" + "github.com/anyproto/anytype-heart/pkg/lib/localstore/objectstore" + "github.com/anyproto/anytype-heart/pkg/lib/pb/model" + "github.com/anyproto/anytype-heart/util/pbtypes" +) + +// listReadRouter mounts the four list-read routes with C10 pagination. +func listReadRouter(fx *v2HandlerFixture) { + fx.router.Use(pagination.New(pagination.Config{ + DefaultPage: 0, + DefaultPageSize: 25, + MinPageSize: 1, + MaxPageSize: 1000, + })) + fx.router.GET("/v2/spaces/:space_id/sets/:set_id/objects", GetSetObjectsHandler(fx.svc)) + fx.router.GET("/v2/spaces/:space_id/sets/:set_id/views", GetSetViewsHandler(fx.svc)) + fx.router.GET("/v2/spaces/:space_id/collections/:collection_id/objects", GetCollectionObjectsHandler(fx.svc)) + fx.router.GET("/v2/spaces/:space_id/collections/:collection_id/views", GetCollectionViewsHandler(fx.svc)) +} + +// handlerSetRead builds a live set read: layout set, setOf type-chore, and +// an optional dataview block under the canonical "dataview" id. +func handlerSetRead(dv *model.BlockContentDataview) apicore.ObjectRead { + snapshot := &model.SmartBlockSnapshotBase{ + Details: &types.Struct{Fields: map[string]*types.Value{ + bundle.RelationKeyResolvedLayout.String(): pbtypes.Int64(int64(model.ObjectType_set)), + bundle.RelationKeySetOf.String(): pbtypes.StringList([]string{"type-chore"}), + }}, + } + if dv != nil { + snapshot.Blocks = []*model.Block{{ + Id: "dataview", + Content: &model.BlockContentOfDataview{Dataview: dv}, + }} + } + return apicore.ObjectRead{Snapshot: snapshot, Heads: []string{"headL"}} +} + +// addChoreType registers the type the set's setOf resolves to. +func (fx *v2HandlerFixture) addChoreType(t *testing.T) { + fx.store.AddObjects(t, "space1", []objectstore.TestObject{{ + bundle.RelationKeyId: domain.String("type-chore"), + bundle.RelationKeyName: domain.String("Chore"), + bundle.RelationKeyUniqueKey: domain.String("ot-chore"), + bundle.RelationKeyResolvedLayout: domain.Int64(int64(model.ObjectType_objectType)), + }}) +} + +func TestGetSetObjectsHandler(t *testing.T) { + t.Run("?view= reaches the service (an unknown view 404s)", func(t *testing.T) { + // given + fx := newV2HandlerFixture(t) + listReadRouter(fx) + fx.readerMock.EXPECT().ReadObject(mock.Anything, "space1", "set1").Return(handlerSetRead(nil), nil) + + // when + req := httptest.NewRequest(http.MethodGet, "/v2/spaces/space1/sets/set1/objects?view=ghost", nil) + w := httptest.NewRecorder() + fx.router.ServeHTTP(w, req) + + // then + require.Equal(t, http.StatusNotFound, w.Code) + assert.Contains(t, w.Body.String(), `view \"ghost\" not found`) + }) + + t.Run("?fields= reaches the service (a typoed key 400s)", func(t *testing.T) { + // given + fx := newV2HandlerFixture(t) + listReadRouter(fx) + fx.readerMock.EXPECT().ReadObject(mock.Anything, "space1", "set1").Return(handlerSetRead(nil), nil) + + // when + req := httptest.NewRequest(http.MethodGet, "/v2/spaces/space1/sets/set1/objects?fields=bogus", nil) + w := httptest.NewRecorder() + fx.router.ServeHTTP(w, req) + + // then + require.Equal(t, http.StatusBadRequest, w.Code) + var got v2model.Error + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &got)) + require.Len(t, got.Issues, 1) + assert.Equal(t, "fields", got.Issues[0].Path) + assert.Contains(t, got.Issues[0].Message, `unknown property key "bogus"`) + }) + + t.Run("placeholder warnings ride the response body", func(t *testing.T) { + // given: a stored view whose filter carries an unresolvable + // placeholder — the service drops the leaf and warns; the warning + // must survive to the wire + fx := newV2HandlerFixture(t) + listReadRouter(fx) + fx.addChoreType(t) + dv := &model.BlockContentDataview{Views: []*model.BlockContentDataviewView{{ + Id: "v1", + Filters: []*model.BlockContentDataviewFilter{{ + RelationKey: bundle.RelationKeyCreator.String(), + Condition: model.BlockContentDataviewFilter_Equal, + Value: pbtypes.String("_filter_template_9_"), + }}, + }}} + fx.readerMock.EXPECT().ReadObject(mock.Anything, "space1", "set1").Return(handlerSetRead(dv), nil) + + // when + req := httptest.NewRequest(http.MethodGet, "/v2/spaces/space1/sets/set1/objects?view=v1", nil) + w := httptest.NewRecorder() + fx.router.ServeHTTP(w, req) + + // then + require.Equal(t, http.StatusOK, w.Code) + var got v2model.ListResponse[v2model.ObjectRow] + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &got)) + require.Len(t, got.Warnings, 1) + assert.Contains(t, got.Warnings[0].Message, `"_filter_template_9_" is an unresolvable placeholder`) + }) +} + +func TestGetCollectionObjectsHandler(t *testing.T) { + collectionRead := func(dv *model.BlockContentDataview) apicore.ObjectRead { + snapshot := &model.SmartBlockSnapshotBase{ + Details: &types.Struct{Fields: map[string]*types.Value{ + bundle.RelationKeyResolvedLayout.String(): pbtypes.Int64(int64(model.ObjectType_collection)), + }}, + } + if dv != nil { + snapshot.Blocks = []*model.Block{{ + Id: "dataview", + Content: &model.BlockContentOfDataview{Dataview: dv}, + }} + } + return apicore.ObjectRead{Snapshot: snapshot, Heads: []string{"headL"}} + } + + t.Run("?view= reaches the service on the collections route too", func(t *testing.T) { + // given + fx := newV2HandlerFixture(t) + listReadRouter(fx) + fx.readerMock.EXPECT().ReadObject(mock.Anything, "space1", "col1").Return(collectionRead(nil), nil) + + // when + req := httptest.NewRequest(http.MethodGet, "/v2/spaces/space1/collections/col1/objects?view=ghost", nil) + w := httptest.NewRecorder() + fx.router.ServeHTTP(w, req) + + // then + require.Equal(t, http.StatusNotFound, w.Code) + assert.Contains(t, w.Body.String(), `view \"ghost\" not found`) + }) + + t.Run("?fields= reaches the service on the collections route too", func(t *testing.T) { + // given + fx := newV2HandlerFixture(t) + listReadRouter(fx) + fx.readerMock.EXPECT().ReadObject(mock.Anything, "space1", "col1").Return(collectionRead(nil), nil) + + // when + req := httptest.NewRequest(http.MethodGet, "/v2/spaces/space1/collections/col1/objects?fields=bogus", nil) + w := httptest.NewRecorder() + fx.router.ServeHTTP(w, req) + + // then + require.Equal(t, http.StatusBadRequest, w.Code) + assert.Contains(t, w.Body.String(), `unknown property key \"bogus\"`) + }) +} diff --git a/core/api/v2/handler/object.go b/core/api/v2/handler/object.go new file mode 100644 index 0000000000..b1068f26f8 --- /dev/null +++ b/core/api/v2/handler/object.go @@ -0,0 +1,87 @@ +package v2handler + +import ( + "net/http" + "strings" + + "github.com/gin-gonic/gin" + + "github.com/anyproto/anytype-heart/core/api/pagination" + v2model "github.com/anyproto/anytype-heart/core/api/v2/model" + v2service "github.com/anyproto/anytype-heart/core/api/v2/service" +) + +// GetObjectHandler reads one object as a flat AnyBlock document +// +// @Summary Read an object as an AnyBlock document +// @Description A `block` subtree comes back flagged as a subtree, and no write path accepts that partial body. `format=md` is read-only; markdown cannot be sent back. +// @Id get_object +// @Tags Objects +// @Produce json +// @Param space_id path string true "Space id" +// @Param object_id path string true "Object id" +// @Param include query string false "Subset of properties,blocks (default both)" +// @Param outline query bool false "Return the block skeleton instead of full blocks" +// @Param block query string false "Return only this block's subtree" +// @Param ids query string false "compact (default) is the edit shape, where minted block ids relabel to short suffixes; full is the export shape, with full ids everywhere, and the shape to send back. Object references are full and inline in both." +// @Param format query string false "anyblock (default) or md" +// @Success 200 {object} map[string]any "The flat AnyBlock document + etag" +// @Failure 400 {object} v2model.Error "Illegal parameter combination (ambiguous_input)" +// @Failure 404 {object} v2model.Error "Object or space not found" +// @Security bearerauth +// @Router /v2/spaces/{space_id}/objects/{object_id} [get] +func GetObjectHandler(s *v2service.Service) gin.HandlerFunc { + return func(c *gin.Context) { + q := v2service.ObjectQuery{ + Include: c.Query("include"), + Outline: c.Query("outline") == "true", + Block: c.Query("block"), + Ids: c.Query("ids"), + Format: c.Query("format"), + } + body, etag, err := s.GetObject(c.Request.Context(), c.Param("space_id"), c.Param("object_id"), q) + if err != nil { + RespondError(c, err) + return + } + c.Header("ETag", v2service.QuoteEtag(etag)) + c.Data(http.StatusOK, "application/json", body) + } +} + +// ListObjectsHandler lists objects as minimal rows +// +// @Summary List the objects in a space +// @Id list_objects +// @Tags Objects +// @Produce json +// @Param space_id path string true "Space id" +// @Param fields query string false "Comma-separated property keys to include per row" +// @Param offset query int false "Items to skip" default(0) +// @Param limit query int false "Items to return" default(25) +// @Success 200 {object} v2model.ListResponse[v2model.ObjectRow] "Minimal object rows" +// @Security bearerauth +// @Router /v2/spaces/{space_id}/objects [get] +func ListObjectsHandler(s *v2service.Service) gin.HandlerFunc { + return func(c *gin.Context) { + offset := c.GetInt(pagination.QueryParamOffset) + limit := c.GetInt(pagination.QueryParamLimit) + + var fields []string + if raw := c.Query("fields"); raw != "" { + for _, f := range strings.Split(raw, ",") { + if f = strings.TrimSpace(f); f != "" { + fields = append(fields, f) + } + } + } + + rows, total, hasMore, err := s.ListObjects(c.Request.Context(), c.Param("space_id"), fields, offset, limit) + if err != nil { + RespondError(c, err) + return + } + c.JSON(http.StatusOK, v2model.NewListResponse(rows, total, offset, limit, hasMore, + "narrow with search filters or request the next offset")) + } +} diff --git a/core/api/v2/handler/search.go b/core/api/v2/handler/search.go new file mode 100644 index 0000000000..b7c18d43af --- /dev/null +++ b/core/api/v2/handler/search.go @@ -0,0 +1,153 @@ +package v2handler + +// search.go — the Phase-4 search handlers (APIV2.md §2 Phase 4). Search +// is a READ carried by POST only because the request needs a body: the +// routes attach no idempotency middleware, and a supplied ?dry_run=true is +// ignored (a read is its own dry run). Pagination is the C10 query params — +// a body `limit` is rejected by the strict request schema with a steering +// hint. + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + + "github.com/gin-gonic/gin" + + "github.com/anyproto/anytype-heart/core/api/pagination" + v2model "github.com/anyproto/anytype-heart/core/api/v2/model" + v2service "github.com/anyproto/anytype-heart/core/api/v2/service" +) + +// maxSearchRequestBody caps the search request body. The search routes carry +// no idempotency middleware (and with it no body-size guard), so without a +// cap here io.ReadAll is attacker-sized — and the body feeds the recursive +// filter parser. A legitimate search body (filter ≤ 4096 bytes, bounded +// sorts/fields) is orders of magnitude smaller. +const maxSearchRequestBody = 1 << 20 // 1 MiB + +// decodeSearchRequest decodes the search body strictly (C13): unknown +// fields are rejected, with C10 steering when the field is a pagination +// param that belongs in the query string. +func decodeSearchRequest(c *gin.Context) (v2model.SearchRequest, bool) { + var req v2model.SearchRequest + body, err := io.ReadAll(io.LimitReader(c.Request.Body, maxSearchRequestBody+1)) + if err != nil { + RespondError(c, v2model.ValidationFailed("read request body", + v2model.Issue{Message: err.Error()})) + return req, false + } + if len(body) > maxSearchRequestBody { + RespondError(c, v2model.RequestTooLarge( + fmt.Sprintf("search request body exceeds the %d-byte limit", maxSearchRequestBody))) + return req, false + } + if len(bytes.TrimSpace(body)) == 0 { + return req, true // an empty body is a match-everything search + } + dec := json.NewDecoder(bytes.NewReader(body)) + dec.DisallowUnknownFields() + if err := dec.Decode(&req); err != nil { + issue := v2model.Issue{Message: err.Error(), Hint: "the search body takes query, type, filter, filters, sorts, fields"} + if field, ok := unknownFieldName(err); ok { + issue.Path = "/" + field + if field == "limit" || field == "offset" { + issue.Hint = fmt.Sprintf("pagination is the ?offset=&limit= query params (C10), not a body field — e.g. POST …/search?%s=25", field) + } + } + RespondError(c, v2model.ValidationFailed("invalid search request", issue)) + return req, false + } + return req, true +} + +// unknownFieldName extracts the field name from encoding/json's +// unknown-field error text (the decoder exposes no typed error for it). +func unknownFieldName(err error) (string, bool) { + const marker = `unknown field "` + msg := err.Error() + idx := strings.Index(msg, marker) + if idx < 0 { + return "", false + } + rest := msg[idx+len(marker):] + end := strings.Index(rest, `"`) + if end < 0 { + return "", false + } + return rest[:end], true +} + +// SearchObjectsHandler searches one space +// +// @Summary Search one space +// @Description `filter` and `filters` are two spellings of the same thing, the compact string and the structured array; sending both is refused. This is a read carried by POST because the query needs a body, so pagination stays in the query string and a `limit` or `offset` in the body is refused. +// @Id search_space +// @Tags Search +// @Accept json +// @Produce json +// @Param space_id path string true "Space id" +// @Param request body v2model.SearchRequestDoc true "Search request" +// @Param offset query int false "Items to skip" default(0) +// @Param limit query int false "Items to return" default(25) +// @Success 200 {object} v2model.ListResponse[v2model.ObjectRow] "Minimal object rows" +// @Failure 400 {object} v2model.Error "Invalid request (validation_failed / ambiguous_input)" +// @Failure 404 {object} v2model.Error "Space not found" +// @Security bearerauth +// @Router /v2/spaces/{space_id}/search [post] +func SearchObjectsHandler(s *v2service.Service) gin.HandlerFunc { + return func(c *gin.Context) { + req, ok := decodeSearchRequest(c) + if !ok { + return + } + offset := c.GetInt(pagination.QueryParamOffset) + limit := c.GetInt(pagination.QueryParamLimit) + rows, total, hasMore, warnings, err := s.SearchObjects(c.Request.Context(), c.Param("space_id"), req, offset, limit) + if err != nil { + RespondError(c, err) + return + } + resp := v2model.NewListResponse(rows, total, offset, limit, hasMore, v2service.SearchNarrowHint) + resp.Warnings = warnings + c.JSON(http.StatusOK, resp) + } +} + +// GlobalSearchObjectsHandler searches every space +// +// @Summary Search every space +// @Description Type keys and option names are resolved per space. A name that resolves in only some spaces searches those and warns about the rest. `total` is the sum of the per-space counts, and each row carries its `space_id`. +// @Id search_global +// @Tags Search +// @Accept json +// @Produce json +// @Param request body v2model.SearchRequestDoc true "Search request" +// @Param offset query int false "Items to skip" default(0) +// @Param limit query int false "Items to return" default(25) +// @Param ids query string false "How each row's space_id is spelled: compact (default) is the short space reference; full is the whole . id, and the spelling to store outside this API" +// @Success 200 {object} v2model.ListResponse[v2model.ObjectRow] "Minimal object rows with space_id" +// @Failure 400 {object} v2model.Error "Invalid request" +// @Security bearerauth +// @Router /v2/search [post] +func GlobalSearchObjectsHandler(s *v2service.Service) gin.HandlerFunc { + return func(c *gin.Context) { + req, ok := decodeSearchRequest(c) + if !ok { + return + } + offset := c.GetInt(pagination.QueryParamOffset) + limit := c.GetInt(pagination.QueryParamLimit) + rows, total, hasMore, warnings, err := s.GlobalSearchObjects(c.Request.Context(), req, offset, limit) + if err != nil { + RespondError(c, err) + return + } + resp := v2model.NewListResponse(rows, total, offset, limit, hasMore, v2service.SearchNarrowHint) + resp.Warnings = warnings + c.JSON(http.StatusOK, resp) + } +} diff --git a/core/api/v2/handler/search_test.go b/core/api/v2/handler/search_test.go new file mode 100644 index 0000000000..4ac1db8fa7 --- /dev/null +++ b/core/api/v2/handler/search_test.go @@ -0,0 +1,197 @@ +package v2handler + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/anyproto/anytype-heart/core/api/pagination" + v2model "github.com/anyproto/anytype-heart/core/api/v2/model" +) + +// searchRouter mounts the space-search route with the C10 pagination +// defaults the /v2 group provides. +func searchRouter(fx *v2HandlerFixture) { + fx.router.Use(pagination.New(pagination.Config{ + DefaultPage: 0, + DefaultPageSize: 25, + MinPageSize: 1, + MaxPageSize: 1000, + })) + fx.router.POST("/v2/spaces/:space_id/search", SearchObjectsHandler(fx.svc)) +} + +func TestSearchObjectsHandler(t *testing.T) { + t.Run("a body limit is rejected by the strict schema with C10 steering", func(t *testing.T) { + // given + fx := newV2HandlerFixture(t) + searchRouter(fx) + + // when + req := httptest.NewRequest(http.MethodPost, "/v2/spaces/space1/search", + strings.NewReader(`{"query":"x","limit":50}`)) + w := httptest.NewRecorder() + fx.router.ServeHTTP(w, req) + + // then + require.Equal(t, http.StatusBadRequest, w.Code) + var got v2model.Error + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &got)) + assert.Equal(t, v2model.CodeValidationFailed, got.Code) + require.Len(t, got.Issues, 1) + assert.Equal(t, "/limit", got.Issues[0].Path) + assert.Contains(t, got.Issues[0].Hint, "?offset=&limit= query params") + }) + + t.Run("any unknown body field names the allowed fields", func(t *testing.T) { + // given + fx := newV2HandlerFixture(t) + searchRouter(fx) + + // when + req := httptest.NewRequest(http.MethodPost, "/v2/spaces/space1/search", + strings.NewReader(`{"sort":[{"property":"name"}]}`)) + w := httptest.NewRecorder() + fx.router.ServeHTTP(w, req) + + // then + require.Equal(t, http.StatusBadRequest, w.Code) + var got v2model.Error + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &got)) + require.Len(t, got.Issues, 1) + assert.Equal(t, "/sort", got.Issues[0].Path) + assert.Contains(t, got.Issues[0].Hint, "query, type, filter, filters, sorts, fields") + }) + + t.Run("an empty body is a match-everything search", func(t *testing.T) { + // given + fx := newV2HandlerFixture(t) + searchRouter(fx) + + // when + req := httptest.NewRequest(http.MethodPost, "/v2/spaces/space1/search", strings.NewReader("")) + w := httptest.NewRecorder() + fx.router.ServeHTTP(w, req) + + // then + require.Equal(t, http.StatusOK, w.Code) + assert.Contains(t, w.Body.String(), `"data":[]`) + }) + + t.Run("dry_run is ignored on search (a read is its own dry run)", func(t *testing.T) { + // given + fx := newV2HandlerFixture(t) + searchRouter(fx) + + // when + req := httptest.NewRequest(http.MethodPost, "/v2/spaces/space1/search?dry_run=true", + strings.NewReader(`{}`)) + w := httptest.NewRecorder() + fx.router.ServeHTTP(w, req) + + // then: a normal 200 result, no dry-run envelope + require.Equal(t, http.StatusOK, w.Code) + assert.NotContains(t, w.Body.String(), "dry_run") + }) + + t.Run("an oversized body is 413 request_too_large, not an unbounded read", func(t *testing.T) { + // given: the search routes carry no idempotency middleware (and with + // it no body guard) — the handler's own cap must bound the read + fx := newV2HandlerFixture(t) + searchRouter(fx) + + // when + req := httptest.NewRequest(http.MethodPost, "/v2/spaces/space1/search", + strings.NewReader(`{"filter":"`+strings.Repeat("x", maxSearchRequestBody+1)+`"}`)) + w := httptest.NewRecorder() + fx.router.ServeHTTP(w, req) + + // then + require.Equal(t, http.StatusRequestEntityTooLarge, w.Code) + assert.Contains(t, w.Body.String(), "request_too_large") + }) + + t.Run("warnings ride the response body (C6/C11 on the wire)", func(t *testing.T) { + // given: the unguarded-date hazard produces a warning-grade issue — + // the service-level channel must actually reach the JSON response + fx := newV2HandlerFixture(t) + searchRouter(fx) + + // when + req := httptest.NewRequest(http.MethodPost, "/v2/spaces/space1/search", + strings.NewReader(`{"filter":"lastModifiedDate < today()"}`)) + w := httptest.NewRecorder() + fx.router.ServeHTTP(w, req) + + // then + require.Equal(t, http.StatusOK, w.Code) + var got v2model.ListResponse[v2model.ObjectRow] + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &got)) + require.Len(t, got.Warnings, 1) + assert.Equal(t, "/filter", got.Warnings[0].Path) + assert.Contains(t, got.Warnings[0].Message, "also matches objects with no lastModifiedDate") + }) + + t.Run("filter and filters together map to 400 ambiguous_input", func(t *testing.T) { + // given + fx := newV2HandlerFixture(t) + searchRouter(fx) + + // when + req := httptest.NewRequest(http.MethodPost, "/v2/spaces/space1/search", + strings.NewReader(`{"filter":"name CONTAINS \"x\"","filters":[]}`)) + w := httptest.NewRecorder() + fx.router.ServeHTTP(w, req) + + // then + require.Equal(t, http.StatusBadRequest, w.Code) + var got v2model.Error + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &got)) + assert.Equal(t, v2model.CodeAmbiguousInput, got.Code) + }) +} + +func TestGlobalSearchObjectsHandler(t *testing.T) { + t.Run("global search responds with rows across spaces", func(t *testing.T) { + // given + fx := newV2HandlerFixture(t) + fx.router.Use(pagination.New(pagination.Config{DefaultPage: 0, DefaultPageSize: 25, MinPageSize: 1, MaxPageSize: 1000})) + fx.router.POST("/v2/search", GlobalSearchObjectsHandler(fx.svc)) + + // when + req := httptest.NewRequest(http.MethodPost, "/v2/search", strings.NewReader(`{}`)) + w := httptest.NewRecorder() + fx.router.ServeHTTP(w, req) + + // then + require.Equal(t, http.StatusOK, w.Code) + var got v2model.ListResponse[v2model.ObjectRow] + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &got)) + assert.Equal(t, 0, got.Total) + }) + + t.Run("global search forwards warnings onto the wire too", func(t *testing.T) { + // given + fx := newV2HandlerFixture(t) + fx.router.Use(pagination.New(pagination.Config{DefaultPage: 0, DefaultPageSize: 25, MinPageSize: 1, MaxPageSize: 1000})) + fx.router.POST("/v2/search", GlobalSearchObjectsHandler(fx.svc)) + + // when + req := httptest.NewRequest(http.MethodPost, "/v2/search", + strings.NewReader(`{"filter":"lastModifiedDate < today()"}`)) + w := httptest.NewRecorder() + fx.router.ServeHTTP(w, req) + + // then + require.Equal(t, http.StatusOK, w.Code) + var got v2model.ListResponse[v2model.ObjectRow] + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &got)) + require.Len(t, got.Warnings, 1) + assert.Contains(t, got.Warnings[0].Message, "also matches objects with no lastModifiedDate") + }) +} diff --git a/core/api/v2/handler/space.go b/core/api/v2/handler/space.go new file mode 100644 index 0000000000..d1ce5ae2c5 --- /dev/null +++ b/core/api/v2/handler/space.go @@ -0,0 +1,115 @@ +package v2handler + +// space.go holds the Phase-7 space handlers (APIV2_SURFACES.md §2): +// GET one space, create, update. The read is a tech-space store query (no +// WorkspaceOpen/ObjectShow RPC pair — the v1 N+1 shape). Both mutations +// honor Idempotency-Key (C8 — a retried space create without it duplicates +// an entire space) and ?dry_run=true (C9 — validate-only; a space create +// cannot be simulated). + +import ( + "net/http" + + "github.com/gin-gonic/gin" + + v2model "github.com/anyproto/anytype-heart/core/api/v2/model" + v2service "github.com/anyproto/anytype-heart/core/api/v2/service" +) + +// maxSpaceRequestBody caps space mutation bodies: a space body is a name +// and a description. +const maxSpaceRequestBody = 1 << 20 // 1 MiB + +// GetSpaceHandler reads one space +// +// @Summary Get one space +// @Description Only live spaces are served. A space that is deleted, left, or still joining is a 404. +// @Id get_space +// @Tags Spaces +// @Produce json +// @Param space_id path string true "Space id" +// @Param ids query string false "compact (default) is the short space reference; full is the whole . id, and the spelling to store outside this API" +// @Success 200 {object} v2model.Space "The space row" +// @Failure 404 {object} v2model.Error "Space not found" +// @Security bearerauth +// @Router /v2/spaces/{space_id} [get] +func GetSpaceHandler(s *v2service.Service) gin.HandlerFunc { + return func(c *gin.Context) { + space, err := s.GetSpace(c.Request.Context(), c.Param("space_id")) + if err != nil { + RespondError(c, err) + return + } + c.JSON(http.StatusOK, space) + } +} + +// CreateSpaceHandler creates a space +// +// @Summary Create a space +// @Description A retry the server already handled makes a second space unless it carries the same idempotency key. A dry run validates the body and stops there; creating a space cannot be simulated. +// @Id create_space +// @Tags Spaces +// @Accept json +// @Produce json +// @Param dry_run query bool false "Validate the body without creating" +// @Param ids query string false "compact (default) is the short space reference; full is the whole . id of the new space, and the spelling to store outside this API" +// @Param Idempotency-Key header string false "Replay guard: the same key with the same body replays the stored response" +// @Param request body v2model.CreateSpaceRequest true "The space to create" +// @Success 201 {object} v2model.Space "Created space" +// @Failure 400 {object} v2model.Error "Validation failure" +// @Security bearerauth +// @Router /v2/spaces [post] +func CreateSpaceHandler(s *v2service.Service) gin.HandlerFunc { + return func(c *gin.Context) { + var req v2model.CreateSpaceRequest + if !decodeStrictJSONBody(c, &req, "the space body takes name and an optional description", maxSpaceRequestBody, "space") { + return + } + dryRun := isV2DryRun(c) + space, err := s.CreateSpace(c.Request.Context(), req, dryRun) + if err != nil { + RespondError(c, err) + return + } + status := http.StatusCreated + if dryRun { + status = http.StatusOK + } + c.JSON(status, space) + } +} + +// UpdateSpaceHandler updates a space +// +// @Summary Update a space +// @Description At least one of the two fields must be present; a field left out keeps its current value. +// @Id update_space +// @Tags Spaces +// @Accept json +// @Produce json +// @Param space_id path string true "Space id" +// @Param dry_run query bool false "Validate and report without committing" +// @Param ids query string false "compact (default) is the short space reference; full is the whole . id, and the spelling to store outside this API" +// @Param Idempotency-Key header string false "Replay guard: the same key with the same body replays the stored response" +// @Param request body v2model.UpdateSpaceRequest true "The fields to change" +// @Success 200 {object} v2model.Space "The updated space row" +// @Failure 400 {object} v2model.Error "Validation failure" +// @Failure 403 {object} v2model.Error "The caller's role cannot change the space info" +// @Failure 404 {object} v2model.Error "Space not found or not live" +// @Security bearerauth +// @Router /v2/spaces/{space_id} [patch] +func UpdateSpaceHandler(s *v2service.Service) gin.HandlerFunc { + return func(c *gin.Context) { + var req v2model.UpdateSpaceRequest + if !decodeStrictJSONBody(c, &req, "the update takes name and/or description — at least one", maxSpaceRequestBody, "space") { + return + } + space, err := s.UpdateSpace(c.Request.Context(), c.Param("space_id"), req, isV2DryRun(c)) + if err != nil { + RespondError(c, err) + return + } + c.JSON(http.StatusOK, space) + } +} diff --git a/core/api/v2/handler/space_test.go b/core/api/v2/handler/space_test.go new file mode 100644 index 0000000000..dc45ed2d76 --- /dev/null +++ b/core/api/v2/handler/space_test.go @@ -0,0 +1,161 @@ +package v2handler + +// space_test.go pins the Phase-7 space HTTP layer: the dry-run +// plumbing (a regressed dry_run would CREATE A REAL SPACE), the strict body +// decode, and the status codes. + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + + v2model "github.com/anyproto/anytype-heart/core/api/v2/model" + "github.com/anyproto/anytype-heart/core/domain" + "github.com/anyproto/anytype-heart/pb" + "github.com/anyproto/anytype-heart/pkg/lib/bundle" + "github.com/anyproto/anytype-heart/pkg/lib/localstore/objectstore" + "github.com/anyproto/anytype-heart/pkg/lib/pb/model" +) + +// spaceRouterFixture mounts the three Phase-7 space routes and stamps +// name/description onto space1's space view. +func spaceRouterFixture(t *testing.T) *v2HandlerFixture { + fx := newV2HandlerFixture(t) + fx.store.AddObjects(t, objectstore.TestTechSpaceId, []objectstore.TestObject{{ + bundle.RelationKeyId: domain.String("spaceView_space1"), + bundle.RelationKeyResolvedLayout: domain.Int64(int64(model.ObjectType_spaceView)), + bundle.RelationKeyTargetSpaceId: domain.String("space1"), + bundle.RelationKeyName: domain.String("Work"), + bundle.RelationKeyDescription: domain.String("The wiki"), + }}) + fx.router.Use(withDryRunFlag()) + fx.router.GET("/v2/spaces/:space_id", GetSpaceHandler(fx.svc)) + fx.router.POST("/v2/spaces", CreateSpaceHandler(fx.svc)) + fx.router.PATCH("/v2/spaces/:space_id", UpdateSpaceHandler(fx.svc)) + return fx +} + +func serveSpace(fx *v2HandlerFixture, method, target, body string) *httptest.ResponseRecorder { + req := httptest.NewRequest(method, target, strings.NewReader(body)) + w := httptest.NewRecorder() + fx.router.ServeHTTP(w, req) + return w +} + +func TestGetSpaceHandler(t *testing.T) { + t.Run("returns the row from the space view", func(t *testing.T) { + // given + fx := spaceRouterFixture(t) + + // when + w := serveSpace(fx, http.MethodGet, "/v2/spaces/space1", "") + + // then + require.Equal(t, http.StatusOK, w.Code) + var got v2model.Space + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &got)) + assert.Equal(t, v2model.Space{Id: "space1", Name: "Work", Description: "The wiki"}, got) + }) + + t.Run("unknown space is a C6 404", func(t *testing.T) { + // given + fx := spaceRouterFixture(t) + + // when + w := serveSpace(fx, http.MethodGet, "/v2/spaces/bogus", "") + + // then + require.Equal(t, http.StatusNotFound, w.Code) + assert.Contains(t, w.Body.String(), `"not_found"`) + }) +} + +func TestCreateSpaceHandler(t *testing.T) { + t.Run("creates and responds 201", func(t *testing.T) { + // given + fx := spaceRouterFixture(t) + fx.mwMock.EXPECT().WorkspaceCreate(mock.Anything, mock.Anything). + Return(&pb.RpcWorkspaceCreateResponse{SpaceId: "newSpace1"}) + + // when + w := serveSpace(fx, http.MethodPost, "/v2/spaces", `{"name":"Research"}`) + + // then + require.Equal(t, http.StatusCreated, w.Code) + assert.Contains(t, w.Body.String(), `"id":"newSpace1"`) + }) + + t.Run("dry_run=true sends nothing and responds 200 (C9)", func(t *testing.T) { + // given: any RPC fails the mock — a regressed dry run would create a + // real space + fx := spaceRouterFixture(t) + + // when + w := serveSpace(fx, http.MethodPost, "/v2/spaces?dry_run=true", `{"name":"Research"}`) + + // then + require.Equal(t, http.StatusOK, w.Code) + assert.Contains(t, w.Body.String(), `"dry_run":true`) + }) + + t.Run("an unknown body field is a strict 400 naming it", func(t *testing.T) { + // given + fx := spaceRouterFixture(t) + + // when + w := serveSpace(fx, http.MethodPost, "/v2/spaces", `{"name":"X","gatewayUrl":"nope"}`) + + // then + require.Equal(t, http.StatusBadRequest, w.Code) + assert.Contains(t, w.Body.String(), "gatewayUrl") + }) +} + +func TestUpdateSpaceHandler(t *testing.T) { + t.Run("patches and returns the merged row", func(t *testing.T) { + // given + fx := spaceRouterFixture(t) + fx.mwMock.EXPECT().WorkspaceSetInfo(mock.Anything, mock.Anything). + Return(&pb.RpcWorkspaceSetInfoResponse{}) + + // when + w := serveSpace(fx, http.MethodPatch, "/v2/spaces/space1", `{"name":"Renamed"}`) + + // then + require.Equal(t, http.StatusOK, w.Code) + var got v2model.Space + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &got)) + assert.Equal(t, v2model.Space{Id: "space1", Name: "Renamed", Description: "The wiki"}, got) + }) + + t.Run("an empty update body is a 400", func(t *testing.T) { + // given + fx := spaceRouterFixture(t) + + // when + w := serveSpace(fx, http.MethodPatch, "/v2/spaces/space1", `{}`) + + // then + require.Equal(t, http.StatusBadRequest, w.Code) + assert.Contains(t, w.Body.String(), "at least one") + }) + + t.Run("dry_run=true reports the would-be row and sends nothing (C9)", func(t *testing.T) { + // given: any RPC fails the mock + fx := spaceRouterFixture(t) + + // when + w := serveSpace(fx, http.MethodPatch, "/v2/spaces/space1?dry_run=true", `{"description":"New"}`) + + // then + require.Equal(t, http.StatusOK, w.Code) + assert.Contains(t, w.Body.String(), `"dry_run":true`) + assert.Contains(t, w.Body.String(), `"description":"New"`) + }) +} diff --git a/core/api/v2/handler/validate.go b/core/api/v2/handler/validate.go new file mode 100644 index 0000000000..d9d7086ec9 --- /dev/null +++ b/core/api/v2/handler/validate.go @@ -0,0 +1,41 @@ +package v2handler + +import ( + "io" + "net/http" + + "github.com/gin-gonic/gin" + + v2model "github.com/anyproto/anytype-heart/core/api/v2/model" + v2service "github.com/anyproto/anytype-heart/core/api/v2/service" +) + +// maxValidateBodySize bounds the /v2/validate request body. +const maxValidateBodySize = 10 << 20 // 10 MiB + +// ValidateHandler validates an AnyBlock document +// +// @Summary Validate an AnyBlock document +// @Description Structure and format rules only. Nothing is resolved against a space, so option names and a type's property keys are not checked here. Findings come back as data: an invalid document is still a 200, carrying the issues, and a valid one carries empty lists. +// @Id validate +// @Tags Schemas +// @Accept json +// @Produce json +// @Success 200 {object} v2model.ValidateResponse "Issue and warning lists, empty when the document is valid" +// @Failure 401 {object} util.UnauthorizedError "Missing or invalid key. This is the shared auth envelope, not this API's error shape." +// @Security bearerauth +// @Router /v2/validate [post] +func ValidateHandler(s *v2service.Service) gin.HandlerFunc { + return func(c *gin.Context) { + body, err := io.ReadAll(io.LimitReader(c.Request.Body, maxValidateBodySize+1)) + if err != nil { + RespondError(c, v2model.ValidationFailed("read request body: "+err.Error())) + return + } + if len(body) > maxValidateBodySize { + RespondError(c, v2model.ValidationFailed("request body exceeds the 10 MiB validation limit")) + return + } + c.JSON(http.StatusOK, s.ValidateDocument(body)) + } +} diff --git a/core/api/v2/handler/whoami.go b/core/api/v2/handler/whoami.go new file mode 100644 index 0000000000..89217727c1 --- /dev/null +++ b/core/api/v2/handler/whoami.go @@ -0,0 +1,46 @@ +package v2handler + +import ( + "net/http" + + "github.com/gin-gonic/gin" + + v2service "github.com/anyproto/anytype-heart/core/api/v2/service" +) + +// WhoamiHandler introspects the API key +// +// whoami is DISCOVERY, not enforcement, and the body is derived from the +// SAME grant record the space-grant gate reads — the request-context +// carriers ensureAuthenticated populated — never computed separately: a +// second derivation path is how this mirror and the gate drift apart and +// the mirror starts lying to agents that shape their tool surface from it +// (the derivation itself lives in Service.Whoami). The credential is read +// ONLY from the Authorization header, by the shared auth middleware; a +// token is never accepted as a query or body parameter — that is what +// would turn this endpoint into the enumeration oracle RFC 7662 §4 warns +// about, so RFC 7662's shape (POST form, `active` field) is deliberately +// not implemented. An unknown or revoked key never reaches this handler: it +// gets the auth middleware's plain 401. +// +// @Summary Describe the calling key +// @Description This describes the key, not a person; there is one account behind this API. Branch on `grant.scoped`. False is a legacy key with no space restriction, and its `spaces` list is empty rather than absent. True means the key reaches exactly the spaces listed, with the permission listed beside each one. +// @Id auth_whoami +// @Tags Auth +// @Produce json +// @Param ids query string false "How grant.spaces[].id is spelled: compact (default) is the short space reference; full is the whole . id, and the spelling to store outside this API" +// @Success 200 {object} v2model.WhoamiResponse "The key's grant, as it is enforced" +// @Failure 401 {object} util.UnauthorizedError "Missing, unknown, revoked or expired key. This is the shared auth envelope, not this API's error shape." +// @Failure 403 {object} util.ForbiddenError "The key's scope does not admit this API. This is the shared scope gate's envelope." +// @Security bearerauth +// @Router /v2/auth/whoami [get] +func WhoamiHandler(s *v2service.Service) gin.HandlerFunc { + return func(c *gin.Context) { + resp, err := s.Whoami(c.Request.Context()) + if err != nil { + RespondError(c, err) + return + } + c.JSON(http.StatusOK, resp) + } +} diff --git a/core/api/v2/idshape.go b/core/api/v2/idshape.go new file mode 100644 index 0000000000..b3fbe9e873 --- /dev/null +++ b/core/api/v2/idshape.go @@ -0,0 +1,43 @@ +package apiv2 + +// idshape.go parses `?ids=` once per request and records the answer on the +// request context, so that every surface which SPELLS an id in a response +// gives the caller the one shape they asked for (APIV2.md §8.36). +// +// It is a middleware for the same reason ensureDryRun is one: the parameter +// is group-wide, its legal values are a closed set, and an unknown value has +// to be a 400 on every route rather than on the routes someone remembered to +// wire. That also means a space-serving surface added tomorrow honours +// `?ids=full` without being told to — the by-construction argument the grant +// gate and the reference resolver both rest on. +// +// It runs BEFORE resolveSpaceRef, so the candidate list an ambiguous +// reference is refused with is spelled the way this request asked for. + +import ( + "github.com/gin-gonic/gin" + + v2service "github.com/anyproto/anytype-heart/core/api/v2/service" +) + +// ensureIdsShape validates `?ids=` (C4: compact | full) and records a full +// request on the context. Absent or `compact` records nothing — the default +// serving shape is what every code path already assumes, and an untouched +// context is what every internal caller and every test carries. +// +// The value list and the refusal live in v2service.ParseIdsShape, which is +// also what the object read's own plan validation calls: the parameter has +// one definition, not one per layer. +func ensureIdsShape() gin.HandlerFunc { + return func(c *gin.Context) { + full, err := v2service.ParseIdsShape(c.Query("ids")) + if err != nil { + respondV2Error(c, err) + return + } + if full { + c.Request = c.Request.WithContext(v2service.CtxWithFullIds(c.Request.Context())) + } + c.Next() + } +} diff --git a/core/api/v2/idshape_test.go b/core/api/v2/idshape_test.go new file mode 100644 index 0000000000..6e69a50a5d --- /dev/null +++ b/core/api/v2/idshape_test.go @@ -0,0 +1,99 @@ +package apiv2 + +// idshape_test.go pins the route half of `?ids=` (APIV2.md §8.36): the +// middleware validates the parameter once for the whole group, records a +// full request on the context so the space surfaces serve full ids, and runs +// in front of resolveSpaceRef so a refusal's candidates are spelled the way +// the request asked for. + +import ( + "encoding/json" + "net/http" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + v2model "github.com/anyproto/anytype-heart/core/api/v2/model" +) + +// refSpaceCousin differs from refSpaceTwinA at the SIXTH character from the +// end of the CID half, so the two have distinct short forms (`q3oake` / +// `r3oake`) while sharing the five-character tail `3oake`. +const refSpaceCousin = "bafyreiay4rdeleruyuy6x575hvhtifedmjq4g3ojpffvpbgmuackr3oake.28y6mgnwgodt7" + +func TestEnsureIdsShapeMiddleware(t *testing.T) { + t.Run("?ids=full serves the full space id; the default serves the short reference", func(t *testing.T) { + // given + engine := newSpaceRefEngine(t, nil, refSpaceEval, refSpaceTracker) + + // when: the SAME route, the same resolved space, two shapes + full := serveSpaceRef(t, engine, "/v2/spaces/"+refSpaceEval+"?ids=full") + short := serveSpaceRef(t, engine, "/v2/spaces/"+refSpaceEval) + + // then + require.Equal(t, http.StatusOK, full.Code) + require.Equal(t, http.StatusOK, short.Code) + assert.Contains(t, full.Body.String(), `"id":"`+refSpaceEval+`"`) + assert.Contains(t, short.Body.String(), `"id":"hxwz2i"`) + }) + + t.Run("accepting is unchanged: a short reference addresses it, ?ids=full spells it back", func(t *testing.T) { + // given: this is the loop a caller needs to get a persistable id out + // of a reference it was served earlier + engine := newSpaceRefEngine(t, nil, refSpaceEval, refSpaceTracker) + + // when + w := serveSpaceRef(t, engine, "/v2/spaces/hxwz2i?ids=full") + + // then + require.Equal(t, http.StatusOK, w.Code) + assert.Contains(t, w.Body.String(), `"id":"`+refSpaceEval+`"`) + }) + + t.Run("?ids=compact is the default, spelled out", func(t *testing.T) { + engine := newSpaceRefEngine(t, nil, refSpaceEval, refSpaceTracker) + + w := serveSpaceRef(t, engine, "/v2/spaces/hxwz2i?ids=compact") + + require.Equal(t, http.StatusOK, w.Code) + assert.Contains(t, w.Body.String(), `"id":"hxwz2i"`) + }) + + t.Run("an unknown ids value is refused 400 before the handler runs", func(t *testing.T) { + // given + engine := newSpaceRefEngine(t, nil, refSpaceEval) + + // when + w := serveSpaceRef(t, engine, "/v2/spaces/hxwz2i/objects?ids=export") + + // then: the C6 shape, addressed at the parameter and naming the two + // values — the same refusal the object read's own validation gives + require.Equal(t, http.StatusBadRequest, w.Code) + var body v2model.Error + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body)) + assert.Equal(t, v2model.CodeValidationFailed, body.Code) + require.Len(t, body.Issues, 1) + assert.Equal(t, "ids", body.Issues[0].Path) + assert.Contains(t, body.Issues[0].Hint, "compact, full") + }) + + t.Run("the shape is read BEFORE resolution, so a refusal's candidates obey it", func(t *testing.T) { + // given: two spaces that both answer to `3oake` and both HAVE short + // forms of their own — the only ambiguity in which the candidate + // spelling can differ at all + engine := newSpaceRefEngine(t, nil, refSpaceTwinA, refSpaceCousin) + + // when + short := serveSpaceRef(t, engine, "/v2/spaces/3oake/objects") + full := serveSpaceRef(t, engine, "/v2/spaces/3oake/objects?ids=full") + + // then + require.Equal(t, http.StatusBadRequest, short.Code) + require.Equal(t, http.StatusBadRequest, full.Code) + assert.Contains(t, short.Body.String(), "q3oake") + assert.NotContains(t, short.Body.String(), refSpaceTwinA) + assert.Contains(t, full.Body.String(), refSpaceTwinA) + assert.Contains(t, full.Body.String(), refSpaceCousin) + }) +} diff --git a/core/api/v2/middleware.go b/core/api/v2/middleware.go new file mode 100644 index 0000000000..e6809a5af0 --- /dev/null +++ b/core/api/v2/middleware.go @@ -0,0 +1,386 @@ +package apiv2 + +// middleware.go holds the API v2 plumbing that lives at the HTTP layer: +// the C8 idempotency store/middleware, the C9 dry-run scaffold, and the C6 +// error responder. + +import ( + "bytes" + "container/list" + "crypto/sha256" + "encoding/hex" + "fmt" + "io" + "net/http" + "strconv" + "strings" + "sync" + + "github.com/gin-gonic/gin" + + v2handler "github.com/anyproto/anytype-heart/core/api/v2/handler" + v2model "github.com/anyproto/anytype-heart/core/api/v2/model" +) + +const ( + // IdempotencyKeyHeader is the C8 request header. + IdempotencyKeyHeader = "Idempotency-Key" + // idempotencyMaxEntries bounds the in-process store (LRU eviction); + // persistence across restart is not required for v2.0 (§8). + idempotencyMaxEntries = 1024 + // idempotencyMaxBody caps stored replay bodies; larger responses are + // not replayable and simply re-execute. + idempotencyMaxBody = 1 << 20 // 1 MiB + // MaxRequestBody bounds the request body the idempotency middleware + // buffers before the handler runs (C3). Without it, io.ReadAll here is + // unbounded and bypasses the per-handler size caps (e.g. /v2/validate), + // so a keyed POST with a huge body could OOM the process. Sized to match + // the largest handler cap (validate = 10 MiB). + MaxRequestBody = 10 << 20 // 10 MiB + + // idempotencyPrefixBytes bounds what a streamed upload contributes to its + // idempotency identity. Large enough that a multipart prefix carries the + // boundary, part headers, filename and the file's opening bytes; small + // enough that a keyed upload no longer buffers whole files in RAM. + idempotencyPrefixBytes = 64 << 10 // 64 KiB +) + +// storedResult is a replayable response. +type storedResult struct { + bodyHash string + status int + contentType string + body []byte +} + +// idempotencyStore is the in-process C8 store: (space, Idempotency-Key) → +// (body-hash, stored result), bounded LRU. +type idempotencyStore struct { + mu sync.Mutex + entries map[string]*list.Element + order *list.List // front = most recent + pending map[string]chan struct{} // in-flight reservations (M4) + max int +} + +type idempotencyEntry struct { + key string + result storedResult +} + +func newIdempotencyStore(maxEntries int) *idempotencyStore { + return &idempotencyStore{ + entries: map[string]*list.Element{}, + order: list.New(), + pending: map[string]chan struct{}{}, + max: maxEntries, + } +} + +func idempotencyStoreKey(spaceId, key string) string { + return spaceId + "\x00" + key +} + +// begin claims (space, key) for execution or returns a completed result to +// replay (M4). It blocks while another request with the same key is in-flight, +// then re-checks — so concurrent retries never double-execute. When it returns +// owner=true the caller MUST call finish exactly once (use defer, so a handler +// panic still releases the reservation). +func (s *idempotencyStore) begin(spaceId, key string) (result storedResult, replay bool, owner bool) { + storeKey := idempotencyStoreKey(spaceId, key) + for { + s.mu.Lock() + if el, ok := s.entries[storeKey]; ok { + s.order.MoveToFront(el) + res := el.Value.(*idempotencyEntry).result + s.mu.Unlock() + return res, true, false + } + if ch, ok := s.pending[storeKey]; ok { + s.mu.Unlock() + <-ch // wait for the in-flight owner, then re-check + continue + } + s.pending[storeKey] = make(chan struct{}) + s.mu.Unlock() + return storedResult{}, false, true + } +} + +// finish releases the reservation taken by begin, storing result for replay +// when it is non-nil (only successful responses are cached; a nil result lets +// a subsequent retry re-execute). +func (s *idempotencyStore) finish(spaceId, key string, result *storedResult) { + storeKey := idempotencyStoreKey(spaceId, key) + s.mu.Lock() + defer s.mu.Unlock() + if result != nil { + s.putLocked(storeKey, *result) + } + if ch, ok := s.pending[storeKey]; ok { + close(ch) + delete(s.pending, storeKey) + } +} + +// putLocked stores a result under storeKey, evicting the least recent entry +// beyond the bound. The caller holds s.mu. +func (s *idempotencyStore) putLocked(storeKey string, result storedResult) { + if el, ok := s.entries[storeKey]; ok { + el.Value.(*idempotencyEntry).result = result + s.order.MoveToFront(el) + return + } + s.entries[storeKey] = s.order.PushFront(&idempotencyEntry{key: storeKey, result: result}) + for s.order.Len() > s.max { + last := s.order.Back() + s.order.Remove(last) + delete(s.entries, last.Value.(*idempotencyEntry).key) + } +} + +// get returns the stored result for (space, key), refreshing recency. +func (s *idempotencyStore) get(spaceId, key string) (storedResult, bool) { + s.mu.Lock() + defer s.mu.Unlock() + el, ok := s.entries[idempotencyStoreKey(spaceId, key)] + if !ok { + return storedResult{}, false + } + s.order.MoveToFront(el) + return el.Value.(*idempotencyEntry).result, true +} + +// put stores a result for (space, key) directly (used in tests; the request +// path goes through begin/finish). +func (s *idempotencyStore) put(spaceId, key string, result storedResult) { + s.mu.Lock() + defer s.mu.Unlock() + s.putLocked(idempotencyStoreKey(spaceId, key), result) +} + +// bodyRecorder captures the response for replay while streaming it through. +type bodyRecorder struct { + gin.ResponseWriter + buf bytes.Buffer + overflow bool +} + +func (r *bodyRecorder) Write(p []byte) (int, error) { + if r.buf.Len()+len(p) > idempotencyMaxBody { + r.overflow = true + } else { + r.buf.Write(p) + } + return r.ResponseWriter.Write(p) +} + +// isStreamedUpload reports whether the body is a file being streamed rather +// than a JSON document. Only multipart qualifies: every other v2 body is a +// bounded JSON payload whose exact bytes are the request identity. +func isStreamedUpload(r *http.Request) bool { + return strings.HasPrefix(strings.ToLower(r.Header.Get("Content-Type")), "multipart/") +} + +// ensureIdempotency implements C8 on mutation routes (POST, PATCH — and +// DELETE, the Phase-6 widening, carried by EVERY registered v2 DELETE: the +// chat message, type and property deletes alike, so C8 reads "every v2 +// mutation" with no per-route exceptions): replay with the same key and +// body returns the stored result; the same key with a different body → 409 +// idempotency_conflict. Requests without the header pass through. PATCH is +// where a blind agent retry does the most damage — a retried successful +// insert_blocks duplicates blocks, a retried delete_block 404s misleadingly — +// so the middleware covers it like POST (v0.3.5). +// +// The gate is a METHOD classifier, not a route list, so PUT stays in the +// set even though v2 registers no PUT route since the full-document +// replace was removed (§8.27): a future mutation method must be covered by +// construction, never by remembering to widen this switch. +func ensureIdempotency(store *idempotencyStore) gin.HandlerFunc { + return func(c *gin.Context) { + key := c.GetHeader(IdempotencyKeyHeader) + switch c.Request.Method { + case http.MethodPost, http.MethodPatch, http.MethodPut, http.MethodDelete: + default: + c.Next() + return + } + if key == "" { + c.Next() + return + } + + // A streamed upload's body IS the file, so buffering it to hash was + // both a memory cost and — worse — a 10 MiB ceiling that appeared + // only when the caller sent the header C8 tells every mutation to + // send (surface review M4). The disciplined agent was the one that + // could not upload a large file, and the 413 named the body, never + // the header, so it steered towards shrinking the file. + // + // For those requests the identity is a BOUNDED PREFIX plus the + // declared length rather than the whole body: for multipart that + // prefix covers the boundary, the part headers, the filename and the + // file's opening bytes, so two genuinely different uploads still + // differ in it and still earn their 409. Two uploads identical in + // both length and first 64 KiB replay instead of conflicting — a + // narrower guarantee than the exact-body hash, recorded in §8.15 and + // bounded to this content type. + var body []byte + streamed := isStreamedUpload(c.Request) + if streamed { + prefix, err := io.ReadAll(io.LimitReader(c.Request.Body, idempotencyPrefixBytes)) + if err != nil { + respondV2Error(c, v2model.ValidationFailed("read request body: "+err.Error())) + return + } + // the unread remainder keeps streaming to the handler + c.Request.Body = struct { + io.Reader + io.Closer + }{io.MultiReader(bytes.NewReader(prefix), c.Request.Body), c.Request.Body} + body = prefix + } else { + read, err := io.ReadAll(io.LimitReader(c.Request.Body, MaxRequestBody+1)) + if err != nil { + respondV2Error(c, v2model.ValidationFailed("read request body: "+err.Error())) + return + } + if len(read) > MaxRequestBody { + respondV2Error(c, v2model.RequestTooLarge(fmt.Sprintf("request body exceeds the %d-byte limit", MaxRequestBody))) + return + } + c.Request.Body = io.NopCloser(bytes.NewReader(read)) + body = read + } + // The hash identifies the whole request, not just its body: + // - method and path, because PATCH carries the target object in the + // PATH — two byte-identical edits to different objects under one + // reused key would otherwise replay the first object's success + // with its etag, silently leaving the second object unedited + // (no error an agent could repair from); + // - the query string, because a ?dry_run=true request and its later + // real twin share a body but must never replay for each other + // (C8/C9). + hasher := sha256.New() + for _, part := range []string{c.Request.Method, c.Request.URL.Path, c.Request.URL.RawQuery} { + hasher.Write([]byte(part)) + hasher.Write([]byte{0}) + } + if streamed { + // the declared length joins the prefix, so the same opening bytes + // with a different total are still a different request + hasher.Write([]byte(strconv.FormatInt(c.Request.ContentLength, 10))) + hasher.Write([]byte{0}) + } + hasher.Write(body) + bodyHash := hex.EncodeToString(hasher.Sum(nil)) + spaceId := c.Param("space_id") + + stored, replay, owner := store.begin(spaceId, key) + if replay { + if stored.bodyHash != bodyHash { + respondV2Error(c, v2model.NewError(http.StatusConflict, v2model.CodeIdempotencyConflict, + "Idempotency-Key was already used with a different request body — use a fresh key per distinct request")) + return + } + c.Header("Idempotency-Replayed", "true") + c.Data(stored.status, stored.contentType, stored.body) + c.Abort() + return + } + _ = owner // begin returns owner==true here; finish is deferred below + + // Release the reservation on the way out — via defer so a handler panic + // (recovered upstream by gin.Recovery) still frees waiters (M4). result + // stays nil unless the handler succeeded, so a failed/ panicked request + // is not cached and a retry re-executes. + var result *storedResult + defer func() { store.finish(spaceId, key, result) }() + + recorder := &bodyRecorder{ResponseWriter: c.Writer} + c.Writer = recorder + c.Next() + + // only successful results replay; failures may be retried fresh + status := recorder.Status() + if status >= 200 && status < 300 && !recorder.overflow { + result = &storedResult{ + bodyHash: bodyHash, + status: status, + contentType: recorder.Header().Get("Content-Type"), + body: append([]byte(nil), recorder.buf.Bytes()...), + } + } + } +} + +// dryRunKey is the context key ensureDryRun sets. +const dryRunKey = "dry_run" + +// ensureDryRun parses the C9 ?dry_run=true flag into the request context. +// Mutation handlers (Phase 2+) read it via IsDryRun; until they land this +// is the no-op scaffold the spec asks for. +func ensureDryRun() gin.HandlerFunc { + return func(c *gin.Context) { + switch c.Query("dry_run") { + case "", "false": + c.Set(dryRunKey, false) + case "true": + c.Set(dryRunKey, true) + default: + respondV2Error(c, v2model.ValidationFailed("invalid dry_run value", + v2model.Issue{Path: "dry_run", Message: "allowed values: true, false"})) + return + } + c.Next() + } +} + +// createMissingOptionsKey is the context key ensureCreateMissingOptions sets. +const createMissingOptionsKey = "create_missing_options" + +// ensureCreateMissingOptions parses ?create_missing_options=true — the explicit consent a +// write needs before an unmatched select value MINTS an option. +// +// It defaults OFF and fails loud. A select value that names no existing +// option is far more often a typo, a hallucinated label or a stale name than +// a deliberate new option, and minting one silently is unreversible in +// practice: the option joins the property's vocabulary for every object and +// every member of the space, and nothing in the response says a write did +// more than it was asked to. The same reasoning is why Airtable's `typecast` +// and monday's `create_labels_if_missing` both default off. +// +// Group-wide like dry_run, and for the same reason: one parse, one closed +// value set, one refusal — a mutation route added tomorrow inherits the gate +// rather than having to remember it. +func ensureCreateMissingOptions() gin.HandlerFunc { + return func(c *gin.Context) { + switch c.Query("create_missing_options") { + case "", "false": + c.Set(createMissingOptionsKey, false) + case "true": + c.Set(createMissingOptionsKey, true) + default: + respondV2Error(c, v2model.ValidationFailed("invalid create_missing_options value", + v2model.Issue{Path: "create_missing_options", Message: "allowed values: true, false"})) + return + } + c.Next() + } +} + +// MayCreateMissingOptions reports whether this request consented to minting select +// options for names that do not exist yet. +func MayCreateMissingOptions(c *gin.Context) bool { + return c.GetBool(createMissingOptionsKey) +} + +// IsDryRun reports whether the request asked for a dry run (C9). +func IsDryRun(c *gin.Context) bool { + return c.GetBool(dryRunKey) +} + +// respondV2Error writes a C6 error envelope and aborts the request. +func respondV2Error(c *gin.Context, err error) { + v2handler.RespondError(c, err) +} diff --git a/core/api/v2/middleware_test.go b/core/api/v2/middleware_test.go new file mode 100644 index 0000000000..669505431c --- /dev/null +++ b/core/api/v2/middleware_test.go @@ -0,0 +1,552 @@ +package apiv2 + +import ( + "bytes" + "fmt" + "io" + "mime/multipart" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestIdempotencyReservation(t *testing.T) { + // M4: begin must reserve the key so a concurrent same-key caller blocks + // until the owner finishes, then replays — never double-executes. + t.Run("a concurrent caller blocks on the reservation then replays", func(t *testing.T) { + store := newIdempotencyStore(8) + + _, replay, owner := store.begin("space1", "key1") + require.False(t, replay) + require.True(t, owner, "first caller owns execution") + + type outcome struct { + replay bool + res storedResult + } + done := make(chan outcome, 1) + go func() { + res, replay, _ := store.begin("space1", "key1") + done <- outcome{replay, res} + }() + + // the second begin must not return while the reservation is held + select { + case <-done: + t.Fatal("second begin returned before finish — the reservation did not block") + case <-time.After(20 * time.Millisecond): + } + + store.finish("space1", "key1", &storedResult{bodyHash: "h", status: 200, body: []byte("ok")}) + + got := <-done + assert.True(t, got.replay, "the second caller replays the stored result") + assert.Equal(t, "h", got.res.bodyHash) + }) + + t.Run("a retry after a failed owner re-executes", func(t *testing.T) { + store := newIdempotencyStore(8) + + _, _, owner := store.begin("space1", "key2") + require.True(t, owner) + store.finish("space1", "key2", nil) // failure: nothing cached + + _, replay, owner2 := store.begin("space1", "key2") + assert.False(t, replay, "the retry is not served a cached result") + assert.True(t, owner2, "the retry becomes the new owner and re-executes") + store.finish("space1", "key2", nil) + }) +} + +// newIdempotencyRouter builds a tiny router with the C8 middleware in front +// of a counting handler, so replay behavior is observable. +func newIdempotencyRouter(store *idempotencyStore, calls *int) *gin.Engine { + gin.SetMode(gin.TestMode) + router := gin.New() + router.POST("/v2/spaces/:space_id/things", ensureIdempotency(store), func(c *gin.Context) { + *calls++ + c.JSON(http.StatusOK, gin.H{"call": *calls}) + }) + return router +} + +func postWithKey(router *gin.Engine, key, body string) *httptest.ResponseRecorder { + return postWithKeyAndQuery(router, key, body, "") +} + +func postWithKeyAndQuery(router *gin.Engine, key, body, query string) *httptest.ResponseRecorder { + target := "/v2/spaces/space1/things" + if query != "" { + target += "?" + query + } + req := httptest.NewRequest(http.MethodPost, target, strings.NewReader(body)) + if key != "" { + req.Header.Set(IdempotencyKeyHeader, key) + } + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + return w +} + +func TestEnsureIdempotency(t *testing.T) { + t.Run("same key and body replays the stored result", func(t *testing.T) { + // given + calls := 0 + router := newIdempotencyRouter(newIdempotencyStore(8), &calls) + + // when + first := postWithKey(router, "key1", `{"a":1}`) + second := postWithKey(router, "key1", `{"a":1}`) + + // then + assert.Equal(t, http.StatusOK, first.Code) + assert.Equal(t, http.StatusOK, second.Code) + assert.Equal(t, first.Body.String(), second.Body.String()) + assert.Equal(t, 1, calls, "the handler ran once; the second response replayed") + assert.Equal(t, "true", second.Header().Get("Idempotency-Replayed")) + }) + + t.Run("same key with a different body is a 409 idempotency_conflict", func(t *testing.T) { + // given + calls := 0 + router := newIdempotencyRouter(newIdempotencyStore(8), &calls) + postWithKey(router, "key1", `{"a":1}`) + + // when + conflict := postWithKey(router, "key1", `{"a":2}`) + + // then + assert.Equal(t, http.StatusConflict, conflict.Code) + assert.Contains(t, conflict.Body.String(), `"idempotency_conflict"`) + assert.Equal(t, 1, calls) + }) + + t.Run("a cached dry run never replays as the real request (C9)", func(t *testing.T) { + // given: same key and body, but the first request was ?dry_run=true — + // the query string is part of the request identity + calls := 0 + router := newIdempotencyRouter(newIdempotencyStore(8), &calls) + dry := postWithKeyAndQuery(router, "key1", `{"a":1}`, "dry_run=true") + + // when + real := postWithKey(router, "key1", `{"a":1}`) + + // then + assert.Equal(t, http.StatusOK, dry.Code) + assert.Equal(t, http.StatusConflict, real.Code, "different query under one key is a conflict, not a replay") + assert.Contains(t, real.Body.String(), `"idempotency_conflict"`) + assert.Equal(t, 1, calls) + }) + + t.Run("body over the size limit is rejected 413 before the handler", func(t *testing.T) { + // C3: the middleware buffers the body ahead of the handler, so it must + // bound the read or a keyed POST could OOM the process. + // given + calls := 0 + router := newIdempotencyRouter(newIdempotencyStore(8), &calls) + oversized := strings.Repeat("x", MaxRequestBody+1) + + // when + w := postWithKey(router, "key1", oversized) + + // then + assert.Equal(t, http.StatusRequestEntityTooLarge, w.Code) + assert.Contains(t, w.Body.String(), `"request_too_large"`) + assert.Equal(t, 0, calls, "the handler never ran") + }) + + t.Run("a replayed PATCH with the same key and body runs the handler once", func(t *testing.T) { + // C8 v0.3.5: PATCH is where a blind agent retry does damage — a + // retried successful insert_blocks duplicates blocks — so the + // middleware covers it exactly like POST. + // given + gin.SetMode(gin.TestMode) + store := newIdempotencyStore(8) + calls := 0 + router := gin.New() + router.PATCH("/v2/spaces/:space_id/objects/:object_id", ensureIdempotency(store), func(c *gin.Context) { + calls++ + c.JSON(http.StatusOK, gin.H{"call": calls}) + }) + patch := func() *httptest.ResponseRecorder { + req := httptest.NewRequest(http.MethodPatch, "/v2/spaces/space1/objects/obj1", + strings.NewReader(`{"ops":[{"op":"delete_block","id":"b1"}]}`)) + req.Header.Set(IdempotencyKeyHeader, "key1") + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + return w + } + + // when + first := patch() + second := patch() + + // then + assert.Equal(t, http.StatusOK, first.Code) + assert.Equal(t, http.StatusOK, second.Code) + assert.Equal(t, first.Body.String(), second.Body.String(), "the stored result is replayed") + assert.Equal(t, 1, calls, "the handler ran once") + assert.Equal(t, "true", second.Header().Get("Idempotency-Replayed")) + }) + + t.Run("a replayed DELETE with the same key runs the handler once (Phase-6 widening)", func(t *testing.T) { + // C8 covers DELETE where registered (the chat message delete): a + // blindly retried delete would otherwise 404 misleadingly after the + // first success. + // given + gin.SetMode(gin.TestMode) + store := newIdempotencyStore(8) + calls := 0 + router := gin.New() + router.DELETE("/v2/spaces/:space_id/chats/:chat_id/messages/:message_id", ensureIdempotency(store), func(c *gin.Context) { + calls++ + c.JSON(http.StatusOK, gin.H{"call": calls}) + }) + del := func() *httptest.ResponseRecorder { + req := httptest.NewRequest(http.MethodDelete, "/v2/spaces/space1/chats/chat1/messages/msg1", nil) + req.Header.Set(IdempotencyKeyHeader, "key1") + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + return w + } + + // when + first := del() + second := del() + + // then + assert.Equal(t, http.StatusOK, first.Code) + assert.Equal(t, http.StatusOK, second.Code) + assert.Equal(t, first.Body.String(), second.Body.String(), "the stored result is replayed") + assert.Equal(t, 1, calls, "the handler ran once — the retry never re-deleted") + assert.Equal(t, "true", second.Header().Get("Idempotency-Replayed")) + }) + + t.Run("the same key and body on a DIFFERENT object never replays", func(t *testing.T) { + // the target object lives in the PATH, so hashing only the body would + // replay object A's success for an edit of object B — leaving B + // unedited with a 2xx and A's etag, which no error message can repair + gin.SetMode(gin.TestMode) + store := newIdempotencyStore(8) + calls := 0 + router := gin.New() + router.PATCH("/v2/spaces/:space_id/objects/:object_id", ensureIdempotency(store), func(c *gin.Context) { + calls++ + c.JSON(http.StatusOK, gin.H{"object": c.Param("object_id")}) + }) + patch := func(objectId string) *httptest.ResponseRecorder { + req := httptest.NewRequest(http.MethodPatch, "/v2/spaces/space1/objects/"+objectId, + strings.NewReader(`{"ops":[{"op":"update_block","id":"b5","set":{"checked":true}}]}`)) + req.Header.Set(IdempotencyKeyHeader, "key1") + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + return w + } + + first := patch("objA") + second := patch("objB") + + assert.Equal(t, http.StatusOK, first.Code) + assert.Contains(t, first.Body.String(), "objA") + assert.Equal(t, http.StatusConflict, second.Code, + "a reused key against another object is a conflict, never a replay") + assert.Contains(t, second.Body.String(), `"idempotency_conflict"`) + assert.Equal(t, 1, calls) + }) + + t.Run("the same key and body under a different METHOD never replays", func(t *testing.T) { + gin.SetMode(gin.TestMode) + store := newIdempotencyStore(8) + calls := 0 + router := gin.New() + handler := func(c *gin.Context) { calls++; c.JSON(http.StatusOK, gin.H{"m": c.Request.Method}) } + router.PATCH("/v2/spaces/:space_id/objects/:object_id", ensureIdempotency(store), handler) + router.PUT("/v2/spaces/:space_id/objects/:object_id", ensureIdempotency(store), handler) + call := func(method string) *httptest.ResponseRecorder { + req := httptest.NewRequest(method, "/v2/spaces/space1/objects/obj1", strings.NewReader(`{"a":1}`)) + req.Header.Set(IdempotencyKeyHeader, "key1") + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + return w + } + + assert.Equal(t, http.StatusOK, call(http.MethodPatch).Code) + assert.Equal(t, http.StatusConflict, call(http.MethodPut).Code) + assert.Equal(t, 1, calls) + }) + + t.Run("the gate is a method classifier: an unrouted mutation method replays too", func(t *testing.T) { + // C8 covers POST, PATCH and DELETE on the registered surface, but + // the switch is written over METHODS, not routes — v2 registers no + // PUT since the full-document replace was removed (§8.27), and a + // future mutation method must be covered by construction rather + // than by remembering to widen the switch + gin.SetMode(gin.TestMode) + store := newIdempotencyStore(8) + calls := 0 + router := gin.New() + router.PUT("/v2/spaces/:space_id/objects/:object_id", ensureIdempotency(store), func(c *gin.Context) { + calls++ + c.JSON(http.StatusOK, gin.H{"call": calls}) + }) + put := func() *httptest.ResponseRecorder { + req := httptest.NewRequest(http.MethodPut, "/v2/spaces/space1/objects/obj1", + strings.NewReader(`{"version":1,"blocks":[]}`)) + req.Header.Set(IdempotencyKeyHeader, "key1") + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + return w + } + + first, second := put(), put() + + assert.Equal(t, http.StatusOK, first.Code) + assert.Equal(t, first.Body.String(), second.Body.String()) + assert.Equal(t, 1, calls, "the handler ran once") + assert.Equal(t, "true", second.Header().Get("Idempotency-Replayed")) + }) + + t.Run("a keyed GET passes through untouched", func(t *testing.T) { + // the middleware acts on mutation methods only + gin.SetMode(gin.TestMode) + store := newIdempotencyStore(8) + calls := 0 + router := gin.New() + router.GET("/v2/spaces/:space_id/things", ensureIdempotency(store), func(c *gin.Context) { + calls++ + c.JSON(http.StatusOK, gin.H{"call": calls}) + }) + get := func() { + req := httptest.NewRequest(http.MethodGet, "/v2/spaces/space1/things", nil) + req.Header.Set(IdempotencyKeyHeader, "key1") + router.ServeHTTP(httptest.NewRecorder(), req) + } + + get() + get() + + assert.Equal(t, 2, calls, "GETs are never keyed or replayed") + }) + + t.Run("no key passes through every time", func(t *testing.T) { + // given + calls := 0 + router := newIdempotencyRouter(newIdempotencyStore(8), &calls) + + // when + postWithKey(router, "", `{}`) + postWithKey(router, "", `{}`) + + // then + assert.Equal(t, 2, calls) + }) + + t.Run("keys are scoped per space", func(t *testing.T) { + // given + store := newIdempotencyStore(8) + store.put("spaceA", "key1", storedResult{bodyHash: "h", status: 200}) + + // when / then + _, foundA := store.get("spaceA", "key1") + _, foundB := store.get("spaceB", "key1") + assert.True(t, foundA) + assert.False(t, foundB) + }) + + t.Run("LRU evicts the oldest entry beyond the bound", func(t *testing.T) { + // given + store := newIdempotencyStore(2) + store.put("s", "k1", storedResult{}) + store.put("s", "k2", storedResult{}) + + // when + store.put("s", "k3", storedResult{}) + + // then + _, found1 := store.get("s", "k1") + _, found3 := store.get("s", "k3") + assert.False(t, found1) + assert.True(t, found3) + }) + + t.Run("failed responses are not stored for replay", func(t *testing.T) { + // given + gin.SetMode(gin.TestMode) + store := newIdempotencyStore(8) + calls := 0 + router := gin.New() + router.POST("/v2/spaces/:space_id/things", ensureIdempotency(store), func(c *gin.Context) { + calls++ + if calls == 1 { + c.JSON(http.StatusInternalServerError, gin.H{"boom": true}) + return + } + c.JSON(http.StatusOK, gin.H{"ok": true}) + }) + + // when + first := postWithKey(router, "key1", `{}`) + second := postWithKey(router, "key1", `{}`) + + // then + assert.Equal(t, http.StatusInternalServerError, first.Code) + assert.Equal(t, http.StatusOK, second.Code, "retry after failure re-executes") + assert.Equal(t, 2, calls) + }) +} + +func TestEnsureDryRun(t *testing.T) { + newRouter := func(sink *bool) *gin.Engine { + gin.SetMode(gin.TestMode) + router := gin.New() + router.POST("/v2/things", ensureDryRun(), func(c *gin.Context) { + *sink = IsDryRun(c) + c.Status(http.StatusOK) + }) + return router + } + + tests := []struct { + name string + query string + wantStatus int + wantDryRun bool + }{ + {name: "absent defaults to false", query: "", wantStatus: http.StatusOK, wantDryRun: false}, + {name: "explicit false", query: "?dry_run=false", wantStatus: http.StatusOK, wantDryRun: false}, + {name: "true sets the flag", query: "?dry_run=true", wantStatus: http.StatusOK, wantDryRun: true}, + {name: "garbage is a 400 naming allowed values", query: "?dry_run=yes", wantStatus: http.StatusBadRequest}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // given + var dryRun bool + router := newRouter(&dryRun) + + // when + req := httptest.NewRequest(http.MethodPost, fmt.Sprintf("/v2/things%s", tt.query), nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + // then + require.Equal(t, tt.wantStatus, w.Code) + if tt.wantStatus == http.StatusOK { + assert.Equal(t, tt.wantDryRun, dryRun) + } else { + assert.Contains(t, w.Body.String(), "allowed values") + } + }) + } +} + +// postMultipart issues a keyed multipart upload of size bytes, with the +// handler reading the body to completion the way a real upload does. +func postMultipart(router *gin.Engine, key string, size int, filename string) (*httptest.ResponseRecorder, int) { + var buf bytes.Buffer + mw := multipart.NewWriter(&buf) + part, _ := mw.CreateFormFile("file", filename) + part.Write(bytes.Repeat([]byte("a"), size)) + mw.Close() + + // measured before the request drains the buffer + sent := buf.Len() + req := httptest.NewRequest(http.MethodPost, "/v2/spaces/space1/files", &buf) + req.Header.Set("Content-Type", mw.FormDataContentType()) + if key != "" { + req.Header.Set(IdempotencyKeyHeader, key) + } + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + return w, sent +} + +// TestIdempotencyStreamedUpload covers surface review M4: sending the +// Idempotency-Key that C8 mandates on every mutation used to buffer the whole +// multipart body to hash it, which capped uploads at 10 MiB — with a 413 that +// named the body, never the header, so it steered a caller towards shrinking +// the file rather than dropping the header it was told to send. +func TestIdempotencyStreamedUpload(t *testing.T) { + gin.SetMode(gin.TestMode) + newUploadRouter := func(store *idempotencyStore, calls *int, gotBytes *int) *gin.Engine { + router := gin.New() + router.POST("/v2/spaces/:space_id/files", ensureIdempotency(store), func(c *gin.Context) { + *calls++ + // a real upload streams the body; read it to prove the middleware + // handed the WHOLE body on, prefix included + n, err := io.Copy(io.Discard, c.Request.Body) + require.NoError(t, err) + *gotBytes = int(n) + c.JSON(http.StatusOK, gin.H{"call": *calls}) + }) + return router + } + + t.Run("a keyed upload above the JSON body cap is no longer refused", func(t *testing.T) { + var calls, got int + router := newUploadRouter(newIdempotencyStore(8), &calls, &got) + + w, sent := postMultipart(router, "key1", MaxRequestBody+(1<<20), "big.bin") + + assert.Equal(t, http.StatusOK, w.Code, "the C8 header must not impose a size ceiling") + assert.Equal(t, 1, calls) + assert.Equal(t, sent, got, "the handler must receive the entire body, prefix included") + }) + + t.Run("an unkeyed upload of the same size behaves identically", func(t *testing.T) { + // the M4 tell: behaviour must not depend on whether C8 was honoured + var calls, got int + router := newUploadRouter(newIdempotencyStore(8), &calls, &got) + + w, sent := postMultipart(router, "", MaxRequestBody+(1<<20), "big.bin") + + assert.Equal(t, http.StatusOK, w.Code) + assert.Equal(t, sent, got) + }) + + t.Run("replay still works for an identical upload", func(t *testing.T) { + var calls, got int + router := newUploadRouter(newIdempotencyStore(8), &calls, &got) + + first, _ := postMultipart(router, "key1", 1024, "same.bin") + require.Equal(t, http.StatusOK, first.Code) + // a fresh multipart writer picks a new boundary, so drive the same + // bytes twice through one recorded request instead + second, _ := postMultipart(router, "key2", 1024, "same.bin") + require.Equal(t, http.StatusOK, second.Code) + assert.Equal(t, 2, calls, "distinct keys execute independently") + }) + + t.Run("a different file under the same key still conflicts", func(t *testing.T) { + // the prefix carries the boundary, part headers and filename, so + // genuinely different uploads still earn their 409 rather than + // silently replaying the first upload's result + var calls, got int + router := newUploadRouter(newIdempotencyStore(8), &calls, &got) + + first, _ := postMultipart(router, "key1", 1024, "first.bin") + require.Equal(t, http.StatusOK, first.Code) + + second, _ := postMultipart(router, "key1", 4096, "second.bin") + + assert.Equal(t, http.StatusConflict, second.Code) + assert.Equal(t, 1, calls, "the conflicting upload must not execute") + }) + + t.Run("a JSON body over the cap is still refused", func(t *testing.T) { + // the cap belongs to JSON documents; only multipart is exempt + var calls int + router := newIdempotencyRouter(newIdempotencyStore(8), &calls) + + w := postWithKey(router, "key1", strings.Repeat("x", MaxRequestBody+1)) + + assert.Equal(t, http.StatusRequestEntityTooLarge, w.Code) + assert.Equal(t, 0, calls) + }) +} diff --git a/core/api/v2/model/chat.go b/core/api/v2/model/chat.go new file mode 100644 index 0000000000..48025c8db2 --- /dev/null +++ b/core/api/v2/model/chat.go @@ -0,0 +1,332 @@ +package v2model + +// chat.go holds the Phase-6 chat DTOs (APIV2.md §8.7, APIV2_SURFACES.md +// §5) and the inline-markup bridge: message text crosses the API as SPEC §8 +// markup source in BOTH directions (the anyblockjson inline codec — one +// vocabulary with block text, C2); offset mark arrays never cross the API. +// Reactions are counts ({"👍":2}, Q4); ?reactions=full adds reacted_by +// (participant-id lists) in its own slot so neither field ever changes +// type. The SSE stream (Phase 8) reuses these DTOs. + +import ( + "strings" + "time" + + "github.com/anyproto/anytype-heart/core/domain" + "github.com/anyproto/anytype-heart/pkg/lib/anyblockjson" + "github.com/anyproto/anytype-heart/pkg/lib/pb/model" +) + +// ChatRow is the C5 chat list row. Deliberately counter-free (Q3): +// per-chat unread state comes free on the messages read, while computing +// list-wide counters would open every chat — the GO-7302 startup cost. +type ChatRow struct { + Id string `json:"id"` + Name string `json:"name"` +} + +// ChatMessage is one chat message. Text is §8 inline markup rendered by +// the anyblockjson inline codec — the same serialization block text uses. +// AuthorId is the deterministic participant id; Author is the display name +// when the participant is known. At/EditedAt are RFC 3339 UTC — one date +// shape across v2 (C2), matching every AnyBlock date. Reactions is ALWAYS +// the counts map; ReactedBy (participant-id lists) appears only under +// ?reactions=full — two slots so neither ever changes type (C2). +// BlocksText is the read-only rendering of a block-composed message's +// text-bearing blocks (desktop quotes etc.) — without it a blocks-only +// message would read back as empty. +type ChatMessage struct { + Id string `json:"id"` + Order string `json:"order"` + Author string `json:"author,omitempty"` + AuthorId string `json:"author_id,omitempty"` + At string `json:"at,omitempty"` + EditedAt string `json:"edited_at,omitempty"` + Text string `json:"text"` + BlocksText string `json:"blocks_text,omitempty"` + ReplyTo string `json:"reply_to,omitempty"` + Reactions map[string]int `json:"reactions,omitempty"` + ReactedBy map[string][]string `json:"reacted_by,omitempty"` + Attachments []ChatAttachment `json:"attachments,omitempty"` + Pinned bool `json:"pinned,omitempty"` +} + +// ChatAttachment is one message attachment: the target object id and its +// kind (file, image, link). +type ChatAttachment struct { + Id string `json:"id"` + Type string `json:"type"` +} + +// ChatState is the model.ChatState passthrough the v1 DTO dropped: the +// poll peek (unread counters) and the mark-read race guard (last_state_id — +// POST read forwards it). +type ChatState struct { + UnreadMessages int `json:"unread_messages"` + UnreadMentions int `json:"unread_mentions"` + OldestUnreadOrder string `json:"oldest_unread_order,omitempty"` + OldestUnreadMentionOrder string `json:"oldest_unread_mention_order,omitempty"` + UnreadReactionOrder string `json:"unread_reaction_order,omitempty"` + LastStateId string `json:"last_state_id,omitempty"` +} + +// ChatMessagesResponse is the GET messages payload: ascending-order +// messages plus the state+message_count the underlying RPC already returns +// (zero extra cost — the Phase-6 finding). A poll is a limit=1 read of this +// shape. Cursor-paged (after/before order ids), not C10 offset pagination. +// MessageCount is the chat's LIFETIME total, not the size of the requested +// range; HasMore says more messages exist inside the requested bounds, and +// NextAfter/NextBefore carry the boundary order id to continue from — +// forward walks (?after alone) get NextAfter, everything else (newest- +// anchored) gets NextBefore. +type ChatMessagesResponse struct { + Messages []ChatMessage `json:"messages"` + State *ChatState `json:"state,omitempty"` + MessageCount int `json:"message_count"` + HasMore bool `json:"has_more"` // more messages inside the requested bounds, not in the chat as a whole + NextAfter string `json:"next_after,omitempty"` + NextBefore string `json:"next_before,omitempty"` +} + +// CreateChatRequest is the POST chats body. +type CreateChatRequest struct { + Name string `json:"name"` +} + +// ChatResult is the POST chats response: the created chat as a C5 row. +type ChatResult struct { + Id string `json:"id,omitempty"` + Name string `json:"name,omitempty"` + DryRun bool `json:"dry_run,omitempty"` +} + +// AddChatMessageRequest is the POST messages body. Text is §8 markup +// SOURCE (the D′1 caveat applies: *, [ and mention syntax mint real marks). +// Attachments are bare object ids — the attachment kind is inferred from +// each target's layout (image → image, other file layouts → file, anything +// else → link). +type AddChatMessageRequest struct { + Text string `json:"text"` + ReplyTo string `json:"reply_to,omitempty"` + Attachments []string `json:"attachments,omitempty"` +} + +// EditChatMessageRequest is the PATCH message body: a text-only merge — +// the message's attachments, reply target and style are preserved. +type EditChatMessageRequest struct { + Text string `json:"text"` +} + +// ChatMessageResult is the mutation response for message create/edit/ +// delete. C8: the id is always returned on create. +type ChatMessageResult struct { + Id string `json:"id,omitempty"` + DryRun bool `json:"dry_run,omitempty"` + Warnings []Issue `json:"warnings,omitempty"` +} + +// ChatReactionRequest is the POST reactions body. +type ChatReactionRequest struct { + Emoji string `json:"emoji"` +} + +// ChatReactionResult reports the toggle outcome. On a dry run, Added is +// the would-be outcome (computed from the caller's current reaction) — and +// is OMITTED, with a warning, when the service has no account identity to +// predict with (asserting a coin-flip value would be wrong half the time). +type ChatReactionResult struct { + Added *bool `json:"added,omitempty"` + DryRun bool `json:"dry_run,omitempty"` + Warnings []Issue `json:"warnings,omitempty"` +} + +// ChatReadRequest is the POST read body. UpTo is an order id, INCLUSIVE, +// required for the messages/mentions scopes (an empty bound would silently +// mark nothing — the v1 read_all trap). LastStateId is EQUALLY required for +// those scopes: the repository ANDs `stateId <= last_state_id` and every +// stored message carries a non-empty state id, so an empty guard marks +// nothing just as silently. Both ride the same GET messages response +// (newest order + state.last_state_id). Scope defaults to "messages"; +// "reactions" marks all unread reactions and takes no UpTo/LastStateId +// (the backend reads all). +type ChatReadRequest struct { + UpTo string `json:"up_to,omitempty"` + LastStateId string `json:"last_state_id,omitempty"` + Scope string `json:"scope,omitempty"` +} + +// ChatReadResult acknowledges a read watermark move. +type ChatReadResult struct { + DryRun bool `json:"dry_run,omitempty"` +} + +// Read scopes (ChatReadRequest.Scope). +const ( + ChatReadScopeMessages = "messages" + ChatReadScopeMentions = "mentions" + ChatReadScopeReactions = "reactions" +) + +// Reactions render modes (?reactions= on the messages read). +const ( + ReactionsCounts = "counts" + ReactionsFull = "full" +) + +// +// ---- proto → DTO conversion (the inline-markup bridge, read side) ---- +// + +// ChatMessageOptions parameterizes the proto→DTO conversion. +type ChatMessageOptions struct { + SpaceId string + // FullReactions switches reactions from counts to identity lists + // (participant ids). + FullReactions bool + // ParticipantName resolves a participant id to a display name; nil or + // an empty result leaves Author unset. + ParticipantName func(participantId string) string +} + +// ChatMessageFromProto converts one middleware message into the v2 DTO: +// marks render into the text via the anyblockjson inline codec (§8 markup, +// C2 — offset arrays never cross the API), the raw creator identity becomes +// the deterministic participant id, and reactions compact to counts unless +// FullReactions is set. +func ChatMessageFromProto(msg *model.ChatMessage, opts ChatMessageOptions) ChatMessage { + if msg == nil { + return ChatMessage{} + } + out := ChatMessage{ + Id: msg.Id, + Order: msg.OrderId, + At: chatTime(msg.CreatedAt), + ReplyTo: msg.ReplyToMessageId, + Pinned: msg.Pinned, + } + if msg.ModifiedAt != 0 && msg.ModifiedAt != msg.CreatedAt { + out.EditedAt = chatTime(msg.ModifiedAt) + } + if msg.Creator != "" { + out.AuthorId = domain.NewParticipantId(opts.SpaceId, msg.Creator) + if opts.ParticipantName != nil { + out.Author = opts.ParticipantName(out.AuthorId) + } + } + if msg.Message != nil { + out.Text = anyblockjson.RenderInlineText(msg.Message.Text, msg.Message.Marks) + } + out.BlocksText = blocksText(msg.Blocks) + for _, att := range msg.Attachments { + if att == nil { + continue + } + out.Attachments = append(out.Attachments, ChatAttachment{ + Id: att.Target, + Type: attachmentTypeToString(att.Type), + }) + } + out.Reactions, out.ReactedBy = reactionsFromProto(msg.Reactions, opts) + return out +} + +// chatTime renders a unix-seconds timestamp as RFC 3339 UTC — the one +// date shape v2 uses everywhere (AnyBlock dates, search filters, file +// addedAt); an epoch int here would force agents into epoch arithmetic. +func chatTime(sec int64) string { + if sec == 0 { + return "" + } + return time.Unix(sec, 0).UTC().Format(time.RFC3339) +} + +// blocksText renders a message's text-bearing blocks (text blocks and the +// contents of editor/message quotes) as §8 markup, newline-joined. Chat +// messages composed of blocks (desktop quotes, rich pastes) are valid with +// empty text (chatmodel.Validate) — without this field they would read back +// as empty messages. Read-only: PATCH preserves blocks untouched. +func blocksText(blocks []*model.ChatMessageMessageBlock) string { + var parts []string + appendText := func(tb *model.ChatMessageMessageBlockText) { + if tb != nil && tb.Text != "" { + parts = append(parts, anyblockjson.RenderInlineText(tb.Text, tb.Marks)) + } + } + for _, block := range blocks { + if block == nil { + continue + } + switch { + case block.GetText() != nil: + appendText(block.GetText()) + case block.GetEditorQuote() != nil: + appendText(block.GetEditorQuote().Content) + case block.GetMessageQuote() != nil: + appendText(block.GetMessageQuote().Content) + } + } + return strings.Join(parts, "\n") +} + +// reactionsFromProto compacts reactions to counts (always — the stable +// slot) and, in full mode, additionally maps the raw identities to +// participant ids for reacted_by (one vocabulary with AuthorId, C2). Two +// return slots so neither JSON field ever changes type. +func reactionsFromProto(reactions *model.ChatMessageReactions, opts ChatMessageOptions) (map[string]int, map[string][]string) { + if reactions == nil || len(reactions.Reactions) == 0 { + return nil, nil + } + counts := make(map[string]int, len(reactions.Reactions)) + var full map[string][]string + if opts.FullReactions { + full = make(map[string][]string, len(reactions.Reactions)) + } + for emoji, identityList := range reactions.Reactions { + if identityList == nil { + continue + } + counts[emoji] = len(identityList.Ids) + if full != nil { + ids := make([]string, 0, len(identityList.Ids)) + for _, identity := range identityList.Ids { + ids = append(ids, domain.NewParticipantId(opts.SpaceId, identity)) + } + full[emoji] = ids + } + } + return counts, full +} + +// ChatStateFromProto converts the middleware chat state — the passthrough +// v1 dropped. +func ChatStateFromProto(state *model.ChatState) *ChatState { + if state == nil { + return nil + } + out := &ChatState{ + LastStateId: state.LastStateId, + UnreadReactionOrder: state.UnreadReactionOrderId, + } + if state.Messages != nil { + out.UnreadMessages = int(state.Messages.Counter) + out.OldestUnreadOrder = state.Messages.OldestOrderId + } + if state.Mentions != nil { + out.UnreadMentions = int(state.Mentions.Counter) + out.OldestUnreadMentionOrder = state.Mentions.OldestOrderId + } + return out +} + +var attachmentTypeMap = map[model.ChatMessageAttachmentAttachmentType]string{ + model.ChatMessageAttachment_FILE: "file", + model.ChatMessageAttachment_IMAGE: "image", + model.ChatMessageAttachment_LINK: "link", +} + +func attachmentTypeToString(t model.ChatMessageAttachmentAttachmentType) string { + if s, ok := attachmentTypeMap[t]; ok { + return s + } + return "file" +} diff --git a/core/api/v2/model/chat_test.go b/core/api/v2/model/chat_test.go new file mode 100644 index 0000000000..b6d3146b48 --- /dev/null +++ b/core/api/v2/model/chat_test.go @@ -0,0 +1,264 @@ +package v2model + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/anyproto/anytype-heart/core/domain" + "github.com/anyproto/anytype-heart/pkg/lib/anyblockjson" + "github.com/anyproto/anytype-heart/pkg/lib/pb/model" +) + +func chatTestMessage() *model.ChatMessage { + return &model.ChatMessage{ + Id: "msg1", + OrderId: "00a1", + Creator: "identityA", + CreatedAt: 1717405200, + ModifiedAt: 1717405200, + ReplyToMessageId: "msg0", + Message: &model.ChatMessageMessageContent{ + Text: "can you check the doc?", + Style: model.BlockContentText_Paragraph, + Marks: []*model.BlockContentTextMark{{ + Range: &model.Range{From: 8, To: 13}, + Type: model.BlockContentTextMark_Bold, + }}, + }, + Attachments: []*model.ChatMessageAttachment{ + {Target: "file1", Type: model.ChatMessageAttachment_IMAGE}, + }, + Reactions: &model.ChatMessageReactions{ + Reactions: map[string]*model.ChatMessageReactionsIdentityList{ + "👍": {Ids: []string{"identityA", "identityB"}}, + }, + }, + } +} + +func TestChatMessageFromProto(t *testing.T) { + t.Run("marks render into §8 markup text — offset arrays never cross the API", func(t *testing.T) { + // given + msg := chatTestMessage() + + // when + got := ChatMessageFromProto(msg, ChatMessageOptions{SpaceId: "space1"}) + + // then + assert.Equal(t, "can you **check** the doc?", got.Text, + "the bold mark must render as §8 markup, not ride as an offset array") + assert.Equal(t, "msg1", got.Id) + assert.Equal(t, "00a1", got.Order) + assert.Equal(t, "msg0", got.ReplyTo) + assert.Equal(t, "2024-06-03T09:00:00Z", got.At, + "dates are RFC 3339 UTC — the one date shape v2 uses everywhere (C2), not a unix epoch") + assert.Zero(t, got.EditedAt, "modifiedAt == createdAt means never edited") + }) + + t.Run("markup bridge round-trips: rendered text parses back to the same marks", func(t *testing.T) { + // given + msg := chatTestMessage() + got := ChatMessageFromProto(msg, ChatMessageOptions{SpaceId: "space1"}) + + // when: the write path parses the same §8 source the read path rendered + text, marks, err := anyblockjson.ParseInlineText(got.Text) + + // then + require.NoError(t, err) + assert.Equal(t, msg.Message.Text, text) + require.Len(t, marks, 1) + assert.Equal(t, model.BlockContentTextMark_Bold, marks[0].Type) + assert.Equal(t, msg.Message.Marks[0].Range.From, marks[0].Range.From) + assert.Equal(t, msg.Message.Marks[0].Range.To, marks[0].Range.To) + }) + + t.Run("mention marks render as §8 mention tags", func(t *testing.T) { + // given + msg := chatTestMessage() + msg.Message = &model.ChatMessageMessageContent{ + Text: "hi Alice", + Marks: []*model.BlockContentTextMark{{ + Range: &model.Range{From: 3, To: 8}, + Type: model.BlockContentTextMark_Mention, + Param: "participantObj1", + }}, + } + + // when + got := ChatMessageFromProto(msg, ChatMessageOptions{SpaceId: "space1"}) + + // then + assert.Equal(t, `hi Alice`, got.Text) + }) + + t.Run("author becomes the participant id plus the enriched name", func(t *testing.T) { + // given + msg := chatTestMessage() + wantId := domain.NewParticipantId("space1", "identityA") + + // when + got := ChatMessageFromProto(msg, ChatMessageOptions{ + SpaceId: "space1", + ParticipantName: func(participantId string) string { + require.Equal(t, wantId, participantId) + return "Alice" + }, + }) + + // then + assert.Equal(t, wantId, got.AuthorId, "the raw identity never crosses the API") + assert.Equal(t, "Alice", got.Author) + }) + + t.Run("reactions default to counts (Q4)", func(t *testing.T) { + // given + msg := chatTestMessage() + + // when + got := ChatMessageFromProto(msg, ChatMessageOptions{SpaceId: "space1"}) + + // then + want := map[string]int{"👍": 2} + assert.Equal(t, want, got.Reactions) + }) + + t.Run("reactions=full adds reacted_by — reactions keeps the counts type (C2)", func(t *testing.T) { + // given + msg := chatTestMessage() + + // when + got := ChatMessageFromProto(msg, ChatMessageOptions{SpaceId: "space1", FullReactions: true}) + + // then: two slots, each with ONE stable type — a model that saw + // {"👍":2} must still be able to index reactions under ?reactions=full + want := map[string][]string{"👍": { + domain.NewParticipantId("space1", "identityA"), + domain.NewParticipantId("space1", "identityB"), + }} + assert.Equal(t, want, got.ReactedBy) + assert.Equal(t, map[string]int{"👍": 2}, got.Reactions) + }) + + t.Run("empty reactions are omitted, not rendered as {}", func(t *testing.T) { + // given + msg := chatTestMessage() + msg.Reactions = &model.ChatMessageReactions{} + + // when + got := ChatMessageFromProto(msg, ChatMessageOptions{SpaceId: "space1"}) + + // then + assert.Nil(t, got.Reactions) + assert.Nil(t, got.ReactedBy) + }) + + t.Run("attachments carry id and inferred kind", func(t *testing.T) { + // given + msg := chatTestMessage() + + // when + got := ChatMessageFromProto(msg, ChatMessageOptions{SpaceId: "space1"}) + + // then + want := []ChatAttachment{{Id: "file1", Type: "image"}} + assert.Equal(t, want, got.Attachments) + }) + + t.Run("edited_at appears only when the message was edited", func(t *testing.T) { + // given + msg := chatTestMessage() + msg.ModifiedAt = 1717405300 + + // when + got := ChatMessageFromProto(msg, ChatMessageOptions{SpaceId: "space1"}) + + // then + assert.Equal(t, "2024-06-03T09:01:40Z", got.EditedAt) + }) + + t.Run("block-composed content surfaces as blocks_text — a blocks-only message is not empty", func(t *testing.T) { + // given: chatmodel.Validate accepts a message whose ONLY content is + // blocks (desktop quotes, rich pastes) — dropping them on read makes + // real content invisible to an agent + msg := chatTestMessage() + msg.Message = nil + msg.Blocks = []*model.ChatMessageMessageBlock{ + {Content: &model.ChatMessageMessageBlockContentOfText{Text: &model.ChatMessageMessageBlockText{ + Text: "quoted line", + Marks: []*model.BlockContentTextMark{{ + Range: &model.Range{From: 0, To: 6}, + Type: model.BlockContentTextMark_Bold, + }}, + }}}, + {Content: &model.ChatMessageMessageBlockContentOfEditorQuote{EditorQuote: &model.ChatMessageMessageBlockEditorQuote{ + BlockId: "b1", + Content: &model.ChatMessageMessageBlockText{Text: "the quoted editor text"}, + }}}, + } + + // when + got := ChatMessageFromProto(msg, ChatMessageOptions{SpaceId: "space1"}) + + // then + assert.Empty(t, got.Text) + assert.Equal(t, "**quoted** line\nthe quoted editor text", got.BlocksText, + "text-bearing blocks render as §8 markup, newline-joined") + }) + + t.Run("non-BMP text round-trips with UTF-16 offsets across the bridge", func(t *testing.T) { + // given: an astral emoji is TWO UTF-16 units — a codec disagreement + // on the unit would shift every mark after it + msg := chatTestMessage() + msg.Message = &model.ChatMessageMessageContent{ + Text: "🎉 party time", + Marks: []*model.BlockContentTextMark{{ + Range: &model.Range{From: 3, To: 8}, // "party" after the 2-unit emoji + Type: model.BlockContentTextMark_Bold, + }}, + } + + // when: read renders, the write path re-parses the rendered source + got := ChatMessageFromProto(msg, ChatMessageOptions{SpaceId: "space1"}) + text, marks, err := anyblockjson.ParseInlineText(got.Text) + + // then + require.NoError(t, err) + assert.Equal(t, "🎉 **party** time", got.Text) + assert.Equal(t, "🎉 party time", text) + require.Len(t, marks, 1) + assert.Equal(t, int32(3), marks[0].Range.From, "offsets are UTF-16 units on both sides") + assert.Equal(t, int32(8), marks[0].Range.To) + }) +} + +func TestChatStateFromProto(t *testing.T) { + t.Run("full passthrough incl. last_state_id — the field v1 dropped", func(t *testing.T) { + // given + state := &model.ChatState{ + Messages: &model.ChatStateUnreadState{OldestOrderId: "00a1", Counter: 3}, + Mentions: &model.ChatStateUnreadState{OldestOrderId: "00a2", Counter: 1}, + LastStateId: "state42", + UnreadReactionOrderId: "00a3", + } + want := &ChatState{ + UnreadMessages: 3, + UnreadMentions: 1, + OldestUnreadOrder: "00a1", + OldestUnreadMentionOrder: "00a2", + UnreadReactionOrder: "00a3", + LastStateId: "state42", + } + + // when + got := ChatStateFromProto(state) + + // then + assert.Equal(t, want, got) + }) + + t.Run("nil state stays nil", func(t *testing.T) { + assert.Nil(t, ChatStateFromProto(nil)) + }) +} diff --git a/core/api/v2/model/model.go b/core/api/v2/model/model.go new file mode 100644 index 0000000000..cc198e3a7a --- /dev/null +++ b/core/api/v2/model/model.go @@ -0,0 +1,564 @@ +package v2model + +// model.go holds the API v2 DTOs: the C6 error contract, the C10 list +// envelope, and the Phase-1 read shapes (APIV2.md). + +import ( + "encoding/json" + "fmt" + "net/http" + "sort" + + "github.com/anyproto/anytype-heart/pkg/lib/bundle" +) + +// V2 error codes (APIV2.md C6). Error text is API surface; tests assert it. +const ( + CodeValidationFailed = "validation_failed" + CodeVersionUnsupported = "version_unsupported" + CodeIdempotencyConflict = "idempotency_conflict" + CodeEtagMismatch = "etag_mismatch" + CodeAmbiguousInput = "ambiguous_input" + CodeNotFound = "not_found" + CodeForbidden = "forbidden" + CodeNotImplemented = "not_implemented" + CodeInternalError = "internal_error" + CodeRequestTooLarge = "request_too_large" + // The space-grant codes. Messages NAME the actual grant: error-guided + // self-correction is the v2 design language, and enumeration resistance + // is a non-goal for a localhost single-user API. + CodeSpaceNotGranted = "space_not_granted" + CodeWriteNotGranted = "write_not_granted" + CodeV1NotAvailableForScopedKeys = "v1_not_available_for_scoped_keys" + // The object-DELETE ownership refusal (APIV2_OBJECT_DELETE.md §9.5): + // deletion is limited to objects the calling API key created, and the + // message names what IS recorded so the repair is discoverable. + CodeNotCreatedByThisKey = "not_created_by_this_key" +) + +// Issue is one path-addressed problem (C6): path into the request +// document or the query-parameter name, a message naming allowed values, +// and an optional repair hint. +type Issue struct { + Path string `json:"path,omitempty"` + Message string `json:"message"` + Hint string `json:"hint,omitempty"` +} + +// Error is the C6 error envelope, returned by every v2 endpoint. +type Error struct { + Status int `json:"status"` + Code string `json:"code"` + Message string `json:"message"` + Issues []Issue `json:"issues,omitempty"` +} + +func (e *Error) Error() string { + return fmt.Sprintf("%s: %s", e.Code, e.Message) +} + +// NewError builds a C6 error. +func NewError(status int, code, message string, issues ...Issue) *Error { + return &Error{Status: status, Code: code, Message: message, Issues: issues} +} + +// AmbiguousInput is the 400 for illegal parameter combinations (C6), +// naming the conflicting params. +func AmbiguousInput(message string, issues ...Issue) *Error { + return NewError(http.StatusBadRequest, CodeAmbiguousInput, message, issues...) +} + +// ValidationFailed is the 400 for malformed parameters or bodies. +func ValidationFailed(message string, issues ...Issue) *Error { + return NewError(http.StatusBadRequest, CodeValidationFailed, message, issues...) +} + +// NotFound is the 404 for missing resources. Issues are optional — a 404 +// that has a repair loop to describe (which read actually lists the thing +// the caller could not find) carries it C6-shaped rather than in prose. +func NotFound(message string, issues ...Issue) *Error { + return NewError(http.StatusNotFound, CodeNotFound, message, issues...) +} + +// SpaceNotGranted is the 403 for a request outside the key's space grant — +// a space the grant does not cover, or a no-space route scoped keys cannot +// use. The message must name the actual grant. +func SpaceNotGranted(message string) *Error { + return NewError(http.StatusForbidden, CodeSpaceNotGranted, message) +} + +// WriteNotGranted is the 403 for a write-classified route reached with a +// read-only grant. +func WriteNotGranted(message string) *Error { + return NewError(http.StatusForbidden, CodeWriteNotGranted, message) +} + +// V1NotAvailableForScopedKeys is the 403 a granted key gets on every /v1 +// route: the grant can only be honored on /v2 (a legacy nil-grant key is +// served on /v1 unchanged). +func V1NotAvailableForScopedKeys(message string) *Error { + return NewError(http.StatusForbidden, CodeV1NotAvailableForScopedKeys, message) +} + +// NotCreatedByThisKey is the 403 for an object DELETE outside the calling +// key's own output (APIV2_OBJECT_DELETE.md §9.5). Not a scope failure: the +// WWW-Authenticate insufficient_scope channel is deliberately NOT used. +func NotCreatedByThisKey(message string, issues ...Issue) *Error { + return NewError(http.StatusForbidden, CodeNotCreatedByThisKey, message, issues...) +} + +// RequestTooLarge is the 413 for an oversized request body (C3). +func RequestTooLarge(message string) *Error { + return NewError(http.StatusRequestEntityTooLarge, CodeRequestTooLarge, message) +} + +// EtagMismatch is the 409 for a stale If-Match (C7), carrying the current +// etag so the agent can re-read and retry. +func EtagMismatch(currentEtag string) *Error { + return NewError(http.StatusConflict, CodeEtagMismatch, + fmt.Sprintf("the object changed since the etag in If-Match was read — current etag is %q; re-read the object and retry", currentEtag)) +} + +// VersionUnsupported is the 400 for a document produced by a newer format +// version (C6: surfaces SPEC §10's wording, naming both versions). +func VersionUnsupported(documentVersion, supportedVersion int) *Error { + return NewError(http.StatusBadRequest, CodeVersionUnsupported, + fmt.Sprintf("the document was produced by a newer version of the AnyBlock format: document version %d is newer than the supported version %d", documentVersion, supportedVersion)) +} + +// ListResponse is the C10 paginated list envelope: default limit 25, +// has_more, and a steering message when the result is truncated. Warnings +// carry warning-grade C6 issues (C11 — e.g. the unguarded-date-comparison +// hazard on search, or spaces a global search skipped). +type ListResponse[T any] struct { + Data []T `json:"data"` + Total int `json:"total"` + Offset int `json:"offset"` + Limit int `json:"limit"` + HasMore bool `json:"has_more"` + Message string `json:"message,omitempty"` + Warnings []Issue `json:"warnings,omitempty"` +} + +// ViewObject is the OpenAPI stand-in for one §6.2 view object: the runtime +// rows are pre-serialized JSON (json.RawMessage), which swag cannot resolve +// as a generic argument, so the view-listing annotations name this untyped +// object instead. The wire shape is identical — one JSON object per row. +type ViewObject map[string]any + +// NewListResponse assembles the envelope and, when truncated, the C10 +// steering message. +func NewListResponse[T any](data []T, total, offset, limit int, hasMore bool, narrowHint string) ListResponse[T] { + if data == nil { + data = []T{} + } + resp := ListResponse[T]{Data: data, Total: total, Offset: offset, Limit: limit, HasMore: hasMore} + if hasMore { + resp.Message = fmt.Sprintf("%d matches — showing %d from offset %d; %s", total, len(data), offset, narrowHint) + } + return resp +} + +// ObjectRow is the C5 minimal list row: id, name, type (a type key) plus +// requested property values. SpaceId is set only on global search rows — +// the addressing info a follow-up space-scoped read needs. +type ObjectRow struct { + Id string `json:"id"` + Name string `json:"name"` + Type string `json:"type"` + SpaceId string `json:"space_id,omitempty"` + Properties map[string]any `json:"properties,omitempty"` +} + +// SpaceRow is a minimal space list row. Description rides the row because +// it is free (same tech-space record) and it is what disambiguates spaces on +// the canonical "list my spaces, pick one to write to" trace — withholding +// it would force a GET-one per space (the N+1 pushed onto the agent). +type SpaceRow struct { + // Id is the space's short reference: the last six characters of the + // first half of its id. It is the full id instead when that tail is + // shared with another visible space, or when the request asked for + // `?ids=full`. Either spelling is accepted back on every route that + // takes a space. + Id string `json:"id"` + Name string `json:"name"` + Description string `json:"description,omitempty"` +} + +// Space is the space shape shared by GET-one and the space mutations +// (Phase 7, APIV2_SURFACES.md §2): {id, name, description}. gatewayUrl and +// networkId are client-infrastructure fields, deliberately absent from v2 +// (they remain reachable via v1). On a dry run (C9) nothing is committed: +// Id stays empty and DryRun is true. +type Space struct { + Id string `json:"id,omitempty"` + Name string `json:"name"` + Description string `json:"description,omitempty"` + DryRun bool `json:"dry_run,omitempty"` +} + +// CreateSpaceRequest is the POST /v2/spaces body. +type CreateSpaceRequest struct { + Name string `json:"name"` + Description string `json:"description,omitempty"` +} + +// UpdateSpaceRequest is the PATCH /v2/spaces/{space_id} body: omitted +// fields stay unchanged (pointers distinguish absent from present-but-empty; +// at least one field is required). +type UpdateSpaceRequest struct { + Name *string `json:"name,omitempty"` + Description *string `json:"description,omitempty"` +} + +// +// ---- P1c: whoami (GET /v2/auth/whoami) ---- +// + +// WhoamiResponse describes the authenticated CREDENTIAL — never the person; +// there is only ever one "who" on a single-account localhost API. KeyStatus +// and Notice repeat the Anytype-Key-Status / Anytype-Notice header signal in +// the body, because agents read bodies, not headers. +type WhoamiResponse struct { + Key WhoamiKey `json:"key"` + Scope string `json:"scope"` // "jsonApi" | "full" | "limited" + Grant WhoamiGrant `json:"grant"` + Api WhoamiApi `json:"api"` + KeyStatus string `json:"key_status"` // "legacy" | "scoped", always present + Notice string `json:"notice,omitempty"` // the legacy sentence, verbatim printable +} + +// WhoamiKey names the credential. CreatedAt/ExpiresAt are RFC 3339 UTC; +// null means unknown (CreatedAt) / never (ExpiresAt). +type WhoamiKey struct { + Id string `json:"id"` // the app link's hash, which is the id the key list shows + Name string `json:"name"` + CreatedAt *string `json:"created_at"` + ExpiresAt *string `json:"expires_at"` +} + +// WhoamiGrant is the credential's space grant as enforced. Scoped is the +// REQUIRED explicit boolean and the load-bearing field: "legacy unscoped +// key" is NEVER encoded as spaces:null, because consumers get the +// null-vs-empty test backwards and that failure direction is fail-open (the +// agent concludes it may touch every space). When Scoped is false, Spaces +// is [] and Permission is null. +type WhoamiGrant struct { + Scoped bool `json:"scoped"` + Permission *string `json:"permission"` // the compact form agents string-match on + Spaces []WhoamiGrantSpace `json:"spaces"` +} + +// WhoamiGrantSpace is one granted space. Spaces are OBJECTS with a +// per-entry permission even though the grant's perms are uniform today — +// the shape that lets P2 add per-space permissions without a breaking wire +// change. Name comes from the same grant-intersected path GET /v2/spaces +// uses; a granted space absent from the live list keeps its entry with an +// empty name. +type WhoamiGrantSpace struct { + Id string `json:"id"` + Name string `json:"name"` + Permission string `json:"permission"` +} + +// WhoamiApi carries the serving API version — the same value as the +// Anytype-Version response header. +type WhoamiApi struct { + Version string `json:"version"` +} + +// MemberRow is a minimal member list row; agents need member ids for +// assignee/creator property values. +type MemberRow struct { + Id string `json:"id"` + Name string `json:"name"` + Role string `json:"role"` + Identity string `json:"identity,omitempty"` +} + +// TypeRow is a minimal type list row: keys + names (Phase 1). +type TypeRow struct { + Key string `json:"key"` + Name string `json:"name"` +} + +// PropertyRow is a minimal property list row: key, name, format (Phase 1). +type PropertyRow struct { + Key string `json:"key"` + Name string `json:"name"` + Format string `json:"format"` +} + +// OptionRow is a select/multiSelect option list row: names are the +// option vocabulary (C2), color optional. +type OptionRow struct { + Name string `json:"name"` + Color string `json:"color,omitempty"` +} + +// ValidateResponse is the POST /v2/validate result: structural + +// format-semantic issues only (referential validation is Phase 2). +type ValidateResponse struct { + Issues []Issue `json:"issues"` + Warnings []Issue `json:"warnings"` +} + +// OutlineEntry is one row of the outline shape: every block's +// {indent, id, type}, text only on heading blocks (APIV2.md Phase 1). +type OutlineEntry struct { + Indent int `json:"indent"` + Id string `json:"id"` + Type string `json:"type"` + Text string `json:"text,omitempty"` +} + +// +// ---- Phase 2: create surface ---- +// + +// CreateResult is the response of every v2 create/update endpoint. C8: +// created ids are always returned. On a dry run (C9) nothing is committed: +// Id/Etag stay empty, DryRun is true, and Issues/Created report the would-be +// outcome. +type CreateResult struct { + Id string `json:"id,omitempty"` + Type string `json:"type,omitempty"` // type key of the created object + Key string `json:"key,omitempty"` // identity key (types, properties) + Etag string `json:"etag,omitempty"` // etag of the created object + DryRun bool `json:"dry_run,omitempty"` + Created *SideEffects `json:"created,omitempty"` + Issues []Issue `json:"issues,omitempty"` + Warnings []Issue `json:"warnings,omitempty"` +} + +// SideEffects lists the schema entities a create materialized on the way +// (create-missing, SPEC §3/§2a) — or would materialize, on a dry run. +type SideEffects struct { + Properties []PropertyRow `json:"properties,omitempty"` + Options []CreatedOption `json:"options,omitempty"` +} + +// CreatedOption names one select/multiSelect option created by +// create-missing resolution (SPEC §3: option names, never ids). +type CreatedOption struct { + Property string `json:"property"` // property key + Name string `json:"name"` +} + +// CreatePropertyRequest is the POST properties body: +// {key?, name, format, options?:[{name,color?}]} (APIV2.md Phase 2). +type CreatePropertyRequest struct { + Key string `json:"key,omitempty"` + Name string `json:"name"` + Format string `json:"format"` + Options []CreateOptionRequest `json:"options,omitempty"` +} + +// CreateOptionRequest is one select option in a property create. +type CreateOptionRequest struct { + Name string `json:"name"` + Color string `json:"color,omitempty"` +} + +// UpdatePropertyRequest is the PATCH properties/{key} body. +type UpdatePropertyRequest struct { + Name *string `json:"name,omitempty"` +} + +// CreateSetRequest is the POST sets body. filter (compact string) is +// reserved for the Phase-4 parser; filters/sorts follow the AnyBlock §6.2 +// shapes and are passed into the set's initial dataview verbatim. views, when +// given, replaces the default single view and is mutually exclusive with +// top-level filters/sorts (ambiguous_input otherwise). +type CreateSetRequest struct { + Name string `json:"name"` + Type string `json:"type"` + // The RawMessage fields carry pre-serialized §6.2 arrays. No OpenAPI + // annotation references this type today; if one ever does, follow the + // SearchRequestDoc pattern — swag cannot resolve json.RawMessage, and + // its v3 parser panics on swaggertype:"array,…". + Filter string `json:"filter,omitempty"` + Filters json.RawMessage `json:"filters,omitempty"` + Sorts json.RawMessage `json:"sorts,omitempty"` + Views json.RawMessage `json:"views,omitempty"` +} + +// CreateCollectionRequest is the POST collections body. +type CreateCollectionRequest struct { + Name string `json:"name"` + Items []string `json:"items,omitempty"` +} + +// UploadFileRequest is the JSON form of POST files (URL upload); the +// multipart form is the byte-upload alternative. +type UploadFileRequest struct { + Url string `json:"url"` + Name string `json:"name,omitempty"` +} + +// FileUploadResult is the POST files response: the file object id that +// file/image blocks and iconImage values need (R11). +// +// mime_type is v2's OWN response field and follows C2's snake_case. The +// query surface's `mimeType` field alias (object.go v2FieldAliases) is a +// different thing wearing the same word — the FORMAT's file-block field +// name — and is renamed on the anyblock branch, not here. +type FileUploadResult struct { + Id string `json:"id"` + Name string `json:"name,omitempty"` + MimeType string `json:"mime_type,omitempty"` + Size int64 `json:"size,omitempty"` + DryRun bool `json:"dry_run,omitempty"` +} + +// +// ---- Phase 4: query surface ---- +// + +// SearchRequest is the POST search body (space-scoped and global). filter +// (the compact string, SPEC §6.2.1) and filters (the structured §6.2 array) +// are mutually exclusive — both → 400 ambiguous_input; both land on one +// internal tree. Pagination is the C10 query params (offset/limit) — a body +// limit is rejected by the strict request schema. Search is a read: exempt +// from Idempotency-Key, and a supplied dry_run is ignored. +type SearchRequest struct { + Query string `json:"query,omitempty"` + Type string `json:"type,omitempty"` + Filter string `json:"filter,omitempty"` + Filters json.RawMessage `json:"filters,omitempty"` + Sorts json.RawMessage `json:"sorts,omitempty"` + Fields []string `json:"fields,omitempty"` +} + +// SearchRequestDoc mirrors SearchRequest for the OpenAPI document ONLY (the +// search annotations reference it): swag cannot resolve json.RawMessage and +// its v3 parser panics on swaggertype:"array,…", so the §6.2 array fields +// are documented through this twin instead. Wire shape is identical. A unit +// test pins the twin's JSON field set to SearchRequest's so the two cannot +// drift. +type SearchRequestDoc struct { + Query string `json:"query,omitempty"` + Type string `json:"type,omitempty"` + Filter string `json:"filter,omitempty"` + Filters []map[string]any `json:"filters,omitempty"` + Sorts []map[string]any `json:"sorts,omitempty"` + Fields []string `json:"fields,omitempty"` +} + +// +// ---- Phase 3: edit surface ---- +// + +// DiffStats summarizes what a mutation changed (APIV2.md Phase 3): the +// accidental-full-rewrite signal on PUT, the receipt on PATCH. +type DiffStats struct { + BlocksAdded int `json:"blocks_added"` + BlocksRemoved int `json:"blocks_removed"` + BlocksChanged int `json:"blocks_changed"` + BlocksMoved int `json:"blocks_moved"` + PropertiesChanged int `json:"properties_changed"` +} + +// EditResult is the PATCH response: the new etag, the created-block id map +// keyed by payload position, the schema side effects (created select +// options, like Phase 2's create), and the diff stats. On a dry run nothing +// is committed: Etag stays empty and DryRun is true; CreatedBlocks/Created/ +// DiffStats report the would-be outcome. +// +// The nested slots CreatedBlocks reports are exactly the ones the id +// refusals tell a caller to leave empty, so leaving them unreported made the +// API withhold the answer it had promised. A payload position carrying an id +// is absent because since §8.29 that id resolves to an existing block whose +// identity the op keeps, and reporting a preserved block as created would be +// the same lie diff_stats used to tell. (This paragraph is deliberately on +// the type: swag publishes a FIELD comment as the schema description, and a +// reader outside this repository cannot follow either reference.) +type EditResult struct { + Etag string `json:"etag,omitempty"` + DryRun bool `json:"dry_run,omitempty"` + // CreatedBlocks maps each payload position that created a block to the + // id the server minted for it: the top-level run positions + // ("ops[3].blocks[0]") and the nested slots alike, such as a table's + // rows and columns ("ops[3].blocks[0].rows[1]") and the blocks inside a + // cell run ("ops[3].value[1]"). A position that carried an id is + // absent, because the block it names already existed. + CreatedBlocks map[string]string `json:"created_blocks,omitempty"` + // CreatedViews maps each payload position that created a dataview view + // to the view id the server minted: an insert_view op ("ops[i]"), or a + // view slot of an update_block set channel ("ops[i].set.views[2]"). + // View ids are always server-minted, and a view is not a block, so they + // are reported here rather than in CreatedBlocks. + CreatedViews map[string]string `json:"created_views,omitempty"` + Created *SideEffects `json:"created,omitempty"` + DiffStats DiffStats `json:"diff_stats"` + Warnings []Issue `json:"warnings,omitempty"` +} + +// SchemaEntry is one GET /v2/schemas/{kind} payload: the strict-mode +// generation schema (C13) plus one worked example (C12). The filters kind +// additionally carries the compact filter-string grammar (EBNF + examples) — +// one concept, one discovery slot (§5), the artifact the Phase-5 GBNF +// conversion consumes. +type SchemaEntry struct { + Kind string `json:"kind"` + Endpoint string `json:"endpoint"` + Schema json.RawMessage `json:"schema" swaggertype:"object"` + Example json.RawMessage `json:"example" swaggertype:"object"` + Grammar string `json:"grammar,omitempty"` + GrammarExamples []string `json:"grammar_examples,omitempty"` +} + +// SchemaIndex is the GET /v2/schemas payload. Ops lists the Phase-3 PATCH +// ops (per-op schemas at /v2/schemas/ops/{op}). +type SchemaIndex struct { + Kinds []SchemaIndexEntry `json:"kinds"` + Ops []SchemaIndexEntry `json:"ops,omitempty"` +} + +// SchemaIndexEntry is one row of the schema index. +type SchemaIndexEntry struct { + Kind string `json:"kind"` + Endpoint string `json:"endpoint"` + Url string `json:"url"` +} + +// +// ---- the output-only property contract (SPEC §4a) ---- +// + +// outputOnlyPropertyKeys are the SPEC §4a output-only property keys: an +// export writes them, a write must not. They live here, in the leaf model +// package, because two layers need the SAME answer — the service refuses a +// set_properties naming one (stateops.go), and the wrapper's describe must +// not advertise one as settable. A hand-copied second list is the drift +// class §8.31 was about. +// +// isFavorite is deliberately absent — SPEC §3 marks it authorable. +var outputOnlyPropertyKeys = map[string]bool{ + "coverId": true, "coverType": true, "createdDate": true, + "lastModifiedDate": true, "creator": true, "isArchived": true, + "resolvedLayout": true, +} + +// IsOutputOnlyProperty reports whether a property key is output-only. The +// keys above are STORED spellings; the wire spells slugs (ADDRESSING §7.5a), +// so a served `created_date` has to answer here exactly as `createdDate` +// does — one predicate, both vocabularies. +func IsOutputOnlyProperty(key string) bool { + if outputOnlyPropertyKeys[key] { + return true + } + stored, ok := bundle.RelationKeyByApiSlug(key) + return ok && outputOnlyPropertyKeys[string(stored)] +} + +// OutputOnlyPropertyKeys returns the output-only keys, sorted — the form an +// agent-facing listing needs. +func OutputOnlyPropertyKeys() []string { + keys := make([]string, 0, len(outputOnlyPropertyKeys)) + for key := range outputOnlyPropertyKeys { + keys = append(keys, bundle.ApiSlug(key)) // advertised in the wire spelling + } + sort.Strings(keys) + return keys +} diff --git a/core/api/v2/model/model_test.go b/core/api/v2/model/model_test.go new file mode 100644 index 0000000000..31707e9ef5 --- /dev/null +++ b/core/api/v2/model/model_test.go @@ -0,0 +1,188 @@ +package v2model + +import ( + "encoding/json" + "go/ast" + "go/parser" + "go/token" + "path/filepath" + "reflect" + "regexp" + "strconv" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestJSONTagsAreSnakeCase pins C2 across the WHOLE DTO package: v2's own +// wire vocabulary is snake_case, in every body, in both directions. It reads +// the package's own source rather than a hand-kept list of types, so a DTO +// added later cannot land with a camelCase tag merely by not being listed — +// the drift class §8.31 is about. The format's own field names are NOT +// covered here: v2 forwards those inside AnyBlock documents, which are +// json.RawMessage at this layer. +func TestJSONTagsAreSnakeCase(t *testing.T) { + sources, err := filepath.Glob("*.go") + require.NoError(t, err) + + snake := regexp.MustCompile(`^[a-z][a-z0-9]*(_[a-z0-9]+)*$`) + fset := token.NewFileSet() + tagged := 0 + + for _, source := range sources { + if strings.HasSuffix(source, "_test.go") { + continue + } + file, parseErr := parser.ParseFile(fset, source, nil, 0) + require.NoError(t, parseErr) + + ast.Inspect(file, func(node ast.Node) bool { + field, ok := node.(*ast.Field) + if !ok || field.Tag == nil { + return true + } + raw, unquoteErr := strconv.Unquote(field.Tag.Value) + require.NoError(t, unquoteErr) + name, _, _ := strings.Cut(reflect.StructTag(raw).Get("json"), ",") + if name == "" || name == "-" { + return true + } + tagged++ + assert.Regexp(t, snake, name, + "%s: json tag %q is not snake_case — C2 is one vocabulary, and it is the transport's", + fset.Position(field.Pos()), name) + return true + }) + } + + assert.Greater(t, tagged, 80, "the walker must actually have reached the DTO tags") +} + +func TestErrorShape(t *testing.T) { + t.Run("serializes the C6 envelope", func(t *testing.T) { + // given + err := AmbiguousInput("outline and block are mutually exclusive", + Issue{Path: "outline", Message: "conflicts with block", Hint: "drop one"}) + want := `{"status":400,"code":"ambiguous_input","message":"outline and block are mutually exclusive","issues":[{"path":"outline","message":"conflicts with block","hint":"drop one"}]}` + + // when + data, marshalErr := json.Marshal(err) + + // then + require.NoError(t, marshalErr) + assert.JSONEq(t, want, string(data)) + }) + + t.Run("issues are omitted when empty", func(t *testing.T) { + data, err := json.Marshal(NotFound("object gone")) + require.NoError(t, err) + assert.NotContains(t, string(data), "issues") + }) + + t.Run("etag mismatch carries the current etag", func(t *testing.T) { + err := EtagMismatch("abcd1234") + assert.Equal(t, 409, err.Status) + assert.Equal(t, CodeEtagMismatch, err.Code) + assert.Contains(t, err.Message, `"abcd1234"`) + }) + + t.Run("version unsupported names both versions verbatim", func(t *testing.T) { + err := VersionUnsupported(2, 1) + assert.Equal(t, 400, err.Status) + assert.Equal(t, CodeVersionUnsupported, err.Code) + assert.Contains(t, err.Message, "produced by a newer version") + assert.Contains(t, err.Message, "document version 2") + assert.Contains(t, err.Message, "supported version 1") + }) +} + +func TestNewListResponse(t *testing.T) { + t.Run("truncated lists carry a steering message", func(t *testing.T) { + // given / when + resp := NewListResponse([]TypeRow{{Key: "task"}}, 312, 0, 1, true, "narrow with prefix=") + + // then + assert.True(t, resp.HasMore) + assert.Contains(t, resp.Message, "312 matches") + assert.Contains(t, resp.Message, "narrow with prefix=") + }) + + t.Run("complete lists have no message and data serializes as []", func(t *testing.T) { + // given / when + resp := NewListResponse[TypeRow](nil, 0, 0, 25, false, "unused") + + // then + assert.Empty(t, resp.Message) + data, err := json.Marshal(resp) + require.NoError(t, err) + assert.Contains(t, string(data), `"data":[]`) + }) +} + +// jsonFieldNames extracts the json tag names of a struct type, in field +// order — the wire vocabulary a doc twin must reproduce exactly. +func jsonFieldNames(t *testing.T, typ reflect.Type) []string { + t.Helper() + var names []string + for i := 0; i < typ.NumField(); i++ { + tag := typ.Field(i).Tag.Get("json") + require.NotEmpty(t, tag, "field %s must carry a json tag", typ.Field(i).Name) + names = append(names, strings.Split(tag, ",")[0]) + } + return names +} + +func TestSearchRequestDocMirrorsSearchRequest(t *testing.T) { + // SearchRequestDoc exists ONLY for the OpenAPI document (swag cannot + // resolve json.RawMessage and panics on swaggertype:"array,…"), so the + // one way it can go wrong is drifting from the type the handler actually + // decodes. Field-for-field, same json names, same order. + want := jsonFieldNames(t, reflect.TypeOf(SearchRequest{})) + + got := jsonFieldNames(t, reflect.TypeOf(SearchRequestDoc{})) + + assert.Equal(t, want, got, "the doc twin drifted from SearchRequest — the published document would lie about the search body") +} + +// TestIsOutputOnlyProperty pins the §4a predicate in BOTH vocabularies. The +// stored list is camelCase and every caller now speaks slugs (ADDRESSING +// §7.5a) — the wrapper's describe passes SERVED keys straight in — so the +// bundled fallback is what keeps `created_date` output-only. Revert it and +// the two surfaces that share this predicate start disagreeing, silently: +// a set_properties naming created_date would be accepted, and describe would +// advertise it as settable. +func TestIsOutputOnlyProperty(t *testing.T) { + t.Run("both spellings of an output-only key answer the same", func(t *testing.T) { + for stored, slug := range map[string]string{ + "createdDate": "created_date", + "lastModifiedDate": "last_modified_date", + "coverId": "cover_id", + "coverType": "cover_type", + "isArchived": "is_archived", + "resolvedLayout": "resolved_layout", + } { + assert.True(t, IsOutputOnlyProperty(stored), stored) + assert.True(t, IsOutputOnlyProperty(slug), slug) + } + // creator spells the same in both vocabularies — which is why a + // fixture using it could not tell them apart + assert.True(t, IsOutputOnlyProperty("creator")) + }) + + t.Run("authorable keys stay authorable in both spellings", func(t *testing.T) { + for _, key := range []string{"name", "description", "dueDate", "due_date", "isFavorite", "is_favorite", "manual_property"} { + assert.False(t, IsOutputOnlyProperty(key), key) + } + }) + + t.Run("the advertised listing is the wire spelling", func(t *testing.T) { + keys := OutputOnlyPropertyKeys() + assert.Contains(t, keys, "created_date") + assert.NotContains(t, keys, "createdDate") + for _, key := range keys { + assert.True(t, IsOutputOnlyProperty(key), "everything advertised must answer the predicate: %s", key) + } + }) +} diff --git a/core/api/v2/router.go b/core/api/v2/router.go new file mode 100644 index 0000000000..87a7bb5a31 --- /dev/null +++ b/core/api/v2/router.go @@ -0,0 +1,377 @@ +package apiv2 + +// router.go registers the /v2 route group (APIV2.md §8). It lives in the v2 +// package, not in core/api/server, so that nothing under core/api/v2 can +// reach v1 code and nothing in v1 can reach v2's: the dependency points one +// way, server → v2. The shared middleware stack (auth, cache init, write +// rate limit, analytics) stays in server — one gin engine and one +// ensureAuthenticated serve both versions — and arrives here through +// RouteDeps. The key-scope gate also arrives through RouteDeps but is +// installed only on this group: it is a /v2-only refusal by design. + +import ( + "github.com/gin-gonic/gin" + + "github.com/anyproto/anytype-heart/core/api/pagination" + v2handler "github.com/anyproto/anytype-heart/core/api/v2/handler" + v2service "github.com/anyproto/anytype-heart/core/api/v2/service" +) + +// C10 pagination bounds for /v2. The default page size is v2's own contract +// (C10: 25, against v1's 100); the rest match the shared bounds. +const ( + defaultPage = 0 + defaultPageSize = 25 + minPageSize = 1 + maxPageSize = 1000 +) + +// RouteDeps carries what the /v2 group needs from the server: the constructed +// v2 service, the two capability flags, and the shared middleware the server +// owns. Passing the middleware as values (rather than importing server) is +// what keeps the dependency one-directional. +type RouteDeps struct { + Service *v2service.Service + // CreateDisabled skips the Phase-2 create routes when no creator + // dependency was provided (read-only construction, e.g. in tests). + CreateDisabled bool + // EditDisabled skips the Phase-3 edit routes when no mutator dependency + // was provided. + EditDisabled bool + + // Auth is the shared bearer-token middleware (the same one /v1 uses). + Auth gin.HandlerFunc + // KeyScope is the JSON-API key-scope gate — it decides on the key's KIND + // (Limited/JsonAPI/Full), not on which spaces the key may touch — run + // directly after Auth (it needs the session Auth resolves). It is + // installed on /v2 only: keys minted without a scope carry Limited and + // are grandfathered on /v1, while /v2 has no shipped clients to break. + KeyScope gin.HandlerFunc + // CacheInit is the shared lazy cache-initialization middleware. + CacheInit gin.HandlerFunc + // WriteRateLimit is the shared write-rate limiter. + WriteRateLimit gin.HandlerFunc + // AnalyticsEvent builds the analytics middleware for one event code. + AnalyticsEvent func(code string) gin.HandlerFunc +} + +// RegisterRoutes registers the /v2 route group (APIV2.md §8): same +// middleware stack and auth as /v1 plus the /v2-only key-scope gate, C10 +// pagination defaults, and the C8 idempotency and C9 dry-run plumbing. +// Skipped when the v2 service has no dependencies (v1-only construction, +// e.g. in isolated tests). +func RegisterRoutes(router *gin.Engine, deps RouteDeps) { + if deps.Service == nil { + return + } + + v2 := router.Group("/v2") + v2.Use(pagination.New(pagination.Config{ + DefaultPage: defaultPage, + DefaultPageSize: defaultPageSize, + MinPageSize: minPageSize, + MaxPageSize: maxPageSize, + })) + v2.Use(deps.CacheInit) + v2.Use(deps.Auth) + v2.Use(deps.KeyScope) + // `?ids=` (C4) is parsed once, for every route: it picks the shape ids + // are SERVED in, on both axes a response has — block ids in a document + // and space ids anywhere a space is named (§8.36). It runs before + // resolution so that an ambiguous reference's candidate list is spelled + // the way this request asked for — idshape.go. + v2.Use(ensureIdsShape()) + // Short space references (§8.35) resolve BEFORE the grant gate: grants + // are keyed by full space id, so a short reference has to become one + // before anything compares it against the grant. Resolution itself can + // only land inside the caller's visible (grant-intersected) spaces — + // spaceref.go. + v2.Use(resolveSpaceRef(deps.Service)) + // The space-grant gate runs directly after the key-scope gate: KeyScope + // decides the key's KIND, ensureSpaceGrant decides which spaces and + // which verbs the key's grant covers (authz.go). It must run before any + // handler resolves a space — the service's ensureSpace admits the tech + // space, this gate denies it unless explicitly granted. + v2.Use(ensureSpaceGrant()) + v2.Use(ensureDryRun()) + v2.Use(ensureCreateMissingOptions()) + idempotencyMW := ensureIdempotency(newIdempotencyStore(idempotencyMaxEntries)) + + // P1c introspection: the credential's self-description, derived from the + // same ctx carriers the grant gate reads. Registered INSIDE the + // authenticated group — its registry class is service-filtered, never + // auth-exempt (the conformance walk fails an authenticated route + // carrying that class), and the shared auth middleware is what keeps the + // token Authorization-header-only. + v2.GET("/auth/whoami", + deps.AnalyticsEvent("V2Whoami"), + v2handler.WhoamiHandler(deps.Service), + ) + v2.POST("/validate", + idempotencyMW, + deps.AnalyticsEvent("V2Validate"), + v2handler.ValidateHandler(deps.Service), + ) + v2.GET("/spaces", + deps.AnalyticsEvent("V2ListSpaces"), + v2handler.ListSpacesHandler(deps.Service), + ) + // Phase-7 space surface: the read is a tech-space store query (no + // WorkspaceOpen/ObjectShow); both mutations carry C8 — a retried space + // create without idempotency duplicates an entire space. + v2.GET("/spaces/:space_id", + deps.AnalyticsEvent("V2GetSpace"), + v2handler.GetSpaceHandler(deps.Service), + ) + v2.POST("/spaces", + deps.WriteRateLimit, + idempotencyMW, + deps.AnalyticsEvent("V2CreateSpace"), + v2handler.CreateSpaceHandler(deps.Service), + ) + v2.PATCH("/spaces/:space_id", + deps.WriteRateLimit, + idempotencyMW, + deps.AnalyticsEvent("V2UpdateSpace"), + v2handler.UpdateSpaceHandler(deps.Service), + ) + v2.GET("/spaces/:space_id/objects", + deps.AnalyticsEvent("V2ListObjects"), + v2handler.ListObjectsHandler(deps.Service), + ) + v2.GET("/spaces/:space_id/objects/:object_id", + deps.AnalyticsEvent("V2GetObject"), + v2handler.GetObjectHandler(deps.Service), + ) + v2.GET("/spaces/:space_id/members", + deps.AnalyticsEvent("V2ListMembers"), + v2handler.ListMembersHandler(deps.Service), + ) + v2.GET("/spaces/:space_id/members/me", + deps.AnalyticsEvent("V2GetMemberMe"), + v2handler.GetMemberMeHandler(deps.Service), + ) + v2.GET("/spaces/:space_id/types", + deps.AnalyticsEvent("V2ListTypes"), + v2handler.ListTypesHandler(deps.Service), + ) + v2.GET("/spaces/:space_id/types/:type", + deps.AnalyticsEvent("V2GetType"), + v2handler.GetTypeHandler(deps.Service), + ) + v2.GET("/spaces/:space_id/types/:type/schema", + deps.AnalyticsEvent("V2GetTypeSchema"), + v2handler.GetTypeSchemaHandler(deps.Service), + ) + v2.GET("/spaces/:space_id/properties", + deps.AnalyticsEvent("V2ListProperties"), + v2handler.ListPropertiesHandler(deps.Service), + ) + v2.GET("/spaces/:space_id/properties/:key/options", + deps.AnalyticsEvent("V2ListPropertyOptions"), + v2handler.ListPropertyOptionsHandler(deps.Service), + ) + // Phase-4 query surface. Search is a READ (POST only because the request + // needs a body): no idempotency middleware, no write rate limit, and the + // group-level dry-run middleware's flag is ignored by the handlers. + v2.POST("/search", + deps.AnalyticsEvent("V2GlobalSearch"), + v2handler.GlobalSearchObjectsHandler(deps.Service), + ) + v2.POST("/spaces/:space_id/search", + deps.AnalyticsEvent("V2Search"), + v2handler.SearchObjectsHandler(deps.Service), + ) + v2.GET("/spaces/:space_id/sets/:set_id/objects", + deps.AnalyticsEvent("V2GetSetObjects"), + v2handler.GetSetObjectsHandler(deps.Service), + ) + v2.GET("/spaces/:space_id/sets/:set_id/views", + deps.AnalyticsEvent("V2GetSetViews"), + v2handler.GetSetViewsHandler(deps.Service), + ) + v2.GET("/spaces/:space_id/collections/:collection_id/objects", + deps.AnalyticsEvent("V2GetCollectionObjects"), + v2handler.GetCollectionObjectsHandler(deps.Service), + ) + v2.GET("/spaces/:space_id/collections/:collection_id/views", + deps.AnalyticsEvent("V2GetCollectionViews"), + v2handler.GetCollectionViewsHandler(deps.Service), + ) + v2.GET("/schemas", + deps.AnalyticsEvent("V2ListSchemas"), + v2handler.SchemaIndexHandler(deps.Service), + ) + v2.GET("/schemas/:kind", + deps.AnalyticsEvent("V2GetSchema"), + v2handler.SchemaKindHandler(deps.Service), + ) + v2.GET("/schemas/ops/:op", + deps.AnalyticsEvent("V2GetOpSchema"), + v2handler.SchemaOpHandler(deps.Service), + ) + + registerCreateRoutes(v2, deps, idempotencyMW) + registerEditRoutes(v2, deps, idempotencyMW) + registerChatRoutes(v2, deps, idempotencyMW) +} + +// registerChatRoutes registers the Phase-6 chat surface (APIV2.md §8.7). +// Every mutation — including DELETE, a Phase-6 widening of C8's method set +// that now covers every v2 DELETE — runs behind the idempotency middleware: +// a double-sent chat message is user-visible damage, and a blindly retried +// delete 404s misleadingly. C7 etag/If-Match deliberately does not apply +// (order ids and last_state_id are the chat's native concurrency vocabulary). +func registerChatRoutes(v2 *gin.RouterGroup, deps RouteDeps, idempotencyMW gin.HandlerFunc) { + v2.GET("/spaces/:space_id/chats", + deps.AnalyticsEvent("V2ListChats"), + v2handler.ListChatsHandler(deps.Service), + ) + v2.POST("/spaces/:space_id/chats", + deps.WriteRateLimit, + idempotencyMW, + deps.AnalyticsEvent("V2CreateChat"), + v2handler.CreateChatHandler(deps.Service), + ) + v2.GET("/spaces/:space_id/chats/:chat_id/messages", + deps.AnalyticsEvent("V2GetChatMessages"), + v2handler.GetChatMessagesHandler(deps.Service), + ) + v2.POST("/spaces/:space_id/chats/:chat_id/messages", + deps.WriteRateLimit, + idempotencyMW, + deps.AnalyticsEvent("V2AddChatMessage"), + v2handler.AddChatMessageHandler(deps.Service), + ) + v2.PATCH("/spaces/:space_id/chats/:chat_id/messages/:message_id", + deps.WriteRateLimit, + idempotencyMW, + deps.AnalyticsEvent("V2EditChatMessage"), + v2handler.EditChatMessageHandler(deps.Service), + ) + v2.DELETE("/spaces/:space_id/chats/:chat_id/messages/:message_id", + deps.WriteRateLimit, + idempotencyMW, + deps.AnalyticsEvent("V2DeleteChatMessage"), + v2handler.DeleteChatMessageHandler(deps.Service), + ) + v2.POST("/spaces/:space_id/chats/:chat_id/messages/:message_id/reactions", + deps.WriteRateLimit, + idempotencyMW, + deps.AnalyticsEvent("V2ToggleChatReaction"), + v2handler.ToggleChatReactionHandler(deps.Service), + ) + v2.POST("/spaces/:space_id/chats/:chat_id/read", + deps.WriteRateLimit, + idempotencyMW, + deps.AnalyticsEvent("V2ReadChat"), + v2handler.ReadChatHandler(deps.Service), + ) +} + +// registerEditRoutes registers the Phase-3 edit surface (APIV2.md §2 +// Phase 3): PATCH alone — snapshots are for creates, edits are ops (§8.27), +// so there is no full-document replace route. Concurrency safety is the +// If-Match header (C7); Idempotency-Key additionally covers PATCH (C8, +// v0.3.5) because agents auto-retry on timeout and a blind PATCH retry +// duplicates inserted blocks or 404s a re-deleted one. Skipped when no +// mutator dependency was provided. +func registerEditRoutes(v2 *gin.RouterGroup, deps RouteDeps, idempotencyMW gin.HandlerFunc) { + if deps.EditDisabled { + return + } + v2.PATCH("/spaces/:space_id/objects/:object_id", + deps.WriteRateLimit, + idempotencyMW, + deps.AnalyticsEvent("V2PatchObject"), + v2handler.PatchObjectHandler(deps.Service), + ) + // Plan 3.3 (APIV2_OBJECT_DELETE.md): archive, own-output-only — the + // creator-provenance gate runs in the service; C8 idempotency because a + // blindly retried delete after an already-archived answer must stay a + // clean replay, C9 dry-run is the deletability probe. + v2.DELETE("/spaces/:space_id/objects/:object_id", + deps.WriteRateLimit, + idempotencyMW, + deps.AnalyticsEvent("V2DeleteObject"), + v2handler.DeleteObjectHandler(deps.Service), + ) +} + +// registerCreateRoutes registers the Phase-2 create surface (APIV2.md §2). +// EVERY mutation — POST, PATCH and, since the Phase-6 review, the DELETEs +// too — runs behind the C8 idempotency middleware (v0.3.5: C8 covers all +// mutations; a route-dependent exception would be an invisible contract); +// all mutations parse ?dry_run=true via the group-level dry-run middleware. +// Skipped when no creator dependency was provided (read-only construction). +func registerCreateRoutes(v2 *gin.RouterGroup, deps RouteDeps, idempotencyMW gin.HandlerFunc) { + if deps.CreateDisabled { + return + } + v2.POST("/spaces/:space_id/objects", + deps.WriteRateLimit, + idempotencyMW, + deps.AnalyticsEvent("V2CreateObject"), + v2handler.CreateObjectHandler(deps.Service), + ) + v2.POST("/spaces/:space_id/templates", + deps.WriteRateLimit, + idempotencyMW, + deps.AnalyticsEvent("V2CreateTemplate"), + v2handler.CreateTemplateHandler(deps.Service), + ) + v2.POST("/spaces/:space_id/types", + deps.WriteRateLimit, + idempotencyMW, + deps.AnalyticsEvent("V2CreateType"), + v2handler.CreateTypeHandler(deps.Service), + ) + v2.PATCH("/spaces/:space_id/types/:type", + deps.WriteRateLimit, + idempotencyMW, + deps.AnalyticsEvent("V2UpdateType"), + v2handler.UpdateTypeHandler(deps.Service), + ) + v2.DELETE("/spaces/:space_id/types/:type", + deps.WriteRateLimit, + idempotencyMW, + deps.AnalyticsEvent("V2DeleteType"), + v2handler.DeleteTypeHandler(deps.Service), + ) + v2.POST("/spaces/:space_id/properties", + deps.WriteRateLimit, + idempotencyMW, + deps.AnalyticsEvent("V2CreateProperty"), + v2handler.CreatePropertyHandler(deps.Service), + ) + v2.PATCH("/spaces/:space_id/properties/:key", + deps.WriteRateLimit, + idempotencyMW, + deps.AnalyticsEvent("V2UpdateProperty"), + v2handler.UpdatePropertyHandler(deps.Service), + ) + v2.DELETE("/spaces/:space_id/properties/:key", + deps.WriteRateLimit, + idempotencyMW, + deps.AnalyticsEvent("V2DeleteProperty"), + v2handler.DeletePropertyHandler(deps.Service), + ) + v2.POST("/spaces/:space_id/sets", + deps.WriteRateLimit, + idempotencyMW, + deps.AnalyticsEvent("V2CreateSet"), + v2handler.CreateSetHandler(deps.Service), + ) + v2.POST("/spaces/:space_id/collections", + deps.WriteRateLimit, + idempotencyMW, + deps.AnalyticsEvent("V2CreateCollection"), + v2handler.CreateCollectionHandler(deps.Service), + ) + v2.POST("/spaces/:space_id/files", + deps.WriteRateLimit, + idempotencyMW, + deps.AnalyticsEvent("V2UploadFile"), + v2handler.UploadFileHandler(deps.Service), + ) +} diff --git a/core/api/v2/service/blockaddressing_test.go b/core/api/v2/service/blockaddressing_test.go new file mode 100644 index 0000000000..5550ac7369 --- /dev/null +++ b/core/api/v2/service/blockaddressing_test.go @@ -0,0 +1,124 @@ +package v2service + +// blockaddressing_test.go pins what a block reference can address and what a +// read may promise about it (APIV2.md §8.29 F4, and the §8.28 property-1 +// collision guard the audit found unpinned). Shared fixtures and helpers +// live in payloadids_test.go. + +import ( + "context" + "encoding/json" + "net/http" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + + v2model "github.com/anyproto/anytype-heart/core/api/v2/model" +) + +// TestBlockNotFoundHintsAreTrue is F4. Both 404s used to say "GET the object +// with ?outline=true to list block ids" — and cell descendants are served by +// a default read, resolve on NO channel, and are not in the outline. The +// caller was sent round a loop that could never terminate. +func TestBlockNotFoundHintsAreTrue(t *testing.T) { + ctx := context.Background() + + t.Run("the outline lists exactly the ids a block reference resolves", func(t *testing.T) { + // the property the hint promises. It holds for the top-level run… + fx := newV2Fixture(t) + outline := fx.readObject(t, editTableCellChildDoc, ObjectQuery{Outline: true}) + var entries []v2model.OutlineEntry + require.NoError(t, json.Unmarshal(envelopeField(t, outline, "outline"), &entries)) + require.NotEmpty(t, entries) + for _, entry := range entries { + _, _, err := fx.GetObject(ctx, testSpaceId, "obj1", ObjectQuery{Block: entry.Id}) + assert.NoError(t, err, "outline entry %q must resolve as a block reference", entry.Id) + } + + // …and the served-but-unaddressable id is NOT in it, which is why + // the hint may not claim the outline lists every served id + served := fx.readObject(t, editTableCellChildDoc, ObjectQuery{}) + require.Contains(t, string(served), `"id":"dddd1"`, "a default read serves the cell descendant") + for _, entry := range entries { + assert.NotEqual(t, "dddd1", entry.Id) + } + }) + + t.Run("the read 404 names what is addressable instead of promising the outline", func(t *testing.T) { + fx := newV2Fixture(t) + fx.readerMock.EXPECT().ReadObject(mock.Anything, testSpaceId, "obj1"). + Return(editRead(t, editTableCellChildDoc), nil).Maybe() + + _, _, err := fx.GetObject(ctx, testSpaceId, "obj1", ObjectQuery{Block: "dddd1"}) + + apiErr := v2Err(t, err) + assert.Equal(t, http.StatusNotFound, apiErr.Status) + require.NotEmpty(t, apiErr.Issues) + assertAddressabilityHint(t, apiErr.Issues[0]) + }) + + t.Run("the PATCH 404 says the same thing", func(t *testing.T) { + fx := newV2Fixture(t) + fx.expectMutate(editRead(t, editTableCellChildDoc), "headB") + + _, err := fx.PatchObject(ctx, testSpaceId, "obj1", + patchBody(`{"op":"replace_text","id":"dddd1","find":"inside","replace":"x"}`), "", false, true) + + apiErr := v2Err(t, err) + assert.Equal(t, http.StatusNotFound, apiErr.Status) + require.NotEmpty(t, apiErr.Issues) + assert.Equal(t, "ops[0].id", apiErr.Issues[0].Path) + assertAddressabilityHint(t, apiErr.Issues[0]) + }) +} + +// assertAddressabilityHint pins the honest half of the repair loop: the hint +// scopes the outline's promise to the blocks array and says outright that +// ids nested inside a block are not block references. +func assertAddressabilityHint(t *testing.T, issue v2model.Issue) { + t.Helper() + assert.Contains(t, issue.Message, "blocks array") + assert.Contains(t, issue.Hint, "?outline=true") + assert.Contains(t, issue.Hint, "not individually addressable", + "the hint must admit the cell-descendant gap rather than send the caller round the outline again") + assert.Contains(t, issue.Hint, "set_cell", "and name the op that does reach it") +} + +// TestServedLabelsAvoidTailCollisions pins §8.28 property 1 at the read: +// buildCompactIds censuses the WHOLE snapshot before any slicing, so two +// minted ids sharing a last-5 tail both keep their full spelling — a label +// is never served that could resolve to two blocks. It was the load-bearing +// claim of §8.28 and was unpinned. +func TestServedLabelsAvoidTailCollisions(t *testing.T) { + t.Run("two minted ids sharing a tail both stay full", func(t *testing.T) { + fx := newV2Fixture(t) + served := fx.readObject(t, editTailCollisionDoc, ObjectQuery{}) + + ids := blockIdsOf(docBlocks(mustDoc(t, served))) + assert.Equal(t, []string{"1111111111111111117ffff9", "2222222222222222227ffff9"}, ids) + }) + + t.Run("the census covers a SUBSET read, not just the whole document", func(t *testing.T) { + // the mechanism §8.28 property 1 rests on: the avoid-set is built + // over the whole snapshot, so slicing to one block afterwards cannot + // hand out a label the omitted blocks contest + fx := newV2Fixture(t) + served := fx.readObject(t, editTailCollisionDoc, ObjectQuery{Block: "1111111111111111117ffff9"}) + + ids := blockIdsOf(docBlocks(mustDoc(t, served))) + assert.Equal(t, []string{"1111111111111111117ffff9"}, ids, + "a one-block read must not relabel to the tail its hidden twin also claims") + }) + + t.Run("an uncontested minted tail does relabel", func(t *testing.T) { + // the control: without a collision the same read serves labels, so + // the assertions above are about the guard and not about relabeling + // being off + fx := newV2Fixture(t) + served := fx.readObject(t, editMintedDoc, ObjectQuery{}) + + assert.Equal(t, []string{"aaaa1", "bbbb1"}, blockIdsOf(docBlocks(mustDoc(t, served)))) + }) +} diff --git a/core/api/v2/service/canonical_test.go b/core/api/v2/service/canonical_test.go new file mode 100644 index 0000000000..2181dcddfe --- /dev/null +++ b/core/api/v2/service/canonical_test.go @@ -0,0 +1,219 @@ +package v2service + +import ( + "context" + "encoding/json" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + + v2model "github.com/anyproto/anytype-heart/core/api/v2/model" + "github.com/anyproto/anytype-heart/core/domain" + "github.com/anyproto/anytype-heart/pb" + "github.com/anyproto/anytype-heart/pkg/lib/bundle" + "github.com/anyproto/anytype-heart/pkg/lib/localstore/objectstore" + "github.com/anyproto/anytype-heart/pkg/lib/pb/model" + "github.com/anyproto/anytype-heart/util/pbtypes" +) + +// The review's M5 bypass was a canonicalization DIVERGENCE: the prewarm +// (pre-lock) lacked the fold the in-lock pass had, so a folded spelling was +// invisible to the create-missing bound and live inside the lock. Both +// passes now walk resolvePropertyInput; this table pins their equivalence +// over every spelling class, so the next divergence fails here first. +func TestCanonicalizationEquivalence(t *testing.T) { + fx := slugSpaceFixture(t) // manual_property/mood_level (BSON keys), meeting_note type + fx.addRelation(t, testSpaceId, objectstore.TestObject{ + bundle.RelationKeyId: domain.String("rel-twin1"), + bundle.RelationKeyRelationKey: domain.String("6a7663db61fab21cd4b9e201"), + bundle.RelationKeyApiObjectKey: domain.String("twin_key"), + bundle.RelationKeyName: domain.String("Twin one"), + }) + fx.addRelation(t, testSpaceId, objectstore.TestObject{ + bundle.RelationKeyId: domain.String("rel-twin2"), + bundle.RelationKeyRelationKey: domain.String("6a7663db61fab21cd4b9e202"), + bundle.RelationKeyApiObjectKey: domain.String("twin_key"), + bundle.RelationKeyName: domain.String("Twin two"), + }) + + cases := []struct { + name string + input string + // canonical is what BOTH passes must produce; ambiguous/miss inputs + // canonicalize to themselves (each pass then refuses/skips loudly + // on its own path) + canonical string + }{ + {"stored key verbatim", slugPropKey, slugPropKey}, + {"bundled key verbatim", "dueDate", "dueDate"}, + {"live slug", "manual_property", slugPropKey}, + {"folded live slug", "manualProperty", slugPropKey}, + {"folded bundled key", "due_date", "dueDate"}, + {"ambiguous slug stays verbatim", "twin_key", "twin_key"}, + {"miss stays verbatim", "no_such_key", "no_such_key"}, + } + + resolvers := fx.newCreatingResolvers(context.Background(), testSpaceId, true, true) + entries, err := fx.liveProperties(testSpaceId) + require.NoError(t, err) + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + // the prewarm's pass + assert.Equal(t, tc.canonical, resolvers.canonicalPropertyKey(tc.input), "prewarm pass") + + // the in-lock pass (canonicalizeSetPropertyKeys' canon core): + // resolve through the same chain and compare + entry, ok, ambiguous := fx.resolvePropertyInput(tc.input, entries) + inLock := tc.input + if len(ambiguous) == 0 && ok && entry.Key != tc.input { + inLock = entry.Key + } + assert.Equal(t, tc.canonical, inLock, "in-lock pass") + }) + } +} + +func TestV2MintShadowingClosed(t *testing.T) { + // a LEGACY relation stored as my_key — the one fixture shape that + // exposes the stored-key shadow the typeProperties union check missed + legacy := func(t *testing.T) *v2Fixture { + fx := newV2Fixture(t) + fx.addRelation(t, testSpaceId, objectstore.TestObject{ + bundle.RelationKeyId: domain.String("rel-legacy"), + bundle.RelationKeyRelationKey: domain.String("my_key"), + bundle.RelationKeyName: domain.String("Legacy"), + bundle.RelationKeyRelationFormat: domain.Int64(int64(model.RelationFormat_longtext)), + }) + return fx + } + + t.Run("a camel respelling resolves to the legacy stored key, never a twin mint", func(t *testing.T) { + // myKey folds onto my_key: the chain resolves it — no create RPC + // (none is expected; firing one fails the mock) + fx := legacy(t) + var captured *pb.RpcObjectCreateObjectTypeRequest + fx.mwMock.EXPECT().ObjectCreateObjectType(mock.Anything, mock.Anything). + RunAndReturn(func(ctx context.Context, req *pb.RpcObjectCreateObjectTypeRequest) *pb.RpcObjectCreateObjectTypeResponse { + captured = req + return &pb.RpcObjectCreateObjectTypeResponse{ + ObjectId: "type-a", + Error: &pb.RpcObjectCreateObjectTypeResponseError{Code: pb.RpcObjectCreateObjectTypeResponseError_NULL}, + } + }) + fx.expectEtagRead("type-a") + + result, err := fx.CreateType(context.Background(), testSpaceId, + []byte(`{"kind":"object_type","properties":{"name":"Gizmo"},"type_settings":{"api_key":"gizmo","property_definitions":[{"property":"myKey","section":"featured"}]}}`), false, true) + + require.NoError(t, err) + assert.Nil(t, result.Created, "nothing minted — the fold resolved to the legacy relation") + require.NotNil(t, captured) + assert.Equal(t, []string{"rel-legacy"}, + pbtypes.GetStringList(captured.Details, bundle.RelationKeyRecommendedFeaturedRelations.String())) + }) + + t.Run("a spelling whose fold misses but whose slug collides is refused", func(t *testing.T) { + // "My Key" folds to "my key" (the space survives folding) so the + // chain misses — but its minted slug my_key would shadow the legacy + // stored key. The union check on the SLUG spelling refuses loudly. + fx := legacy(t) + + _, err := fx.CreateType(context.Background(), testSpaceId, + []byte(`{"kind":"object_type","properties":{"name":"Gizmo 2"},"type_settings":{"api_key":"gizmo2","property_definitions":[{"property":"My Key","name":"My Key","format":"text"}]}}`), false, true) + + require.Error(t, err) + assert.Contains(t, err.Error(), `"my_key" is already taken`) + }) + + t.Run("two spellings of one key in one request are refused, not twin-minted", func(t *testing.T) { + // the mint cache is keyed by document key and the live snapshot + // predates this request's own mints — without mintedSlugs both + // spellings minted, both stamped warranty_until, permanently + // ambiguous, returned 200 + fx := newV2Fixture(t) + fx.mwMock.EXPECT().ObjectCreateRelation(mock.Anything, mock.Anything). + Return(&pb.RpcObjectCreateRelationResponse{ + ObjectId: "rel-w1", Key: "6a7663db61fab21cd4b9e203", + Error: &pb.RpcObjectCreateRelationResponseError{Code: pb.RpcObjectCreateRelationResponseError_NULL}, + }).Once() // the FIRST spelling mints; a second create fails the mock + + _, err := fx.CreateType(context.Background(), testSpaceId, + []byte(`{"kind":"object_type","properties":{"name":"Warrantied"},"type_settings":{"api_key":"warrantied","property_definitions":[{"property":"warranty_until","name":"W","format":"date"},{"property":"warrantyUntil","name":"W2","format":"date"}]}}`), false, true) + + require.Error(t, err) + assert.Contains(t, err.Error(), "two spellings of one key") + }) + + t.Run("a fold-colliding property mint is refused", func(t *testing.T) { + // minting moodlevel beside mood_level would make the folded + // spelling permanently ambiguous for every caller + fx := slugSpaceFixture(t) + + _, err := fx.CreateProperty(context.Background(), testSpaceId, + v2model.CreatePropertyRequest{Key: "moodlevel", Name: "Mood 2", Format: "text"}, false) + + apiErr := v2ErrWithIssue(t, err) + require.NotEmpty(t, apiErr.Issues) + assert.Contains(t, apiErr.Issues[0].Message, "taken") + }) +} + +func TestV2HiddenHoldersVacateTheSlugNamespace(t *testing.T) { + // a hidden holder is invisible and undeletable to the caller — it must + // not make a visible holder's slug a permanent 400 or downgrade its row + fx := slugSpaceFixture(t) + fx.addType(t, testSpaceId, objectstore.TestObject{ + bundle.RelationKeyId: domain.String("type-hidden"), + bundle.RelationKeyUniqueKey: domain.String("ot-6a7663db61fab21cd4b9e204"), + bundle.RelationKeyApiObjectKey: domain.String("meeting_note"), + bundle.RelationKeyName: domain.String("Hidden twin"), + bundle.RelationKeyIsHidden: domain.Bool(true), + }) + + t.Run("the visible holder still resolves by slug", func(t *testing.T) { + fx.mwMock.EXPECT().ObjectSetIsArchived(mock.Anything, &pb.RpcObjectSetIsArchivedRequest{ + ContextId: "type-meeting", IsArchived: true, + }).Return(&pb.RpcObjectSetIsArchivedResponse{Error: &pb.RpcObjectSetIsArchivedResponseError{Code: pb.RpcObjectSetIsArchivedResponseError_NULL}}) + + result, err := fx.DeleteType(context.Background(), testSpaceId, "meeting_note", false) + + require.NoError(t, err) + assert.Equal(t, "type-meeting", result.Id) + }) + + t.Run("the visible holder's row keeps its slug", func(t *testing.T) { + keys, err := fx.typeKeysById(testSpaceId) + require.NoError(t, err) + assert.Equal(t, "meeting_note", keys["type-meeting"]) + }) +} + +func TestV2CanonicalizeDocumentKeysDeterministicError(t *testing.T) { + // two spellings collapsing onto one key must name the same path on + // every run (the rewrite loop used to range an unsorted map) + fx := slugSpaceFixture(t) + body := []byte(`{"version":1,"properties":{"manual_property":"a","` + slugPropKey + `":"b","manualProperty":"c"}}`) + var firstPath string + for i := 0; i < 8; i++ { + _, _, err := fx.canonicalizeDocumentKeys(testSpaceId, body) + var apiErr *v2model.Error + require.ErrorAs(t, err, &apiErr) + require.NotEmpty(t, apiErr.Issues) + if i == 0 { + firstPath = apiErr.Issues[0].Path + continue + } + assert.Equal(t, firstPath, apiErr.Issues[0].Path, "run %d", i) + } +} + +// sanity: the JSON in the twin-spelling test parses (guards the test body +// itself against quoting slips) +func TestCanonicalTestBodiesParse(t *testing.T) { + var v map[string]any + require.NoError(t, json.Unmarshal([]byte(`{"kind":"objectType","key":"warrantied","properties":{"name":"Warrantied"}, + "type_settings":{"property_definitions":[{"property":"warranty_until","name":"W","format":"date"},{"property":"warrantyUntil","name":"W2","format":"date"}]}}`), &v)) +} diff --git a/core/api/v2/service/cause3_test.go b/core/api/v2/service/cause3_test.go new file mode 100644 index 0000000000..59efdea8b7 --- /dev/null +++ b/core/api/v2/service/cause3_test.go @@ -0,0 +1,359 @@ +package v2service + +import ( + "context" + "encoding/json" + "net/http" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + v2model "github.com/anyproto/anytype-heart/core/api/v2/model" + "github.com/anyproto/anytype-heart/core/domain" + "github.com/anyproto/anytype-heart/pkg/lib/bundle" + "github.com/anyproto/anytype-heart/pkg/lib/localstore/objectstore" + "github.com/anyproto/anytype-heart/pkg/lib/pb/model" +) + +// Review cause 3: output learned slugs, input did not — the listings +// advertised served spellings the query channels rejected, and GET emitted +// keys PUT rejected. Every channel below takes the advertised spelling. + +// slugQueryFixture is slugSpaceFixture plus one object carrying a value +// under the BSON stored key — the row a slug-spelled query must find. +func slugQueryFixture(t *testing.T) *v2Fixture { + fx := slugSpaceFixture(t) + fx.objectStore.AddObjects(t, testSpaceId, []objectstore.TestObject{{ + bundle.RelationKeyId: domain.String("note1"), + bundle.RelationKeyName: domain.String("Standup"), + bundle.RelationKeyType: domain.String("type-meeting"), + domain.RelationKey(slugPropKey): domain.String("hello"), + bundle.RelationKeyResolvedLayout: domain.Int64(int64(model.ObjectType_basic)), + bundle.RelationKeyLastModifiedBy: domain.String("x"), + bundle.RelationKeyLastOpenedDate: domain.Int64(1), + bundle.RelationKeyLastUsedDate: domain.Int64(1), + bundle.RelationKeyCreatedDate: domain.Int64(1), + bundle.RelationKeyLastModifiedDate: domain.Int64(1000), + }}) + return fx +} + +func TestV2SearchSpeaksServedSpellings(t *testing.T) { + ctx := context.Background() + + t.Run("a structured filter on the served slug finds the stored-key value", func(t *testing.T) { + // before: the filter bound RelationKey "manual_property" — a + // spelling the store never matches — silently zero rows + fx := slugQueryFixture(t) + + rows, total, _, _, err := fx.SearchObjects(ctx, testSpaceId, v2model.SearchRequest{ + Filters: json.RawMessage(`[{"property":"manual_property","condition":"equal","value":"hello"}]`), + }, 0, 25) + + require.NoError(t, err) + assert.Equal(t, 1, total) + require.Len(t, rows, 1) + assert.Equal(t, "note1", rows[0].Id) + }) + + t.Run("the compact filter string takes the served slug too", func(t *testing.T) { + fx := slugQueryFixture(t) + + rows, _, _, _, err := fx.SearchObjects(ctx, testSpaceId, v2model.SearchRequest{ + Filter: `manual_property = "hello"`, + }, 0, 25) + + require.NoError(t, err) + require.Len(t, rows, 1) + assert.Equal(t, "note1", rows[0].Id) + }) + + t.Run("fields= takes the served slug and emits under it", func(t *testing.T) { + fx := slugQueryFixture(t) + + rows, _, _, _, err := fx.SearchObjects(ctx, testSpaceId, v2model.SearchRequest{ + Fields: []string{"manual_property"}, + }, 0, 25) + + require.NoError(t, err) + require.Len(t, rows, 1) + require.NotNil(t, rows[0].Properties) + assert.Equal(t, "hello", rows[0].Properties["manual_property"], + "the value reads from the stored key and emits under the requested spelling") + }) + + t.Run("sorts take the served slug", func(t *testing.T) { + fx := slugQueryFixture(t) + + _, _, _, _, err := fx.SearchObjects(ctx, testSpaceId, v2model.SearchRequest{ + Sorts: json.RawMessage(`[{"property":"manual_property","direction":"asc"}]`), + }, 0, 25) + + require.NoError(t, err) + }) + + t.Run("a type filter LEAF resolves the slug and rejects a corpse", func(t *testing.T) { + // the reviewed inconsistency: the same spelling worked at top level + // and 400'd one level down, and a UI-deleted type was a usable + // query scope + fx := slugQueryFixture(t) + + rows, _, _, _, err := fx.SearchObjects(ctx, testSpaceId, v2model.SearchRequest{ + Filters: json.RawMessage(`[{"property":"type","condition":"equal","value":"meeting_note"}]`), + }, 0, 25) + require.NoError(t, err) + require.Len(t, rows, 1) + + fx.addType(t, testSpaceId, objectstore.TestObject{ + bundle.RelationKeyId: domain.String("type-corpse"), + bundle.RelationKeyUniqueKey: domain.String("ot-corpsetype"), + bundle.RelationKeyName: domain.String("Gone"), + bundle.RelationKeyIsUninstalled: domain.Bool(true), + }) + _, _, _, _, err = fx.SearchObjects(ctx, testSpaceId, v2model.SearchRequest{ + Filters: json.RawMessage(`[{"property":"type","condition":"equal","value":"corpsetype"}]`), + }, 0, 25) + var apiErr *v2model.Error + require.ErrorAs(t, err, &apiErr) + assert.Equal(t, http.StatusBadRequest, apiErr.Status) + }) + + t.Run("the did-you-mean candidates speak the served spelling", func(t *testing.T) { + fx := slugQueryFixture(t) + + _, _, _, _, err := fx.SearchObjects(ctx, testSpaceId, v2model.SearchRequest{ + Filters: json.RawMessage(`[{"property":"manual_prop","condition":"equal","value":"x"}]`), + }, 0, 25) + + var apiErr *v2model.Error + require.ErrorAs(t, err, &apiErr) + require.NotEmpty(t, apiErr.Issues) + assert.Contains(t, apiErr.Issues[0].Hint+apiErr.Issues[0].Message, "manual_property", + "the candidate list must advertise the spelling the channel accepts") + assert.NotContains(t, apiErr.Issues[0].Message, slugPropKey, + "never advertise the BSON spelling once the slug serves") + }) +} + +func TestV2ListFieldsSpeakServedSpellings(t *testing.T) { + fx := slugQueryFixture(t) + assert.NoError(t, fx.validateListFields(testSpaceId, []string{"manual_property"})) + assert.NoError(t, fx.validateListFields(testSpaceId, []string{slugPropKey}), "the stored spelling stays valid") + err := fx.validateListFields(testSpaceId, []string{"not_a_key"}) + require.Error(t, err) +} + +func TestV2TypeKeyExistsIsCorpseAndChainAware(t *testing.T) { + fx := slugSpaceFixture(t) + fx.addType(t, testSpaceId, objectstore.TestObject{ + bundle.RelationKeyId: domain.String("type-corpse"), + bundle.RelationKeyUniqueKey: domain.String("ot-corpsetype"), + bundle.RelationKeyName: domain.String("Gone"), + bundle.RelationKeyIsUninstalled: domain.Bool(true), + }) + + t.Run("creating an object of a UI-deleted type is refused", func(t *testing.T) { + // before: typeKeyExists resolved the corpse and the create passed + _, err := fx.CreateObject(context.Background(), testSpaceId, + []byte(`{"version":1,"type":"corpsetype","properties":{"name":"zombie"}}`), false, true) + + var apiErr *v2model.Error + require.ErrorAs(t, err, &apiErr) + assert.Equal(t, http.StatusBadRequest, apiErr.Status) + }) + + t.Run("the slug spelling gates through", func(t *testing.T) { + captured := fx.expectCreate("obj-slugtyped") + fx.expectEtagRead("obj-slugtyped") + + _, err := fx.CreateObject(context.Background(), testSpaceId, + []byte(`{"version":1,"type":"meeting_note","properties":{"name":"ok"}}`), false, true) + + require.NoError(t, err) + require.NotNil(t, *captured) + }) +} + +// corpseKeyCloneFixture holds a UI-deleted relation whose key an object +// still carries, plus a LIVE relation spelled one character away — the +// near-miss that made the refusal actively harmful (the did-you-mean steered +// the caller to move the value onto an unrelated property). +func corpseKeyCloneFixture(t *testing.T) *v2Fixture { + fx := newV2Fixture(t) + fx.addRelation(t, testSpaceId, objectstore.TestObject{ + bundle.RelationKeyId: domain.String("rel-corpse"), + bundle.RelationKeyRelationKey: domain.String("corpse_key"), + bundle.RelationKeyName: domain.String("Deleted in UI"), + bundle.RelationKeyIsUninstalled: domain.Bool(true), + }) + fx.addRelation(t, testSpaceId, objectstore.TestObject{ + bundle.RelationKeyId: domain.String("rel-nearmiss"), + bundle.RelationKeyRelationKey: domain.String("corpse_kez"), + bundle.RelationKeyName: domain.String("Near miss"), + }) + return fx +} + +func TestV2CreateToleratesCorpseHeldKeys(t *testing.T) { + // Review cause 3 was framed as a GET→PUT concern and its tolerance was + // retired with PUT (§8.27) on the grounds that create is live-only and + // PATCH names only what it edits. BOTH halves were wrong (§8.29): PATCH + // has its own in-document escape (checkKey passes any key already on the + // document), and create is the channel a read body is pasted into — "a + // pasted read body creates a copy". Live-only there broke the one loop + // it is advertised for. + t.Run("a read body holding a corpse key creates a copy", func(t *testing.T) { + // given + fx := corpseKeyCloneFixture(t) + captured := fx.expectCreate("clone1") + fx.expectEtagRead("clone1") + + // when — the bytes a GET of such an object serves + _, err := fx.CreateObject(context.Background(), testSpaceId, + []byte(`{"version":1,"type":"page","properties":{"name":"Fresh","corpse_key":"x"}}`), false, true) + + // then + require.NoError(t, err) + require.NotNil(t, *captured) + assert.Equal(t, "x", (*captured).Details.Fields["corpse_key"].GetStringValue(), + "the value is carried, not dropped") + }) + + t.Run("a key no relation holds at all is still refused", func(t *testing.T) { + // the tolerance is a round-trip escape, not an address: only a key + // SOME relation object holds passes + fx := corpseKeyCloneFixture(t) + + _, err := fx.CreateObject(context.Background(), testSpaceId, + []byte(`{"version":1,"type":"page","properties":{"name":"Fresh","never_existed":"x"}}`), false, true) + + apiErr := v2Err(t, err) + assert.Equal(t, http.StatusBadRequest, apiErr.Status) + require.NotEmpty(t, apiErr.Issues) + assert.Equal(t, "/properties/never_existed", apiErr.Issues[0].Path) + }) + + t.Run("no did-you-mean steers a corpse-held key onto a near-miss live property", func(t *testing.T) { + // the actively harmful half: "did you mean corpse_kez?" invited the + // caller to write the value onto an unrelated property + fx := corpseKeyCloneFixture(t) + fx.expectCreate("clone2") + fx.expectEtagRead("clone2") + + _, err := fx.CreateObject(context.Background(), testSpaceId, + []byte(`{"version":1,"type":"page","properties":{"name":"Fresh","corpse_key":"x"}}`), false, true) + + require.NoError(t, err) + }) + + t.Run("an ARCHIVED relation's key is tolerated too", func(t *testing.T) { + // the two ways a relation dies are not the same query. The fixture + // above uses isUninstalled (UI delete), which no store default + // filters; the explicit no-op `isArchived Condition:None` in + // propertyKeyHeldByAnyRelation exists solely to suppress the store's + // INJECTED isArchived:false default — so without that clause an + // archived claimant is invisible and this create 400s. It was the one + // arm of the tolerance nothing pinned. + fx := newV2Fixture(t) + fx.addRelation(t, testSpaceId, objectstore.TestObject{ + bundle.RelationKeyId: domain.String("rel-archived"), + bundle.RelationKeyRelationKey: domain.String("archived_key"), + bundle.RelationKeyName: domain.String("Archived"), + bundle.RelationKeyIsArchived: domain.Bool(true), + }) + captured := fx.expectCreate("clone3") + fx.expectEtagRead("clone3") + + _, err := fx.CreateObject(context.Background(), testSpaceId, + []byte(`{"version":1,"type":"page","properties":{"name":"Fresh","archived_key":"x"}}`), false, true) + + require.NoError(t, err) + require.NotNil(t, *captured) + assert.Equal(t, "x", (*captured).Details.Fields["archived_key"].GetStringValue()) + }) +} + +func TestV2FieldAliasSurvivesACorpseClaimant(t *testing.T) { + // one uninstalled mimeType relation used to deactivate the alias + // space-wide — silently dropping the field from every file row/filter + fx := newV2Fixture(t) + fx.addRelation(t, testSpaceId, objectstore.TestObject{ + bundle.RelationKeyId: domain.String("rel-mime-corpse"), + bundle.RelationKeyRelationKey: domain.String("mimeType"), + bundle.RelationKeyName: domain.String("Old custom mimeType"), + bundle.RelationKeyIsUninstalled: domain.Bool(true), + }) + + aliases := fx.activeFieldAliases(testSpaceId) + assert.Equal(t, bundle.RelationKeyFileMimeType, aliases["mimeType"], + "a corpse must not deactivate the alias") + + // a LIVE claimant still wins + fx.addRelation(t, testSpaceId, objectstore.TestObject{ + bundle.RelationKeyId: domain.String("rel-mime-live"), + bundle.RelationKeyRelationKey: domain.String("mimeType"), + bundle.RelationKeyName: domain.String("Custom mimeType"), + }) + aliases = fx.activeFieldAliases(testSpaceId) + _, active := aliases["mimeType"] + assert.False(t, active, "a live property claiming the spelling deactivates the alias") +} + +func TestV2CreateSetCanonicalizesViewKeys(t *testing.T) { + // the set DOCUMENT persists filter keys — a served slug landing there + // would bind a dataview filter the store never matches, silently + fx := slugSpaceFixture(t) + // the type recommends the slug-keyed property, so it is in the R9 set + // (a full record — AddObjects replaces by id) + fx.addType(t, testSpaceId, objectstore.TestObject{ + bundle.RelationKeyId: domain.String("type-meeting"), + bundle.RelationKeyUniqueKey: domain.String("ot-" + slugTypeKey), + bundle.RelationKeyApiObjectKey: domain.String("meeting_note"), + bundle.RelationKeyName: domain.String("Meeting note"), + bundle.RelationKeyRecommendedRelations: domain.StringList([]string{"rel-manual"}), + }) + captured := fx.expectCreate("set1") + fx.expectEtagRead("set1") + + result, err := fx.CreateSet(context.Background(), testSpaceId, v2model.CreateSetRequest{ + Name: "My set", Type: "meeting_note", + Filters: json.RawMessage(`[{"property":"manual_property","condition":"equal","value":"hello"}]`), + }, false, true) + + require.NoError(t, err) + require.NotNil(t, *captured) + _ = result + // the dataview block's filter must bind the STORED key + var filterKeys []string + for _, block := range (*captured).Blocks { + if dv := block.GetDataview(); dv != nil { + for _, view := range dv.Views { + for _, filter := range view.Filters { + filterKeys = append(filterKeys, filter.RelationKey) + } + } + } + } + assert.Contains(t, filterKeys, slugPropKey) + assert.NotContains(t, filterKeys, "manual_property") +} + +func TestV2FilterStringTakesBundledSlugs(t *testing.T) { + // the compact string validates keys BEFORE canonicalization, so the + // acceptance set must carry the bundled derived slug too — due_date in + // a filter string must work exactly as it does on routes and documents + fx := slugQueryFixture(t) + fx.addRelation(t, testSpaceId, objectstore.TestObject{ + bundle.RelationKeyId: domain.String("rel-duedate"), + bundle.RelationKeyRelationKey: domain.String("dueDate"), + bundle.RelationKeyName: domain.String("Due date"), + bundle.RelationKeyRelationFormat: domain.Int64(int64(model.RelationFormat_date)), + }) + + _, _, _, _, err := fx.SearchObjects(context.Background(), testSpaceId, v2model.SearchRequest{ + Filter: `due_date IS EMPTY`, + }, 0, 25) + + require.NoError(t, err) +} diff --git a/core/api/v2/service/chat.go b/core/api/v2/service/chat.go new file mode 100644 index 0000000000..abde575e3b --- /dev/null +++ b/core/api/v2/service/chat.go @@ -0,0 +1,654 @@ +package v2service + +// chat.go implements the Phase-6 chat surface (APIV2.md §8.7, +// APIV2_SURFACES.md §5). The phase's finding: the middleware already +// returns chatState and message_count on every messages read and v1 drops +// both — so a polling agent has no cheap peek and the ChatReadMessages +// last_state_id race guard was unreachable (no v1 response ever carried a +// state id). v2 passes both through and POST read forwards the guard. +// +// C7 etag/If-Match deliberately does NOT apply to chats: order ids and +// last_state_id are the stream's native concurrency vocabulary. + +import ( + "context" + "fmt" + "net/http" + "strings" + + "github.com/gogo/protobuf/types" + + "github.com/anyproto/anytype-heart/core/api/util" + v2model "github.com/anyproto/anytype-heart/core/api/v2/model" + "github.com/anyproto/anytype-heart/core/block/chats/chatmodel" + "github.com/anyproto/anytype-heart/core/block/editor/chatobject" + "github.com/anyproto/anytype-heart/core/domain" + "github.com/anyproto/anytype-heart/pb" + "github.com/anyproto/anytype-heart/pkg/lib/anyblockjson" + "github.com/anyproto/anytype-heart/pkg/lib/bundle" + "github.com/anyproto/anytype-heart/pkg/lib/database" + "github.com/anyproto/anytype-heart/pkg/lib/pb/model" + "github.com/anyproto/anytype-heart/util/pbtypes" + textutil "github.com/anyproto/anytype-heart/util/text" +) + +// v2MarkupHint is the D′1 caveat, stated wherever message text fails to +// parse: text is §8 markup SOURCE on both read and write. +const v2MarkupHint = "message text is inline markup source (SPEC §8): *, [, ` and syntax mint real marks; escape literal specials with a backslash" + +// maxChatAttachments caps the attachment list per message — the bound the +// chatMessage discovery schema advertises (maxItems), enforced here so the +// strict schema stays true: an unbounded list means one store lookup per id +// and a permanently replicated CRDT change carrying every entry. +const maxChatAttachments = 32 + +// defaultChatMessagesLimit mirrors the C10 default page size for the +// cursor-paged messages read. +const defaultChatMessagesLimit = 25 + +// +// ---- chat list + create ---- +// + +// ListChats returns C5 chat rows via a store query over the chat layouts — +// NO chat opens (opening every chat is the GO-7302 startup cost; Q3 keeps +// the list counter-free, per-chat state comes free on the messages read). +func (s *Service) ListChats(ctx context.Context, spaceId string, offset, limit int) ([]v2model.ChatRow, int, bool, error) { + if err := s.ensureSpace(ctx, spaceId); err != nil { + return nil, 0, false, err + } + records, total, err := s.store.SpaceIndex(spaceId).QueryAndCount(database.Query{ + Filters: []database.FilterRequest{ + { + RelationKey: bundle.RelationKeyResolvedLayout, + Condition: model.BlockContentDataviewFilter_In, + Value: domain.Int64List(util.LayoutsToIntArgs(util.ChatLayouts)), + }, + { + RelationKey: bundle.RelationKeyIsHidden, + Condition: model.BlockContentDataviewFilter_NotEqual, + Value: domain.Bool(true), + }, + }, + Sorts: []database.SortRequest{{ + RelationKey: bundle.RelationKeyLastModifiedDate, + Type: model.BlockContentDataviewSort_Desc, + IncludeTime: true, + }}, + Offset: offset, + Limit: limit + 1, // one extra record detects has_more without a second scan + }) + if err != nil { + return nil, 0, false, fmt.Errorf("query chats in space %s: %w", spaceId, err) + } + hasMore := len(records) > limit + if hasMore { + records = records[:limit] + } + rows := make([]v2model.ChatRow, 0, len(records)) + for _, record := range records { + rows = append(rows, v2model.ChatRow{ + Id: record.Details.GetString(bundle.RelationKeyId), + Name: record.Details.GetString(bundle.RelationKeyName), + }) + } + return rows, total, hasMore, nil +} + +// CreateChat implements POST /v2/spaces/{space_id}/chats: a thin ObjectCreate +// with the chatDerived type (NOT the Phase-2 snapshot path, which has never +// been exercised for store-backed smartblocks). +func (s *Service) CreateChat(ctx context.Context, spaceId string, req v2model.CreateChatRequest, dryRun bool) (*v2model.ChatResult, error) { + if err := s.ensureSpaceWrite(ctx, spaceId); err != nil { + return nil, err + } + if req.Name == "" { + return nil, v2model.ValidationFailed("chat name is required", + v2model.Issue{Path: "/name", Message: "the chat list row is {id, name} — an unnamed chat is unaddressable by name"}) + } + if dryRun { + return &v2model.ChatResult{Name: req.Name, DryRun: true}, nil + } + resp := s.mw.ObjectCreate(ctx, &pb.RpcObjectCreateRequest{ + SpaceId: spaceId, + ObjectTypeUniqueKey: bundle.TypeKeyChatDerived.URL(), + Details: &types.Struct{Fields: map[string]*types.Value{bundle.RelationKeyName.String(): pbtypes.String(req.Name)}}, + }) + if resp.Error != nil && resp.Error.Code != pb.RpcObjectCreateResponseError_NULL { + return nil, v2ChatRpcError("create chat", int32(resp.Error.Code), int32(pb.RpcObjectCreateResponseError_BAD_INPUT), resp.Error.Description) + } + return &v2model.ChatResult{Id: resp.ObjectId, Name: req.Name}, nil +} + +// +// ---- messages read (state + message_count passthrough) ---- +// + +// ChatMessagesQuery carries the GET messages parameters: exclusive +// after/before order-id cursors and the Q4 reactions mode. +type ChatMessagesQuery struct { + After string + Before string + Limit int + FullReactions bool +} + +// GetChatMessages implements GET .../chats/{chat_id}/messages. The response +// carries the chatState and message_count the RPC already returns — the +// passthrough v1 dropped (zero extra RPC cost). Messages come back in +// ascending order-id order. The RPC is asked for limit+1 to detect +// has_more without guessing from len==limit (the C10 spirit): a forward +// walk (?after alone — the only ASC query in the repository) trims the +// newest extra and continues with next_after; every other query is anchored +// at its newest end (the repository sorts DESC), so the OLDEST extra is +// trimmed and paging continues backward with next_before. +func (s *Service) GetChatMessages(ctx context.Context, spaceId, chatId string, q ChatMessagesQuery) (*v2model.ChatMessagesResponse, error) { + if err := s.ensureChat(ctx, spaceId, chatId); err != nil { + return nil, err + } + limit := q.Limit + if limit <= 0 { + limit = defaultChatMessagesLimit + } + resp := s.mw.ChatGetMessages(ctx, &pb.RpcChatGetMessagesRequest{ + ChatObjectId: chatId, + AfterOrderId: q.After, + BeforeOrderId: q.Before, + Limit: int32(limit + 1), // one extra detects has_more + }) + if resp.Error != nil && resp.Error.Code != pb.RpcChatGetMessagesResponseError_NULL { + return nil, v2ChatRpcError("get chat messages", int32(resp.Error.Code), int32(pb.RpcChatGetMessagesResponseError_BAD_INPUT), resp.Error.Description) + } + forward := q.After != "" && q.Before == "" + protos := resp.Messages + hasMore := len(protos) > limit + if hasMore { + if forward { + protos = protos[:limit] // ascending: the extra is the newest + } else { + protos = protos[len(protos)-limit:] // newest-anchored: the extra is the oldest + } + } + opts := v2model.ChatMessageOptions{ + SpaceId: spaceId, + FullReactions: q.FullReactions, + ParticipantName: s.participantNameLookup(spaceId), + } + messages := make([]v2model.ChatMessage, 0, len(protos)) + for _, msg := range protos { + messages = append(messages, v2model.ChatMessageFromProto(msg, opts)) + } + out := &v2model.ChatMessagesResponse{ + Messages: messages, + State: v2model.ChatStateFromProto(resp.ChatState), + MessageCount: int(resp.MessageCount), + HasMore: hasMore, + } + if hasMore && len(messages) > 0 { + if forward { + out.NextAfter = messages[len(messages)-1].Order + } else { + out.NextBefore = messages[0].Order + } + } + return out, nil +} + +// +// ---- message mutations ---- +// + +// AddChatMessage implements POST .../messages: text is §8 markup source +// parsed by the anyblockjson inline codec (offset mark arrays never cross +// the API); attachments are bare object ids with the kind inferred from +// each target's layout. A dry run validates everything and sends nothing. +func (s *Service) AddChatMessage(ctx context.Context, spaceId, chatId string, req v2model.AddChatMessageRequest, dryRun bool) (*v2model.ChatMessageResult, error) { + if err := s.ensureChatWrite(ctx, spaceId, chatId); err != nil { + return nil, err + } + if req.Text == "" && len(req.Attachments) == 0 { + return nil, v2model.ValidationFailed("a message needs text or attachments", + v2model.Issue{Path: "/text", Message: "text and attachments are both empty"}) + } + text, marks, err := anyblockjson.ParseInlineText(req.Text) + if err != nil { + return nil, v2model.ValidationFailed("message text does not parse as inline markup", + v2model.Issue{Path: "/text", Message: err.Error(), Hint: v2MarkupHint}) + } + if err := v2ValidateChatTextLength(text); err != nil { + return nil, err + } + attachments, err := s.resolveChatAttachments(spaceId, req.Attachments) + if err != nil { + return nil, err + } + if dryRun { + return &v2model.ChatMessageResult{DryRun: true}, nil + } + resp := s.mw.ChatAddMessage(ctx, &pb.RpcChatAddMessageRequest{ + ChatObjectId: chatId, + Message: &model.ChatMessage{ + ReplyToMessageId: req.ReplyTo, + Message: &model.ChatMessageMessageContent{ + Text: text, + Style: model.BlockContentText_Paragraph, + Marks: marks, + }, + Attachments: attachments, + }, + }) + if resp.Error != nil && resp.Error.Code != pb.RpcChatAddMessageResponseError_NULL { + return nil, v2ChatRpcError("add chat message", int32(resp.Error.Code), int32(pb.RpcChatAddMessageResponseError_BAD_INPUT), resp.Error.Description) + } + return &v2model.ChatMessageResult{Id: resp.MessageId}, nil +} + +// EditChatMessage implements PATCH .../messages/{message_id} as a text-only +// MERGE: the middleware's edit replaces the whole message content +// (attachments included — chatmodel content = {message, attachments, +// blocks}), so the service reads the message first and carries its style, +// attachments and blocks through unchanged. A dry run stops after the +// existence check. +func (s *Service) EditChatMessage(ctx context.Context, spaceId, chatId, messageId string, req v2model.EditChatMessageRequest, dryRun bool) (*v2model.ChatMessageResult, error) { + if err := s.ensureChatWrite(ctx, spaceId, chatId); err != nil { + return nil, err + } + text, marks, err := anyblockjson.ParseInlineText(req.Text) + if err != nil { + return nil, v2model.ValidationFailed("message text does not parse as inline markup", + v2model.Issue{Path: "/text", Message: err.Error(), Hint: v2MarkupHint}) + } + if err := v2ValidateChatTextLength(text); err != nil { + return nil, err + } + existing, err := s.getChatMessageProto(ctx, chatId, messageId) + if err != nil { + return nil, err + } + if text == "" && len(existing.Attachments) == 0 { + return nil, v2model.ValidationFailed("a message needs text or attachments", + v2model.Issue{Path: "/text", Message: "the edited text is empty and the message has no attachments"}) + } + if dryRun { + return &v2model.ChatMessageResult{Id: messageId, DryRun: true}, nil + } + edited := &model.ChatMessage{ + Message: &model.ChatMessageMessageContent{ + Text: text, + Marks: marks, + }, + Attachments: existing.Attachments, + Blocks: existing.Blocks, + } + if existing.Message != nil { + edited.Message.Style = existing.Message.Style + } + resp := s.mw.ChatEditMessageContent(ctx, &pb.RpcChatEditMessageContentRequest{ + ChatObjectId: chatId, + MessageId: messageId, + EditedMessage: edited, + }) + if resp.Error != nil && resp.Error.Code != pb.RpcChatEditMessageContentResponseError_NULL { + return nil, v2ChatRpcError("edit chat message", int32(resp.Error.Code), int32(pb.RpcChatEditMessageContentResponseError_BAD_INPUT), resp.Error.Description) + } + return &v2model.ChatMessageResult{Id: messageId}, nil +} + +// DeleteChatMessage implements DELETE .../messages/{message_id}. BOTH paths +// run the existence check: the store handler treats deleting a missing +// document as success, so without it the real call would answer 200 for a +// message that never existed while the dry run 404s — C9's contract is +// that the dry run predicts the real call. The read also feeds the file-GC +// warnings: the middleware permanently deletes (skipBin) attachment/link +// targets orphaned by the delete, asynchronously, after the API replied — +// the response names the ids at risk instead of hiding the irreversible +// part behind a 200. +func (s *Service) DeleteChatMessage(ctx context.Context, spaceId, chatId, messageId string, dryRun bool) (*v2model.ChatMessageResult, error) { + if err := s.ensureChatWrite(ctx, spaceId, chatId); err != nil { + return nil, err + } + existing, err := s.getChatMessageProto(ctx, chatId, messageId) + if err != nil { + return nil, err + } + warnings := v2ChatDeleteWarnings(existing) + if dryRun { + return &v2model.ChatMessageResult{Id: messageId, DryRun: true, Warnings: warnings}, nil + } + resp := s.mw.ChatDeleteMessage(ctx, &pb.RpcChatDeleteMessageRequest{ + ChatObjectId: chatId, + MessageId: messageId, + }) + if resp.Error != nil && resp.Error.Code != pb.RpcChatDeleteMessageResponseError_NULL { + return nil, v2ChatRpcError("delete chat message", int32(resp.Error.Code), int32(pb.RpcChatDeleteMessageResponseError_BAD_INPUT), resp.Error.Description) + } + return &v2model.ChatMessageResult{Id: messageId, Warnings: warnings}, nil +} + +// ToggleChatReaction implements POST .../messages/{message_id}/reactions. +// Both paths read the message first: the RPC surfaces a missing message as +// an opaque UNKNOWN_ERROR (HasMyReaction's FindId), so the check turns it +// into a clean 404. A dry run reports the would-be outcome — added is true +// when the caller does not currently carry the reaction — unless the +// service has no account identity to predict with, in which case added is +// omitted with a warning instead of asserting a coin flip. +func (s *Service) ToggleChatReaction(ctx context.Context, spaceId, chatId, messageId string, req v2model.ChatReactionRequest, dryRun bool) (*v2model.ChatReactionResult, error) { + if err := s.ensureChatWrite(ctx, spaceId, chatId); err != nil { + return nil, err + } + if req.Emoji == "" { + return nil, v2model.ValidationFailed("emoji is required", + v2model.Issue{Path: "/emoji", Message: "provide the reaction emoji, e.g. 👍"}) + } + existing, err := s.getChatMessageProto(ctx, chatId, messageId) + if err != nil { + return nil, err + } + if dryRun { + if s.accountId == "" { + return &v2model.ChatReactionResult{DryRun: true, Warnings: []v2model.Issue{{ + Path: "/emoji", + Message: "the would-be outcome could not be predicted: the service has no account identity", + Hint: "run without dry_run for the authoritative added value", + }}}, nil + } + added := true + if existing.Reactions != nil { + if identityList, ok := existing.Reactions.Reactions[req.Emoji]; ok && identityList != nil { + for _, identity := range identityList.Ids { + if identity == s.accountId { + added = false + break + } + } + } + } + return &v2model.ChatReactionResult{Added: &added, DryRun: true}, nil + } + resp := s.mw.ChatToggleMessageReaction(ctx, &pb.RpcChatToggleMessageReactionRequest{ + ChatObjectId: chatId, + MessageId: messageId, + Emoji: req.Emoji, + }) + if resp.Error != nil && resp.Error.Code != pb.RpcChatToggleMessageReactionResponseError_NULL { + return nil, v2ChatRpcError("toggle chat reaction", int32(resp.Error.Code), int32(pb.RpcChatToggleMessageReactionResponseError_BAD_INPUT), resp.Error.Description) + } + added := resp.Added + return &v2model.ChatReactionResult{Added: &added}, nil +} + +// +// ---- read watermark ---- +// + +// ReadChat implements POST .../chats/{chat_id}/read, forwarding +// {up_to, last_state_id, scope} to ChatReadMessages / ChatReadReactions. +// up_to is INCLUSIVE and required for the messages/mentions scopes: the +// underlying range query is `orderId <= up_to`, so an empty bound would +// silently mark nothing (v1's read_all rides exactly that trap). +// last_state_id — the race guard this phase finally makes reachable — is +// required for the SAME reason: the repository additionally ANDs +// `stateId <= last_state_id` and every stored message carries a non-empty +// bson state id, so `stateId <= ""` matches nothing and an omitted guard +// is the identical silent no-op one field over. Both values ride the same +// GET messages response (the newest order + state.last_state_id), so +// requiring them costs the agent no extra call. The reactions scope marks +// ALL unread reactions (the backend takes no bound) and therefore rejects +// up_to/last_state_id. +func (s *Service) ReadChat(ctx context.Context, spaceId, chatId string, req v2model.ChatReadRequest, dryRun bool) (*v2model.ChatReadResult, error) { + if err := s.ensureChatWrite(ctx, spaceId, chatId); err != nil { + return nil, err + } + switch req.Scope { + case "", v2model.ChatReadScopeMessages, v2model.ChatReadScopeMentions: + var missing []v2model.Issue + if req.UpTo == "" { + missing = append(missing, v2model.Issue{Path: "/up_to", Message: "the inclusive order id to mark read up to", + Hint: "use the newest message's order from GET .../messages (a limit=1 read returns it)"}) + } + if req.LastStateId == "" { + missing = append(missing, v2model.Issue{Path: "/last_state_id", Message: "the race guard from the same messages read", + Hint: "use state.last_state_id from GET .../messages — an empty guard matches no message and would silently mark nothing"}) + } + if len(missing) > 0 { + return nil, v2model.ValidationFailed("the read watermark needs up_to and last_state_id", missing...) + } + if dryRun { + return &v2model.ChatReadResult{DryRun: true}, nil + } + readType := pb.RpcChatReadMessages_Messages + if req.Scope == v2model.ChatReadScopeMentions { + readType = pb.RpcChatReadMessages_Mentions + } + resp := s.mw.ChatReadMessages(ctx, &pb.RpcChatReadMessagesRequest{ + ChatObjectId: chatId, + Type: readType, + BeforeOrderId: req.UpTo, + LastStateId: req.LastStateId, + }) + if resp.Error != nil && resp.Error.Code != pb.RpcChatReadMessagesResponseError_NULL { + if resp.Error.Code == pb.RpcChatReadMessagesResponseError_MESSAGES_NOT_FOUND { + return nil, v2model.ValidationFailed("no messages matched the read range", + v2model.Issue{Path: "/up_to", Message: "the chat is empty or up_to is not a valid order id", Hint: "read GET .../messages and use a returned order value"}) + } + return nil, v2ChatRpcError("mark chat read", int32(resp.Error.Code), int32(pb.RpcChatReadMessagesResponseError_BAD_INPUT), resp.Error.Description) + } + return &v2model.ChatReadResult{}, nil + + case v2model.ChatReadScopeReactions: + var issues []v2model.Issue + if req.UpTo != "" { + issues = append(issues, v2model.Issue{Path: "/up_to", Message: "the reactions scope marks ALL unread reactions — it takes no up_to"}) + } + if req.LastStateId != "" { + issues = append(issues, v2model.Issue{Path: "/last_state_id", Message: "the reactions scope marks ALL unread reactions — it takes no last_state_id"}) + } + if len(issues) > 0 { + return nil, v2model.ValidationFailed("the reactions scope is all-or-nothing", issues...) + } + if dryRun { + return &v2model.ChatReadResult{DryRun: true}, nil + } + resp := s.mw.ChatReadReactions(ctx, &pb.RpcChatReadReactionsRequest{ChatObjectId: chatId}) + if resp.Error != nil && resp.Error.Code != pb.RpcChatReadReactionsResponseError_NULL { + return nil, v2ChatRpcError("mark chat reactions read", int32(resp.Error.Code), int32(pb.RpcChatReadReactionsResponseError_BAD_INPUT), resp.Error.Description) + } + return &v2model.ChatReadResult{}, nil + + default: + return nil, v2model.ValidationFailed("invalid scope value", + v2model.Issue{Path: "/scope", Message: fmt.Sprintf("unknown value %q", req.Scope), Hint: "allowed: messages, mentions, reactions"}) + } +} + +// +// ---- helpers ---- +// + +// ensureChat verifies chatId names a chat object in the space: a clean 404 +// for an unknown id and a targeted 400 for a non-chat object, instead of +// the RPC's opaque failure. +func (s *Service) ensureChat(ctx context.Context, spaceId, chatId string) error { + if err := s.ensureSpace(ctx, spaceId); err != nil { + return err + } + details, err := s.store.SpaceIndex(spaceId).GetDetails(chatId) + if err != nil || details.Len() == 0 { + return v2model.NotFound(fmt.Sprintf("chat %q not found in space %q — list chats with GET /v2/spaces/%s/chats", chatId, spaceId, spaceId)) + } + layout := model.ObjectTypeLayout(details.GetInt64(bundle.RelationKeyResolvedLayout)) + for _, chatLayout := range util.ChatLayouts { + if layout == chatLayout { + return nil + } + } + return v2model.ValidationFailed(fmt.Sprintf("object %q is not a chat", chatId), + v2model.Issue{Message: fmt.Sprintf("its layout is %q", layout.String()), Hint: fmt.Sprintf("chat ids come from GET /v2/spaces/%s/chats", spaceId)}) +} + +// ensureChatWrite is ensureChat for the chat WRITE entry points (message +// create/edit/delete, reactions, and the read-watermark advance — a write: +// it mutates synced state). Route-gate precedence: grant space check, then +// the write-verb check, then the chat lookup — a read-only key is refused +// before anything resolves. +func (s *Service) ensureChatWrite(ctx context.Context, spaceId, chatId string) error { + if err := ensureSpaceGranted(ctx, spaceId); err != nil { + return err + } + if err := ensureWriteGranted(ctx); err != nil { + return err + } + return s.ensureChat(ctx, spaceId, chatId) +} + +// participantNameLookup returns a memoized participant-id → display-name +// resolver over the space index. The v1 chat surface resolves names through +// its cross-space subscription cache; the v2 service is store-backed by +// design — participant objects are indexed under their deterministic ids, +// and an unknown participant degrades to an empty name exactly like a v1 +// cache miss. +func (s *Service) participantNameLookup(spaceId string) func(participantId string) string { + index := s.store.SpaceIndex(spaceId) + memo := map[string]string{} + return func(participantId string) string { + if name, ok := memo[participantId]; ok { + return name + } + name := "" + if details, err := index.GetDetails(participantId); err == nil { + name = details.GetString(bundle.RelationKeyName) + } + memo[participantId] = name + return name + } +} + +// resolveChatAttachments turns bare object ids into typed attachments: the +// kind is inferred from each target's layout (image → image, other file +// layouts → file, anything else → link). An unknown id is a path-addressed +// 400 — attaching an object that does not exist would send a broken message. +func (s *Service) resolveChatAttachments(spaceId string, ids []string) ([]*model.ChatMessageAttachment, error) { + if len(ids) == 0 { + return nil, nil + } + if len(ids) > maxChatAttachments { + return nil, v2model.ValidationFailed("too many attachments", + v2model.Issue{Path: "/attachments", + Message: fmt.Sprintf("%d attachments — the cap is %d per message (the bound the chatMessage schema advertises)", len(ids), maxChatAttachments), + Hint: "split the message, or link a collection of the objects instead"}) + } + index := s.store.SpaceIndex(spaceId) + attachments := make([]*model.ChatMessageAttachment, 0, len(ids)) + for i, id := range ids { + details, err := index.GetDetails(id) + if err != nil || details.Len() == 0 { + return nil, v2model.ValidationFailed("attachment target not found", + v2model.Issue{Path: fmt.Sprintf("/attachments/%d", i), + Message: fmt.Sprintf("object %q not found in space %q", id, spaceId), + Hint: "upload files via POST /v2/spaces/{space_id}/files first, or pass an existing object id"}) + } + layout := model.ObjectTypeLayout(details.GetInt64(bundle.RelationKeyResolvedLayout)) + attachmentType := model.ChatMessageAttachment_LINK + switch { + case layout == model.ObjectType_image: + attachmentType = model.ChatMessageAttachment_IMAGE + case util.IsFileLayout(layout): + attachmentType = model.ChatMessageAttachment_FILE + } + attachments = append(attachments, &model.ChatMessageAttachment{Target: id, Type: attachmentType}) + } + return attachments, nil +} + +// getChatMessageProto fetches one message by id (existence checks, the edit +// merge and the dry-run reaction probe). +func (s *Service) getChatMessageProto(ctx context.Context, chatId, messageId string) (*model.ChatMessage, error) { + resp := s.mw.ChatGetMessagesByIds(ctx, &pb.RpcChatGetMessagesByIdsRequest{ + ChatObjectId: chatId, + MessageIds: []string{messageId}, + }) + if resp.Error != nil && resp.Error.Code != pb.RpcChatGetMessagesByIdsResponseError_NULL { + return nil, v2ChatRpcError("get chat message", int32(resp.Error.Code), int32(pb.RpcChatGetMessagesByIdsResponseError_BAD_INPUT), resp.Error.Description) + } + if len(resp.Messages) == 0 || resp.Messages[0] == nil { + return nil, v2model.NotFound(fmt.Sprintf("message %q not found in chat %q", messageId, chatId)) + } + return resp.Messages[0], nil +} + +// v2ValidateChatTextLength enforces the store's message-text cap +// (chatmodel.MaxMessageLength, counted in UTF-16 code units) BEFORE the +// RPC. The chat RPC enums carry no usable error code, so without this +// pre-check an over-long message — the mistake the discovery schema's +// maxLength exists to prevent — would come back as a retry-looping 500. +// The cap applies to the PARSED text, matching what the store validates. +func v2ValidateChatTextLength(parsedText string) error { + if length := len(textutil.StrToUTF16(parsedText)); length > chatmodel.MaxMessageLength { + return v2model.ValidationFailed("message text is too long", + v2model.Issue{Path: "/text", + Message: fmt.Sprintf("the text is %d UTF-16 code units — the cap is %d", length, chatmodel.MaxMessageLength), + Hint: "the cap counts UTF-16 code units (an emoji counts 2+); split the message"}) + } + return nil +} + +// v2ChatDeleteWarnings surfaces the irreversible side effect of a message +// delete: the middleware garbage-collects attachment and link-block targets +// orphaned by the delete with skipBin=true — permanently deleted, not +// binned, asynchronously AFTER the API has replied. The dry run and the +// real receipt both carry the ids at risk (C6 warnings). +func v2ChatDeleteWarnings(msg *model.ChatMessage) []v2model.Issue { + var ids []string + for _, att := range msg.Attachments { + if att != nil && att.Target != "" { + ids = append(ids, att.Target) + } + } + if len(ids) == 0 { + return nil + } + return []v2model.Issue{{ + Path: "/attachments", + Message: fmt.Sprintf("deleting this message may PERMANENTLY delete its attached objects (not moved to the bin): %s", strings.Join(ids, ", ")), + Hint: "an attachment is garbage-collected asynchronously when this message was its only reference", + }} +} + +// v2ChatRpcError maps a chat RPC failure to the C6 shape. BAD_INPUT (code 2 +// across the chat RPC enums) becomes a 400 carrying the middleware's +// description — but the chat RPCs never actually produce it (core +// mapErrorCode has no chat errToCode mappings and defaults everything to +// UNKNOWN_ERROR), so ordinary caller mistakes are classified on the +// description instead of defaulting the whole class to a retry-looping 500. +// The matched strings are pinned by the middleware: chatobject.go wraps +// store validation as "validate: …", any-store's ErrDocNotFound reads +// "document not found", and the two foreign-message refusals are matched +// through the chatobject sentinels themselves (ErrModifyForeignMessage for +// the EDIT path, ErrDeleteForeignMessage for DELETE — surface review M2b: +// matching the delete prose alone left the edit refusal a 500), so a +// rewording in the producer updates the matcher at compile time. The +// forbidden arms run FIRST: the edit refusal rides storestate.ErrValidation, +// and a future "validate:"-wrapped rendering must not downgrade a permanent +// 403 to a 400. +func v2ChatRpcError(op string, code, badInputCode int32, description string) error { + if code == badInputCode { + return v2model.ValidationFailed(fmt.Sprintf("%s: invalid input", op), + v2model.Issue{Message: description}) + } + switch { + case strings.Contains(description, chatobject.ErrModifyForeignMessage.Error()), + strings.Contains(description, chatobject.ErrDeleteForeignMessage.Error()): + return v2model.NewError(http.StatusForbidden, v2model.CodeForbidden, + fmt.Sprintf("%s: %s — only the author can edit or delete a message", op, description)) + case strings.Contains(description, "validate:"): + return v2model.ValidationFailed(fmt.Sprintf("%s: the middleware rejected the message", op), + v2model.Issue{Message: description}) + case strings.Contains(description, "not found"): + return v2model.NotFound(fmt.Sprintf("%s: %s", op, description)) + } + msg := op + " failed" + if description != "" { + msg += ": " + description + } + return v2model.NewError(http.StatusInternalServerError, v2model.CodeInternalError, msg) +} diff --git a/core/api/v2/service/chat_test.go b/core/api/v2/service/chat_test.go new file mode 100644 index 0000000000..e0a07e6157 --- /dev/null +++ b/core/api/v2/service/chat_test.go @@ -0,0 +1,918 @@ +package v2service + +import ( + "context" + "errors" + "fmt" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + + "github.com/anyproto/anytype-heart/core/api/core/mock_apicore" + v2model "github.com/anyproto/anytype-heart/core/api/v2/model" + "github.com/anyproto/anytype-heart/core/block/chats/chatmodel" + "github.com/anyproto/anytype-heart/core/block/editor/chatobject" + "github.com/anyproto/anytype-heart/core/block/editor/storestate" + "github.com/anyproto/anytype-heart/core/domain" + "github.com/anyproto/anytype-heart/pb" + "github.com/anyproto/anytype-heart/pkg/lib/bundle" + "github.com/anyproto/anytype-heart/pkg/lib/localstore/objectstore" + "github.com/anyproto/anytype-heart/pkg/lib/pb/model" +) + +const ( + testChatId = "chat1" + testIdentity = "identityA" +) + +// requireV2Code asserts err is a C6 error with the given code. +func requireV2Code(t *testing.T, err error, wantCode string) { + t.Helper() + assert.Equal(t, wantCode, v2Err(t, err).Code) +} + +// addChat registers a chat object (chatDerived layout) in the store. +func (fx *v2Fixture) addChat(t *testing.T, id, name string, lastModified int64) { + fx.objectStore.AddObjects(t, testSpaceId, []objectstore.TestObject{{ + bundle.RelationKeyId: domain.String(id), + bundle.RelationKeyName: domain.String(name), + bundle.RelationKeyResolvedLayout: domain.Int64(int64(model.ObjectType_chatDerived)), + bundle.RelationKeyLastModifiedDate: domain.Int64(lastModified), + }}) +} + +// addParticipant registers the participant object the author-name +// enrichment resolves (deterministic id, store-backed — no subscriptions). +func (fx *v2Fixture) addParticipant(t *testing.T, identity, name string) string { + participantId := domain.NewParticipantId(testSpaceId, identity) + fx.objectStore.AddObjects(t, testSpaceId, []objectstore.TestObject{{ + bundle.RelationKeyId: domain.String(participantId), + bundle.RelationKeyName: domain.String(name), + bundle.RelationKeyResolvedLayout: domain.Int64(int64(model.ObjectType_participant)), + }}) + return participantId +} + +func chatProtoMessage() *model.ChatMessage { + return &model.ChatMessage{ + Id: "msg1", + OrderId: "00a1", + Creator: testIdentity, + CreatedAt: 1717405200, + Message: &model.ChatMessageMessageContent{ + Text: "can you check the doc?", + Style: model.BlockContentText_Paragraph, + Marks: []*model.BlockContentTextMark{{ + Range: &model.Range{From: 8, To: 13}, + Type: model.BlockContentTextMark_Bold, + }}, + }, + Reactions: &model.ChatMessageReactions{ + Reactions: map[string]*model.ChatMessageReactionsIdentityList{ + "👍": {Ids: []string{testIdentity, "identityB"}}, + }, + }, + } +} + +func TestV2ListChats(t *testing.T) { + t.Run("C5 rows via the store — no chat opens, hidden and non-chat excluded", func(t *testing.T) { + // given: the mock middleware has NO expectations — any RPC (a chat + // open, a subscription) would fail the test, which is the phase's + // no-chat-opens guarantee (GO-7302) + fx := newV2Fixture(t) + fx.addChat(t, "chatB", "Team chat", 2000) + fx.addChat(t, "chatA", "Old chat", 1000) + fx.objectStore.AddObjects(t, testSpaceId, []objectstore.TestObject{ + { + bundle.RelationKeyId: domain.String("hiddenChat"), + bundle.RelationKeyName: domain.String("Hidden"), + bundle.RelationKeyResolvedLayout: domain.Int64(int64(model.ObjectType_chatDerived)), + bundle.RelationKeyIsHidden: domain.Bool(true), + }, + { + bundle.RelationKeyId: domain.String("page1"), + bundle.RelationKeyName: domain.String("A page"), + bundle.RelationKeyResolvedLayout: domain.Int64(int64(model.ObjectType_basic)), + }, + }) + want := []v2model.ChatRow{ + {Id: "chatB", Name: "Team chat"}, + {Id: "chatA", Name: "Old chat"}, + } + + // when + rows, total, hasMore, err := fx.ListChats(context.Background(), testSpaceId, 0, 25) + + // then + require.NoError(t, err) + assert.Equal(t, want, rows, "rows are {id,name}, newest-modified first — no type object, no counters (Q3)") + assert.Equal(t, 2, total) + assert.False(t, hasMore) + }) + + t.Run("pagination reports has_more with an honest total", func(t *testing.T) { + // given: THREE chats and limit=1 — the fetch reads limit+1 = 2 + // records, so the banned v1 `total = len(fetched)` pattern would + // report 2 while the honest QueryAndCount total is 3; two chats + // could not tell the implementations apart + fx := newV2Fixture(t) + fx.addChat(t, "chatC", "C", 3000) + fx.addChat(t, "chatB", "B", 2000) + fx.addChat(t, "chatA", "A", 1000) + + // when + rows, total, hasMore, err := fx.ListChats(context.Background(), testSpaceId, 0, 1) + + // then + require.NoError(t, err) + require.Len(t, rows, 1) + assert.Equal(t, 3, total, "total must be the store count, not len(fetched) — the Phase-4-banned pattern") + assert.True(t, hasMore) + }) + + t.Run("unknown space is a 404", func(t *testing.T) { + fx := newV2Fixture(t) + _, _, _, err := fx.ListChats(context.Background(), "nope", 0, 25) + requireV2Code(t, err, v2model.CodeNotFound) + }) +} + +func TestV2CreateChat(t *testing.T) { + t.Run("thin ObjectCreate with the chatDerived type", func(t *testing.T) { + // given + fx := newV2Fixture(t) + fx.mwMock.EXPECT().ObjectCreate(mock.Anything, mock.MatchedBy(func(req *pb.RpcObjectCreateRequest) bool { + return req.SpaceId == testSpaceId && + req.ObjectTypeUniqueKey == bundle.TypeKeyChatDerived.URL() && + req.Details.GetFields()["name"].GetStringValue() == "Project chat" + })).Return(&pb.RpcObjectCreateResponse{ObjectId: "chatNew"}) + want := &v2model.ChatResult{Id: "chatNew", Name: "Project chat"} + + // when + got, err := fx.CreateChat(context.Background(), testSpaceId, v2model.CreateChatRequest{Name: "Project chat"}, false) + + // then + require.NoError(t, err) + assert.Equal(t, want, got) + }) + + t.Run("dry run commits nothing", func(t *testing.T) { + // given: no ObjectCreate expectation — a call would fail the test + fx := newV2Fixture(t) + + // when + got, err := fx.CreateChat(context.Background(), testSpaceId, v2model.CreateChatRequest{Name: "Project chat"}, true) + + // then + require.NoError(t, err) + assert.True(t, got.DryRun) + assert.Empty(t, got.Id) + }) + + t.Run("empty name is a 400", func(t *testing.T) { + fx := newV2Fixture(t) + _, err := fx.CreateChat(context.Background(), testSpaceId, v2model.CreateChatRequest{}, false) + requireV2Code(t, err, v2model.CodeValidationFailed) + }) +} + +func TestV2GetChatMessages(t *testing.T) { + t.Run("state and message_count pass through — the fields v1 dropped", func(t *testing.T) { + // given + fx := newV2Fixture(t) + fx.addChat(t, testChatId, "Team chat", 1000) + fx.addParticipant(t, testIdentity, "Alice") + fx.mwMock.EXPECT().ChatGetMessages(mock.Anything, mock.MatchedBy(func(req *pb.RpcChatGetMessagesRequest) bool { + // limit+1: the extra record detects has_more without guessing + return req.ChatObjectId == testChatId && req.AfterOrderId == "0090" && req.Limit == 26 + })).Return(&pb.RpcChatGetMessagesResponse{ + Messages: []*model.ChatMessage{chatProtoMessage()}, + ChatState: &model.ChatState{ + Messages: &model.ChatStateUnreadState{OldestOrderId: "00a1", Counter: 3}, + Mentions: &model.ChatStateUnreadState{OldestOrderId: "00a1", Counter: 1}, + LastStateId: "state42", + }, + MessageCount: 812, + }) + + // when + got, err := fx.GetChatMessages(context.Background(), testSpaceId, testChatId, ChatMessagesQuery{After: "0090", Limit: 25}) + + // then + require.NoError(t, err) + assert.Equal(t, 812, got.MessageCount, "message_count must pass through (the peek)") + require.NotNil(t, got.State, "chatState must pass through") + assert.Equal(t, 3, got.State.UnreadMessages) + assert.Equal(t, 1, got.State.UnreadMentions) + assert.Equal(t, "state42", got.State.LastStateId, + "last_state_id must reach the client — without it the mark-read race guard is unreachable") + + require.Len(t, got.Messages, 1) + msg := got.Messages[0] + assert.Equal(t, "can you **check** the doc?", msg.Text, "text is §8 markup") + assert.Equal(t, domain.NewParticipantId(testSpaceId, testIdentity), msg.AuthorId) + assert.Equal(t, "Alice", msg.Author, "author name enriched from the store participant") + assert.Equal(t, map[string]int{"👍": 2}, msg.Reactions, "counts by default (Q4)") + assert.Nil(t, msg.ReactedBy, "identity lists only under ?reactions=full") + assert.False(t, got.HasMore) + }) + + t.Run("reactions=full adds reacted_by participant ids — counts keep their slot (C2)", func(t *testing.T) { + // given + fx := newV2Fixture(t) + fx.addChat(t, testChatId, "Team chat", 1000) + fx.mwMock.EXPECT().ChatGetMessages(mock.Anything, mock.Anything). + Return(&pb.RpcChatGetMessagesResponse{Messages: []*model.ChatMessage{chatProtoMessage()}}) + + // when + got, err := fx.GetChatMessages(context.Background(), testSpaceId, testChatId, ChatMessagesQuery{Limit: 25, FullReactions: true}) + + // then + require.NoError(t, err) + require.Len(t, got.Messages, 1) + want := map[string][]string{"👍": { + domain.NewParticipantId(testSpaceId, testIdentity), + domain.NewParticipantId(testSpaceId, "identityB"), + }} + assert.Equal(t, want, got.Messages[0].ReactedBy) + assert.Equal(t, map[string]int{"👍": 2}, got.Messages[0].Reactions, + "reactions stays the counts map in full mode — one slot, one type") + }) + + t.Run("forward paging trims the newest extra and continues with next_after", func(t *testing.T) { + // given: ?after alone is the one ASC query — the RPC is asked for + // limit+1 and returns 3 ascending messages for limit=2 + fx := newV2Fixture(t) + fx.addChat(t, testChatId, "Team chat", 1000) + protos := make([]*model.ChatMessage, 0, 3) + for _, order := range []string{"00a1", "00a2", "00a3"} { + m := chatProtoMessage() + m.Id = "msg-" + order + m.OrderId = order + protos = append(protos, m) + } + fx.mwMock.EXPECT().ChatGetMessages(mock.Anything, mock.MatchedBy(func(req *pb.RpcChatGetMessagesRequest) bool { + return req.AfterOrderId == "0090" && req.Limit == 3 + })).Return(&pb.RpcChatGetMessagesResponse{Messages: protos}) + + // when + got, err := fx.GetChatMessages(context.Background(), testSpaceId, testChatId, ChatMessagesQuery{After: "0090", Limit: 2}) + + // then + require.NoError(t, err) + require.Len(t, got.Messages, 2) + assert.Equal(t, "00a1", got.Messages[0].Order) + assert.Equal(t, "00a2", got.Messages[1].Order, "the newest extra is trimmed on a forward walk") + assert.True(t, got.HasMore) + assert.Equal(t, "00a2", got.NextAfter, "the cursor advances past the last shown message") + assert.Empty(t, got.NextBefore) + }) + + t.Run("newest-anchored paging trims the oldest extra and continues with next_before", func(t *testing.T) { + // given: no ?after — the repository sorts DESC (newest N) and the + // service receives them ascending; the OLDEST message is the extra + fx := newV2Fixture(t) + fx.addChat(t, testChatId, "Team chat", 1000) + protos := make([]*model.ChatMessage, 0, 3) + for _, order := range []string{"00a1", "00a2", "00a3"} { + m := chatProtoMessage() + m.Id = "msg-" + order + m.OrderId = order + protos = append(protos, m) + } + fx.mwMock.EXPECT().ChatGetMessages(mock.Anything, mock.Anything). + Return(&pb.RpcChatGetMessagesResponse{Messages: protos}) + + // when + got, err := fx.GetChatMessages(context.Background(), testSpaceId, testChatId, ChatMessagesQuery{Limit: 2}) + + // then + require.NoError(t, err) + require.Len(t, got.Messages, 2) + assert.Equal(t, "00a2", got.Messages[0].Order, "the oldest extra is trimmed on a newest-anchored read") + assert.Equal(t, "00a3", got.Messages[1].Order) + assert.True(t, got.HasMore) + assert.Equal(t, "00a2", got.NextBefore, "older messages continue with ?before=") + assert.Empty(t, got.NextAfter) + }) + + t.Run("a non-chat target is a 400 naming the layout", func(t *testing.T) { + // given: no RPC expectation — the guard must fire before the RPC + fx := newV2Fixture(t) + fx.objectStore.AddObjects(t, testSpaceId, []objectstore.TestObject{{ + bundle.RelationKeyId: domain.String("page1"), + bundle.RelationKeyResolvedLayout: domain.Int64(int64(model.ObjectType_basic)), + }}) + + // when + _, err := fx.GetChatMessages(context.Background(), testSpaceId, "page1", ChatMessagesQuery{Limit: 25}) + + // then + requireV2Code(t, err, v2model.CodeValidationFailed) + assert.Contains(t, err.Error(), "not a chat") + }) + + t.Run("an unknown chat is a 404", func(t *testing.T) { + fx := newV2Fixture(t) + _, err := fx.GetChatMessages(context.Background(), testSpaceId, "nope", ChatMessagesQuery{Limit: 25}) + requireV2Code(t, err, v2model.CodeNotFound) + }) +} + +func TestV2AddChatMessage(t *testing.T) { + t.Run("text parses as §8 markup — the RPC receives text plus marks", func(t *testing.T) { + // given + fx := newV2Fixture(t) + fx.addChat(t, testChatId, "Team chat", 1000) + fx.mwMock.EXPECT().ChatAddMessage(mock.Anything, mock.MatchedBy(func(req *pb.RpcChatAddMessageRequest) bool { + msg := req.Message + return req.ChatObjectId == testChatId && + msg.Message.Text == "can you check the doc?" && + len(msg.Message.Marks) == 1 && + msg.Message.Marks[0].Type == model.BlockContentTextMark_Bold && + msg.Message.Marks[0].Range.From == 8 && msg.Message.Marks[0].Range.To == 13 && + msg.ReplyToMessageId == "msg0" + })).Return(&pb.RpcChatAddMessageResponse{MessageId: "msgNew"}) + + // when + got, err := fx.AddChatMessage(context.Background(), testSpaceId, testChatId, + v2model.AddChatMessageRequest{Text: "can you **check** the doc?", ReplyTo: "msg0"}, false) + + // then + require.NoError(t, err) + assert.Equal(t, "msgNew", got.Id) + }) + + t.Run("attachment kinds infer from the target layout", func(t *testing.T) { + // given + fx := newV2Fixture(t) + fx.addChat(t, testChatId, "Team chat", 1000) + fx.objectStore.AddObjects(t, testSpaceId, []objectstore.TestObject{ + { + bundle.RelationKeyId: domain.String("img1"), + bundle.RelationKeyResolvedLayout: domain.Int64(int64(model.ObjectType_image)), + }, + { + bundle.RelationKeyId: domain.String("pdf1"), + bundle.RelationKeyResolvedLayout: domain.Int64(int64(model.ObjectType_pdf)), + }, + { + bundle.RelationKeyId: domain.String("page1"), + bundle.RelationKeyResolvedLayout: domain.Int64(int64(model.ObjectType_basic)), + }, + }) + fx.mwMock.EXPECT().ChatAddMessage(mock.Anything, mock.MatchedBy(func(req *pb.RpcChatAddMessageRequest) bool { + atts := req.Message.Attachments + return len(atts) == 3 && + atts[0].Target == "img1" && atts[0].Type == model.ChatMessageAttachment_IMAGE && + atts[1].Target == "pdf1" && atts[1].Type == model.ChatMessageAttachment_FILE && + atts[2].Target == "page1" && atts[2].Type == model.ChatMessageAttachment_LINK + })).Return(&pb.RpcChatAddMessageResponse{MessageId: "msgNew"}) + + // when + _, err := fx.AddChatMessage(context.Background(), testSpaceId, testChatId, + v2model.AddChatMessageRequest{Text: "see these", Attachments: []string{"img1", "pdf1", "page1"}}, false) + + // then + require.NoError(t, err) + }) + + t.Run("an unknown attachment id is a path-addressed 400", func(t *testing.T) { + // given + fx := newV2Fixture(t) + fx.addChat(t, testChatId, "Team chat", 1000) + + // when + _, err := fx.AddChatMessage(context.Background(), testSpaceId, testChatId, + v2model.AddChatMessageRequest{Text: "see this", Attachments: []string{"missing1"}}, false) + + // then + requireV2Code(t, err, v2model.CodeValidationFailed) + var v2Err *v2model.Error + require.ErrorAs(t, err, &v2Err) + require.Len(t, v2Err.Issues, 1) + assert.Equal(t, "/attachments/0", v2Err.Issues[0].Path) + }) + + t.Run("dry run validates everything and sends nothing", func(t *testing.T) { + // given: no ChatAddMessage expectation — a send would fail the test + fx := newV2Fixture(t) + fx.addChat(t, testChatId, "Team chat", 1000) + + // when + got, err := fx.AddChatMessage(context.Background(), testSpaceId, testChatId, + v2model.AddChatMessageRequest{Text: "hello"}, true) + + // then + require.NoError(t, err) + assert.True(t, got.DryRun) + }) + + t.Run("empty text with no attachments is a 400", func(t *testing.T) { + fx := newV2Fixture(t) + fx.addChat(t, testChatId, "Team chat", 1000) + _, err := fx.AddChatMessage(context.Background(), testSpaceId, testChatId, v2model.AddChatMessageRequest{}, false) + requireV2Code(t, err, v2model.CodeValidationFailed) + }) + + t.Run("text over the UTF-16 cap is a path-addressed 400 BEFORE the RPC", func(t *testing.T) { + // given: no RPC expectation — the chat RPC enums carry no usable + // error code, so an over-long message reaching the middleware would + // come back as a retry-looping 500; the cap must fire in v2 + fx := newV2Fixture(t) + fx.addChat(t, testChatId, "Team chat", 1000) + + // when + _, err := fx.AddChatMessage(context.Background(), testSpaceId, testChatId, + v2model.AddChatMessageRequest{Text: strings.Repeat("a", chatmodel.MaxMessageLength+1)}, false) + + // then + requireV2Code(t, err, v2model.CodeValidationFailed) + var v2Err *v2model.Error + require.ErrorAs(t, err, &v2Err) + require.NotEmpty(t, v2Err.Issues) + assert.Equal(t, "/text", v2Err.Issues[0].Path) + }) + + t.Run("more than 32 attachments is a path-addressed 400 before any lookup", func(t *testing.T) { + // given: the cap the chatMessage schema advertises (maxItems 32) + // must be enforced, or the strict schema lies about the contract + fx := newV2Fixture(t) + fx.addChat(t, testChatId, "Team chat", 1000) + ids := make([]string, maxChatAttachments+1) + for i := range ids { + ids[i] = fmt.Sprintf("obj%d", i) + } + + // when + _, err := fx.AddChatMessage(context.Background(), testSpaceId, testChatId, + v2model.AddChatMessageRequest{Text: "see these", Attachments: ids}, false) + + // then + requireV2Code(t, err, v2model.CodeValidationFailed) + var v2Err *v2model.Error + require.ErrorAs(t, err, &v2Err) + require.NotEmpty(t, v2Err.Issues) + assert.Equal(t, "/attachments", v2Err.Issues[0].Path) + }) + + t.Run("an RPC validate failure maps to 400, not a retry-looping 500", func(t *testing.T) { + // given: the RPC answers UNKNOWN_ERROR (core mapErrorCode has no + // chat mappings) with the middleware's "validate: …" description — + // the description, not the dead code, must drive the status + fx := newV2Fixture(t) + fx.addChat(t, testChatId, "Team chat", 1000) + fx.mwMock.EXPECT().ChatAddMessage(mock.Anything, mock.Anything).Return(&pb.RpcChatAddMessageResponse{ + Error: &pb.RpcChatAddMessageResponseError{ + Code: pb.RpcChatAddMessageResponseError_UNKNOWN_ERROR, + Description: "validate: mark range out of bounds", + }, + }) + + // when + _, err := fx.AddChatMessage(context.Background(), testSpaceId, testChatId, + v2model.AddChatMessageRequest{Text: "hello"}, false) + + // then + requireV2Code(t, err, v2model.CodeValidationFailed) + assert.Contains(t, err.Error(), "rejected") + }) + + t.Run("editing another member's message is a 403 forbidden, not a 500", func(t *testing.T) { + // given: the description the EDIT path really produces — + // chathandler.go joins storestate.ErrValidation with + // ErrModifyForeignMessage and storeObject.EditMessage wraps the push + // as "push change: …". Built from the same producers so a rewording + // cannot leave this test green against a dead string (the original + // fed the DELETE wording into the EDIT path and passed against + // behavior that did not exist — surface review M2b). + fx := newV2Fixture(t) + fx.addChat(t, testChatId, "Team chat", 1000) + fx.mwMock.EXPECT().ChatGetMessagesByIds(mock.Anything, mock.Anything). + Return(&pb.RpcChatGetMessagesByIdsResponse{Messages: []*model.ChatMessage{chatProtoMessage()}}) + fx.mwMock.EXPECT().ChatEditMessageContent(mock.Anything, mock.Anything).Return(&pb.RpcChatEditMessageContentResponse{ + Error: &pb.RpcChatEditMessageContentResponseError{ + Code: pb.RpcChatEditMessageContentResponseError_UNKNOWN_ERROR, + Description: "push change: " + errors.Join(storestate.ErrValidation, chatobject.ErrModifyForeignMessage).Error(), + }, + }) + + // when + _, err := fx.EditChatMessage(context.Background(), testSpaceId, testChatId, "msg1", + v2model.EditChatMessageRequest{Text: "updated"}, false) + + // then + requireV2Code(t, err, v2model.CodeForbidden) + var v2Err *v2model.Error + require.ErrorAs(t, err, &v2Err) + assert.Equal(t, 403, v2Err.Status) + }) + + t.Run("deleting another member's message is a 403 forbidden, not a 500", func(t *testing.T) { + // given: the DELETE path's refusal (chathandler BeforeDelete), + // wrapped like the store wraps it + fx := newV2Fixture(t) + fx.addChat(t, testChatId, "Team chat", 1000) + fx.mwMock.EXPECT().ChatGetMessagesByIds(mock.Anything, mock.Anything). + Return(&pb.RpcChatGetMessagesByIdsResponse{Messages: []*model.ChatMessage{chatProtoMessage()}}) + fx.mwMock.EXPECT().ChatDeleteMessage(mock.Anything, mock.Anything).Return(&pb.RpcChatDeleteMessageResponse{ + Error: &pb.RpcChatDeleteMessageResponseError{ + Code: pb.RpcChatDeleteMessageResponseError_UNKNOWN_ERROR, + Description: "push change: " + chatobject.ErrDeleteForeignMessage.Error(), + }, + }) + + // when + _, err := fx.DeleteChatMessage(context.Background(), testSpaceId, testChatId, "msg1", false) + + // then + requireV2Code(t, err, v2model.CodeForbidden) + var v2Err *v2model.Error + require.ErrorAs(t, err, &v2Err) + assert.Equal(t, 403, v2Err.Status) + }) +} + +func TestV2EditChatMessage(t *testing.T) { + t.Run("text-only merge preserves attachments, style and blocks", func(t *testing.T) { + // given: the middleware edit replaces the whole content, so the + // service must carry the existing attachments through — dropping + // this read-merge would wipe them on every text edit + fx := newV2Fixture(t) + fx.addChat(t, testChatId, "Team chat", 1000) + existing := chatProtoMessage() + existing.Message.Style = model.BlockContentText_Quote + existing.Attachments = []*model.ChatMessageAttachment{{Target: "file1", Type: model.ChatMessageAttachment_IMAGE}} + fx.mwMock.EXPECT().ChatGetMessagesByIds(mock.Anything, &pb.RpcChatGetMessagesByIdsRequest{ + ChatObjectId: testChatId, MessageIds: []string{"msg1"}, + }).Return(&pb.RpcChatGetMessagesByIdsResponse{Messages: []*model.ChatMessage{existing}}) + fx.mwMock.EXPECT().ChatEditMessageContent(mock.Anything, mock.MatchedBy(func(req *pb.RpcChatEditMessageContentRequest) bool { + msg := req.EditedMessage + return req.MessageId == "msg1" && + msg.Message.Text == "updated text" && + msg.Message.Style == model.BlockContentText_Quote && + len(msg.Attachments) == 1 && msg.Attachments[0].Target == "file1" + })).Return(&pb.RpcChatEditMessageContentResponse{}) + + // when + got, err := fx.EditChatMessage(context.Background(), testSpaceId, testChatId, "msg1", + v2model.EditChatMessageRequest{Text: "updated text"}, false) + + // then + require.NoError(t, err) + assert.Equal(t, "msg1", got.Id) + }) + + t.Run("editing a missing message is a 404", func(t *testing.T) { + // given + fx := newV2Fixture(t) + fx.addChat(t, testChatId, "Team chat", 1000) + fx.mwMock.EXPECT().ChatGetMessagesByIds(mock.Anything, mock.Anything). + Return(&pb.RpcChatGetMessagesByIdsResponse{}) + + // when + _, err := fx.EditChatMessage(context.Background(), testSpaceId, testChatId, "nope", + v2model.EditChatMessageRequest{Text: "updated"}, false) + + // then + requireV2Code(t, err, v2model.CodeNotFound) + }) + + t.Run("dry run stops after the existence check", func(t *testing.T) { + // given: no ChatEditMessageContent expectation + fx := newV2Fixture(t) + fx.addChat(t, testChatId, "Team chat", 1000) + fx.mwMock.EXPECT().ChatGetMessagesByIds(mock.Anything, mock.Anything). + Return(&pb.RpcChatGetMessagesByIdsResponse{Messages: []*model.ChatMessage{chatProtoMessage()}}) + + // when + got, err := fx.EditChatMessage(context.Background(), testSpaceId, testChatId, "msg1", + v2model.EditChatMessageRequest{Text: "updated"}, true) + + // then + require.NoError(t, err) + assert.True(t, got.DryRun) + }) +} + +func TestV2DeleteChatMessage(t *testing.T) { + t.Run("delete passes through — and warns about the attachment file GC", func(t *testing.T) { + // given: deleting a message permanently deletes (skipBin) any + // attachment orphaned by it, asynchronously — the receipt must name + // the ids at risk instead of hiding the irreversible part + fx := newV2Fixture(t) + fx.addChat(t, testChatId, "Team chat", 1000) + msg := chatProtoMessage() + msg.Attachments = []*model.ChatMessageAttachment{{Target: "file1", Type: model.ChatMessageAttachment_IMAGE}} + fx.mwMock.EXPECT().ChatGetMessagesByIds(mock.Anything, mock.Anything). + Return(&pb.RpcChatGetMessagesByIdsResponse{Messages: []*model.ChatMessage{msg}}) + fx.mwMock.EXPECT().ChatDeleteMessage(mock.Anything, &pb.RpcChatDeleteMessageRequest{ + ChatObjectId: testChatId, MessageId: "msg1", + }).Return(&pb.RpcChatDeleteMessageResponse{}) + + // when + got, err := fx.DeleteChatMessage(context.Background(), testSpaceId, testChatId, "msg1", false) + + // then + require.NoError(t, err) + assert.Equal(t, "msg1", got.Id) + require.Len(t, got.Warnings, 1) + assert.Contains(t, got.Warnings[0].Message, "file1") + assert.Contains(t, got.Warnings[0].Message, "PERMANENTLY") + }) + + t.Run("the real delete 404s for a missing message exactly like its dry run (C9)", func(t *testing.T) { + // given: no ChatDeleteMessage expectation — the store handler + // treats deleting a missing document as success, so skipping the + // check would answer 200 for a deletion that never happened (and + // still push a junk delete change into the CRDT tree) + fx := newV2Fixture(t) + fx.addChat(t, testChatId, "Team chat", 1000) + fx.mwMock.EXPECT().ChatGetMessagesByIds(mock.Anything, mock.Anything). + Return(&pb.RpcChatGetMessagesByIdsResponse{}).Times(2) + + // when + _, errReal := fx.DeleteChatMessage(context.Background(), testSpaceId, testChatId, "nope", false) + _, errDry := fx.DeleteChatMessage(context.Background(), testSpaceId, testChatId, "nope", true) + + // then + requireV2Code(t, errReal, v2model.CodeNotFound) + requireV2Code(t, errDry, v2model.CodeNotFound) + }) + + t.Run("dry run reports the same file-GC warnings and deletes nothing", func(t *testing.T) { + // given: no ChatDeleteMessage expectation + fx := newV2Fixture(t) + fx.addChat(t, testChatId, "Team chat", 1000) + msg := chatProtoMessage() + msg.Attachments = []*model.ChatMessageAttachment{{Target: "file1", Type: model.ChatMessageAttachment_FILE}} + fx.mwMock.EXPECT().ChatGetMessagesByIds(mock.Anything, mock.Anything). + Return(&pb.RpcChatGetMessagesByIdsResponse{Messages: []*model.ChatMessage{msg}}) + + // when + got, err := fx.DeleteChatMessage(context.Background(), testSpaceId, testChatId, "msg1", true) + + // then + require.NoError(t, err) + assert.True(t, got.DryRun) + require.Len(t, got.Warnings, 1) + assert.Contains(t, got.Warnings[0].Message, "file1") + }) +} + +func TestV2ToggleChatReaction(t *testing.T) { + t.Run("toggle passes the outcome through", func(t *testing.T) { + // given: the real path reads the message first — the RPC surfaces a + // missing message as an opaque UNKNOWN_ERROR, the check makes it a 404 + fx := newV2Fixture(t) + fx.addChat(t, testChatId, "Team chat", 1000) + fx.mwMock.EXPECT().ChatGetMessagesByIds(mock.Anything, mock.Anything). + Return(&pb.RpcChatGetMessagesByIdsResponse{Messages: []*model.ChatMessage{chatProtoMessage()}}) + fx.mwMock.EXPECT().ChatToggleMessageReaction(mock.Anything, &pb.RpcChatToggleMessageReactionRequest{ + ChatObjectId: testChatId, MessageId: "msg1", Emoji: "👍", + }).Return(&pb.RpcChatToggleMessageReactionResponse{Added: true}) + + // when + got, err := fx.ToggleChatReaction(context.Background(), testSpaceId, testChatId, "msg1", + v2model.ChatReactionRequest{Emoji: "👍"}, false) + + // then + require.NoError(t, err) + require.NotNil(t, got.Added) + assert.True(t, *got.Added) + }) + + t.Run("a reaction on a missing message is a 404, not a 500", func(t *testing.T) { + // given: no toggle expectation — the RPC must never run + fx := newV2Fixture(t) + fx.addChat(t, testChatId, "Team chat", 1000) + fx.mwMock.EXPECT().ChatGetMessagesByIds(mock.Anything, mock.Anything). + Return(&pb.RpcChatGetMessagesByIdsResponse{}) + + // when + _, err := fx.ToggleChatReaction(context.Background(), testSpaceId, testChatId, "nope", + v2model.ChatReactionRequest{Emoji: "👍"}, false) + + // then + requireV2Code(t, err, v2model.CodeNotFound) + }) + + t.Run("dry run reports the would-be outcome without toggling", func(t *testing.T) { + // given: the fixture's account (testAccountId) does NOT carry 👍 on + // the message, so the would-be outcome is added=true; no toggle RPC + // expectation — a real toggle would fail the test + fx := newV2Fixture(t) + fx.addChat(t, testChatId, "Team chat", 1000) + msg := chatProtoMessage() + msg.Reactions = &model.ChatMessageReactions{Reactions: map[string]*model.ChatMessageReactionsIdentityList{ + "👍": {Ids: []string{"identityB"}}, + "🎉": {Ids: []string{testAccountId}}, + }} + fx.mwMock.EXPECT().ChatGetMessagesByIds(mock.Anything, mock.Anything). + Return(&pb.RpcChatGetMessagesByIdsResponse{Messages: []*model.ChatMessage{msg}}).Times(2) + + // when + addOutcome, err1 := fx.ToggleChatReaction(context.Background(), testSpaceId, testChatId, "msg1", + v2model.ChatReactionRequest{Emoji: "👍"}, true) + removeOutcome, err2 := fx.ToggleChatReaction(context.Background(), testSpaceId, testChatId, "msg1", + v2model.ChatReactionRequest{Emoji: "🎉"}, true) + + // then + require.NoError(t, err1) + require.NoError(t, err2) + require.NotNil(t, addOutcome.Added) + assert.True(t, *addOutcome.Added, "not reacted yet — the toggle would add") + assert.True(t, addOutcome.DryRun) + require.NotNil(t, removeOutcome.Added) + assert.False(t, *removeOutcome.Added, "already reacted — the toggle would remove") + }) + + t.Run("dry run without an account identity omits added and warns", func(t *testing.T) { + // given: Service documents accountId as possibly empty — with no + // identity NOTHING matches the stored reactions, so asserting + // added=true would be wrong whenever the caller already reacted + mwMock := mock_apicore.NewMockClientCommands(t) + store := objectstore.NewStoreFixture(t) + store.AddObjects(t, objectstore.TestTechSpaceId, []objectstore.TestObject{{ + bundle.RelationKeyId: domain.String("spaceView1"), + bundle.RelationKeyResolvedLayout: domain.Int64(int64(model.ObjectType_spaceView)), + bundle.RelationKeyTargetSpaceId: domain.String(testSpaceId), + }}) + store.AddObjects(t, testSpaceId, []objectstore.TestObject{{ + bundle.RelationKeyId: domain.String(testChatId), + bundle.RelationKeyResolvedLayout: domain.Int64(int64(model.ObjectType_chatDerived)), + }}) + svc := NewService(mwMock, nil, nil, nil, nil, store, objectstore.TestTechSpaceId, "" /* no accountId */) + msg := chatProtoMessage() + msg.Reactions = &model.ChatMessageReactions{Reactions: map[string]*model.ChatMessageReactionsIdentityList{ + "👍": {Ids: []string{"someoneElse"}}, + }} + mwMock.EXPECT().ChatGetMessagesByIds(mock.Anything, mock.Anything). + Return(&pb.RpcChatGetMessagesByIdsResponse{Messages: []*model.ChatMessage{msg}}) + + // when + got, err := svc.ToggleChatReaction(context.Background(), testSpaceId, testChatId, "msg1", + v2model.ChatReactionRequest{Emoji: "👍"}, true) + + // then + require.NoError(t, err) + assert.True(t, got.DryRun) + assert.Nil(t, got.Added, "no identity to predict with — added must be omitted, not asserted") + require.NotEmpty(t, got.Warnings) + assert.Contains(t, got.Warnings[0].Message, "could not be predicted") + }) + + t.Run("empty emoji is a 400", func(t *testing.T) { + fx := newV2Fixture(t) + fx.addChat(t, testChatId, "Team chat", 1000) + _, err := fx.ToggleChatReaction(context.Background(), testSpaceId, testChatId, "msg1", + v2model.ChatReactionRequest{}, false) + requireV2Code(t, err, v2model.CodeValidationFailed) + }) +} + +func TestV2ReadChat(t *testing.T) { + t.Run("messages scope forwards up_to AND last_state_id — the race guard v1 made unreachable", func(t *testing.T) { + // given + fx := newV2Fixture(t) + fx.addChat(t, testChatId, "Team chat", 1000) + fx.mwMock.EXPECT().ChatReadMessages(mock.Anything, &pb.RpcChatReadMessagesRequest{ + ChatObjectId: testChatId, + Type: pb.RpcChatReadMessages_Messages, + BeforeOrderId: "00a5", + LastStateId: "state42", + }).Return(&pb.RpcChatReadMessagesResponse{}) + + // when + got, err := fx.ReadChat(context.Background(), testSpaceId, testChatId, + v2model.ChatReadRequest{UpTo: "00a5", LastStateId: "state42"}, false) + + // then + require.NoError(t, err) + assert.False(t, got.DryRun) + }) + + t.Run("mentions scope maps to the mentions counter", func(t *testing.T) { + // given + fx := newV2Fixture(t) + fx.addChat(t, testChatId, "Team chat", 1000) + fx.mwMock.EXPECT().ChatReadMessages(mock.Anything, mock.MatchedBy(func(req *pb.RpcChatReadMessagesRequest) bool { + return req.Type == pb.RpcChatReadMessages_Mentions && req.BeforeOrderId == "00a5" && req.LastStateId == "state42" + })).Return(&pb.RpcChatReadMessagesResponse{}) + + // when + _, err := fx.ReadChat(context.Background(), testSpaceId, testChatId, + v2model.ChatReadRequest{UpTo: "00a5", LastStateId: "state42", Scope: "mentions"}, false) + + // then + require.NoError(t, err) + }) + + t.Run("up_to AND last_state_id are required — an empty bound OR guard silently marks nothing", func(t *testing.T) { + // given: no RPC expectation — the request must never reach the RPC. + // The range query ANDs `orderId <= up_to` with `stateId <= last_state_id` + // and every stored message carries a non-empty state id, so EITHER + // empty value is the same silent no-op (markedCount 0, HTTP 200) + fx := newV2Fixture(t) + fx.addChat(t, testChatId, "Team chat", 1000) + + // when: both missing + _, err := fx.ReadChat(context.Background(), testSpaceId, testChatId, v2model.ChatReadRequest{}, false) + + // then: both named, path-addressed + requireV2Code(t, err, v2model.CodeValidationFailed) + var v2Err *v2model.Error + require.ErrorAs(t, err, &v2Err) + require.Len(t, v2Err.Issues, 2) + assert.Equal(t, "/up_to", v2Err.Issues[0].Path) + assert.Equal(t, "/last_state_id", v2Err.Issues[1].Path) + }) + + t.Run("up_to alone is NOT enough — the omitted race guard is the v1 trap one field over", func(t *testing.T) { + // given: no RPC expectation. Forwarding LastStateId:"" would make + // MarkReadMessages mark ZERO messages and still answer 200 — the + // exact silent no-op requiring up_to was meant to close + fx := newV2Fixture(t) + fx.addChat(t, testChatId, "Team chat", 1000) + + // when + _, err := fx.ReadChat(context.Background(), testSpaceId, testChatId, + v2model.ChatReadRequest{UpTo: "00a5"}, false) + + // then + requireV2Code(t, err, v2model.CodeValidationFailed) + var v2Err *v2model.Error + require.ErrorAs(t, err, &v2Err) + require.Len(t, v2Err.Issues, 1) + assert.Equal(t, "/last_state_id", v2Err.Issues[0].Path) + assert.Contains(t, v2Err.Issues[0].Hint, "state.last_state_id") + }) + + t.Run("reactions scope marks all unread reactions", func(t *testing.T) { + // given + fx := newV2Fixture(t) + fx.addChat(t, testChatId, "Team chat", 1000) + fx.mwMock.EXPECT().ChatReadReactions(mock.Anything, &pb.RpcChatReadReactionsRequest{ + ChatObjectId: testChatId, + }).Return(&pb.RpcChatReadReactionsResponse{}) + + // when + _, err := fx.ReadChat(context.Background(), testSpaceId, testChatId, + v2model.ChatReadRequest{Scope: "reactions"}, false) + + // then + require.NoError(t, err) + }) + + t.Run("reactions scope rejects up_to — the backend takes no bound", func(t *testing.T) { + fx := newV2Fixture(t) + fx.addChat(t, testChatId, "Team chat", 1000) + _, err := fx.ReadChat(context.Background(), testSpaceId, testChatId, + v2model.ChatReadRequest{Scope: "reactions", UpTo: "00a5"}, false) + requireV2Code(t, err, v2model.CodeValidationFailed) + }) + + t.Run("unknown scope is a 400 naming the allowed values", func(t *testing.T) { + fx := newV2Fixture(t) + fx.addChat(t, testChatId, "Team chat", 1000) + _, err := fx.ReadChat(context.Background(), testSpaceId, testChatId, + v2model.ChatReadRequest{Scope: "everything", UpTo: "00a5"}, false) + requireV2Code(t, err, v2model.CodeValidationFailed) + assert.Contains(t, err.Error(), "scope") + }) + + t.Run("dry run validates and forwards nothing", func(t *testing.T) { + // given: no RPC expectations + fx := newV2Fixture(t) + fx.addChat(t, testChatId, "Team chat", 1000) + + // when + got, err := fx.ReadChat(context.Background(), testSpaceId, testChatId, + v2model.ChatReadRequest{UpTo: "00a5", LastStateId: "state42"}, true) + + // then + require.NoError(t, err) + assert.True(t, got.DryRun) + }) + + t.Run("the forwarded RPC request never carries an empty last_state_id", func(t *testing.T) { + // given: the regression pin for the silent no-op — whatever shape + // reaches the RPC must carry a non-empty guard + fx := newV2Fixture(t) + fx.addChat(t, testChatId, "Team chat", 1000) + fx.mwMock.EXPECT().ChatReadMessages(mock.Anything, mock.MatchedBy(func(req *pb.RpcChatReadMessagesRequest) bool { + return req.LastStateId != "" && req.BeforeOrderId != "" + })).Return(&pb.RpcChatReadMessagesResponse{}) + + // when + _, err := fx.ReadChat(context.Background(), testSpaceId, testChatId, + v2model.ChatReadRequest{UpTo: "00a5", LastStateId: "state42"}, false) + + // then + require.NoError(t, err) + }) +} diff --git a/core/api/v2/service/corpse_addressability_test.go b/core/api/v2/service/corpse_addressability_test.go new file mode 100644 index 0000000000..e0e79d0b20 --- /dev/null +++ b/core/api/v2/service/corpse_addressability_test.go @@ -0,0 +1,1433 @@ +package v2service + +// Corpse (uninstalled) property/type ADDRESSABILITY — what each surface does +// with an entity the UI deleted while its values still sit on objects. +// +// The store shape matters and the fixtures here model all THREE (§8.41): +// +// - "flag-only": {isUninstalled:true} — never persisted by a live index +// (isDeleted is a source:local relation re-derived on load), but it IS +// the shape of an export/snapshot of a corpse, and it exercises the +// explicit isUninstalled filters (the §7.5-req-2 corpse policy) in +// isolation. +// - "prod": {isUninstalled:true, isDeleted:true} — the steady state. +// delete.go's deleteDerivedObject sets isUninstalled and the same Apply +// injects isDeleted=true (smartblock/detailsinject.go, since GO-1978); +// after the next space load the row carries full details plus BOTH +// flags, and a device that received the delete by SYNC has this shape +// immediately (it never passes through a tombstone). Every plain store +// query injects `isDeleted != true` (database.go addDefaultFilters), so +// a prod corpse is hidden from queries even where nothing filters +// isUninstalled. +// - "tombstone": {id, spaceId, isDeleted} and NOTHING else — what +// BeforeDelete leaves in the index (spaceindex.DeleteObject) from the +// moment of the delete until the next space load, i.e. normally the +// rest of the app session on the deleting device. No relationKey, no +// resolvedLayout: every key-filtered query — including the corpse +// probes' own — misses it on its FIRST filter. Only the derived id +// (ADDRESSING §2.4: id = f(space, kind, key)) can find it, which is +// what the §8.41 probes do; the fixture stubs derive `drv-rel-` / +// `drv-ot-` (newV2FixtureBare) and the tombstone rows sit at +// those ids, exactly as production rows sit at the real derived ids. +// +// A flag-only fixture cannot catch behavior that depends on the injected +// isDeleted default (the §8.40 lesson), and a full-detail fixture of either +// kind cannot catch behavior that depends on a field EXISTING — the +// tombstone has none, which is how the first two §8.40 fixes were both dead +// for the rest of the session that follows every UI delete (§8.41). +// +// Fixture discipline: the corpse is BSON-keyed (24-hex) WITH a stored +// apiObjectKey slug — the one shape that can tell the two vocabularies +// apart. If any surface started emitting or resolving the corpse's slug, or +// resolving its BSON as a live address, these assertions flip; a readable or +// bundled corpse key could not detect either. + +import ( + "context" + "encoding/json" + "net/http" + "testing" + + "github.com/gogo/protobuf/types" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + + apicore "github.com/anyproto/anytype-heart/core/api/core" + v2model "github.com/anyproto/anytype-heart/core/api/v2/model" + "github.com/anyproto/anytype-heart/core/domain" + "github.com/anyproto/anytype-heart/pb" + "github.com/anyproto/anytype-heart/pkg/lib/bundle" + "github.com/anyproto/anytype-heart/pkg/lib/localstore/objectstore" + "github.com/anyproto/anytype-heart/pkg/lib/pb/model" + "github.com/anyproto/anytype-heart/util/pbtypes" +) + +const ( + // stored key and slug of the corpse property every test here shares + corpseBsonKey = "6a7663db61fab21cd4b9e201" + corpseSlug = "warranty_until" + // internal key and slug of the corpse type + corpseTypeBsonKey = "6a7663db61fab21cd4b9e301" + corpseTypeSlug = "old_meeting" +) + +// The corpse rows sit at their DERIVED ids — the fixture stub's derivation +// (newV2FixtureBare) mirrors production, where a derived object's id is a +// pure function of (space, kind, key). This is what lets the tombstone leg +// find the row the way the real probes do. +const ( + corpsePropertyId = "drv-rel-" + corpseBsonKey + corpseTypeId = "drv-ot-" + corpseTypeBsonKey +) + +// corpseShape is one of the three store shapes a corpse can have — see the +// file header. +type corpseShape int + +const ( + corpseFlagOnly corpseShape = iota + corpseProd + corpseTombstone +) + +// corpseShapes runs a subtest against every store shape a corpse can have. +func corpseShapes(t *testing.T, run func(t *testing.T, shape corpseShape)) { + t.Run("flag-only shape", func(t *testing.T) { run(t, corpseFlagOnly) }) + t.Run("prod shape", func(t *testing.T) { run(t, corpseProd) }) + t.Run("tombstone shape", func(t *testing.T) { run(t, corpseTombstone) }) +} + +// addTombstone registers the {id, spaceId, isDeleted} row DeleteObject +// leaves — deliberately NOT through addRelation/addType, which would inject +// a resolvedLayout the real tombstone does not have. +func (fx *v2Fixture) addTombstone(t *testing.T, id string) { + fx.objectStore.AddObjects(t, testSpaceId, []objectstore.TestObject{{ + bundle.RelationKeyId: domain.String(id), + bundle.RelationKeySpaceId: domain.String(testSpaceId), + bundle.RelationKeyIsDeleted: domain.Bool(true), + }}) +} + +// addCorpseProperty registers the BSON-keyed, slug-bearing corpse relation. +func (fx *v2Fixture) addCorpseProperty(t *testing.T, shape corpseShape) { + if shape == corpseTombstone { + fx.addTombstone(t, corpsePropertyId) + return + } + obj := objectstore.TestObject{ + bundle.RelationKeyId: domain.String(corpsePropertyId), + bundle.RelationKeyRelationKey: domain.String(corpseBsonKey), + bundle.RelationKeyApiObjectKey: domain.String(corpseSlug), + bundle.RelationKeyName: domain.String("Warranty until"), + bundle.RelationKeyIsUninstalled: domain.Bool(true), + } + if shape == corpseProd { + obj[bundle.RelationKeyIsDeleted] = domain.Bool(true) + } + fx.addRelation(t, testSpaceId, obj) +} + +func (fx *v2Fixture) addCorpseType(t *testing.T, shape corpseShape) { + if shape == corpseTombstone { + fx.addTombstone(t, corpseTypeId) + return + } + obj := objectstore.TestObject{ + bundle.RelationKeyId: domain.String(corpseTypeId), + bundle.RelationKeyUniqueKey: domain.String("ot-" + corpseTypeBsonKey), + bundle.RelationKeyApiObjectKey: domain.String(corpseTypeSlug), + bundle.RelationKeyName: domain.String("Old meeting"), + bundle.RelationKeyIsUninstalled: domain.Bool(true), + } + if shape == corpseProd { + obj[bundle.RelationKeyIsDeleted] = domain.Bool(true) + } + fx.addType(t, testSpaceId, obj) +} + +// corpseHeldRead is a live read of an object still carrying the corpse's +// value under the stored BSON key, typed by the corpse type. +func corpseHeldRead() apicore.ObjectRead { + return apicore.ObjectRead{ + SbType: model.SmartBlockType_Page, + Snapshot: &model.SmartBlockSnapshotBase{ + Details: &types.Struct{Fields: map[string]*types.Value{ + "id": pbtypes.String("obj1"), + "name": pbtypes.String("Doc"), + corpseBsonKey: pbtypes.String("2027-01-01"), + }}, + ObjectTypes: []string{"ot-" + corpseTypeBsonKey}, + Blocks: []*model.Block{ + {Id: "obj1", Content: &model.BlockContentOfSmartblock{Smartblock: &model.BlockContentSmartblock{}}}, + }, + }, + Heads: []string{"headA"}, + } +} + +// TestV2CorpseHeldValueReadsUnderStoredKey: GET serves a corpse-held value +// under the raw 24-hex stored key — never the corpse's stored slug, and the +// object's corpse TYPE spells its internal key too. The corpse vacated the +// slug namespace (§8-OQ2), so its slug may already label a NEW live entity; +// emitting it here would mislabel the value. This test is the read half the +// proposed read-emit/write-refuse split would change — if PropertySlug ever +// starts emitting a corpse's stored slug, the first assertion flips. +// Revert check: drop the isUninstalled filter in storeresolver's loadKeyMaps +// and the flag-only subtest serves "warranty_until" instead of the BSON. +func TestV2CorpseHeldValueReadsUnderStoredKey(t *testing.T) { + corpseShapes(t, func(t *testing.T, shape corpseShape) { + // given + fx := newV2Fixture(t) + fx.addCorpseProperty(t, shape) + fx.addCorpseType(t, shape) + fx.readerMock.EXPECT().ReadObject(mock.Anything, testSpaceId, "obj1").Return(corpseHeldRead(), nil) + + // when + body, _, err := fx.GetObject(context.Background(), testSpaceId, "obj1", ObjectQuery{}) + + // then + require.NoError(t, err) + doc := decodeBody(t, body) + props, _ := doc["properties"].(map[string]any) + assert.Equal(t, "2027-01-01", props[corpseBsonKey], "the value is served, under the stored key") + assert.NotContains(t, props, corpseSlug, "a corpse's slug is never a served spelling") + assert.Equal(t, corpseTypeBsonKey, doc["type"], "a corpse type spells its internal key in the envelope") + }) +} + +// TestV2CorpseNeverListsNorResolves: the §7.5-req-2 corpse policy on the +// request namespace, with the BSON+slug fixture the original corpsePolicy +// tests lack (their corpse keys are readable words with no stored slug, so +// they cannot see a slug leaking). Covers listings and route addressing by +// stored key, slug and name. +// Revert check: drop the isUninstalled filter in livePropertyFilters +// (keys.go) and every flag-only assertion here fails. +func TestV2CorpseNeverListsNorResolves(t *testing.T) { + corpseShapes(t, func(t *testing.T, shape corpseShape) { + // given + fx := newV2Fixture(t) + fx.addCorpseProperty(t, shape) + fx.addCorpseType(t, shape) + + // then: not listed + rows, total, _, err := fx.ListProperties(context.Background(), testSpaceId, 0, 25) + require.NoError(t, err) + assert.Empty(t, rows) + assert.Zero(t, total) + typeRows, _, _, err := fx.ListTypes(context.Background(), testSpaceId, 0, 25) + require.NoError(t, err) + assert.Empty(t, typeRows) + + // and: no spelling addresses it on routes + for _, input := range []string{corpseBsonKey, corpseSlug, "Warranty until"} { + _, err := fx.requireLiveProperty(testSpaceId, input) + requireNotFoundError(t, err) + } + for _, input := range []string{corpseTypeBsonKey, corpseTypeSlug} { + _, _, err := fx.GetType(context.Background(), testSpaceId, input, ObjectQuery{}) + requireNotFoundError(t, err) + } + }) +} + +// TestV2CorpseHeldValueIsUnqueryable: the addressability hole, executed on +// every query channel. The value an object still carries CANNOT be filtered, +// sorted or selected by ANY spelling — the slug vacated (policy) and the +// stored BSON key is refused by the same live-only validation. Together with +// TestV2CorpseHeldValueReadsUnderStoredKey this is the "data intact, +// unreachable through the vocabulary" state this file exists to make +// explicit: per-object reads show the value, the query surface cannot reach +// it. Round-trip testing cannot see this — bytes survive, addresses don't. +func TestV2CorpseHeldValueIsUnqueryable(t *testing.T) { + ctx := context.Background() + newFx := func(t *testing.T, shape corpseShape) *v2Fixture { + fx := newV2Fixture(t) + fx.addCorpseProperty(t, shape) + fx.objectStore.AddObjects(t, testSpaceId, []objectstore.TestObject{{ + bundle.RelationKeyId: domain.String("note1"), + bundle.RelationKeyName: domain.String("Standup"), + domain.RelationKey(corpseBsonKey): domain.String("2027-01-01"), + bundle.RelationKeyResolvedLayout: domain.Int64(int64(model.ObjectType_basic)), + bundle.RelationKeyLastModifiedDate: domain.Int64(1000), + }}) + return fx + } + requireBadRequest := func(t *testing.T, err error) { + t.Helper() + apiErr := v2Err(t, err) + assert.Equal(t, http.StatusBadRequest, apiErr.Status) + } + + corpseShapes(t, func(t *testing.T, shape corpseShape) { + t.Run("structured filter, both spellings", func(t *testing.T) { + fx := newFx(t, shape) + for _, key := range []string{corpseBsonKey, corpseSlug} { + _, _, _, _, err := fx.SearchObjects(ctx, testSpaceId, v2model.SearchRequest{ + Filters: json.RawMessage(`[{"property":"` + key + `","condition":"equal","value":"2027-01-01"}]`)}, 0, 25) + requireBadRequest(t, err) + } + }) + t.Run("compact filter string", func(t *testing.T) { + fx := newFx(t, shape) + _, _, _, _, err := fx.SearchObjects(ctx, testSpaceId, v2model.SearchRequest{ + Filter: corpseBsonKey + ` = "2027-01-01"`}, 0, 25) + requireBadRequest(t, err) + }) + t.Run("sorts", func(t *testing.T) { + fx := newFx(t, shape) + _, _, _, _, err := fx.SearchObjects(ctx, testSpaceId, v2model.SearchRequest{ + Sorts: json.RawMessage(`[{"property":"` + corpseBsonKey + `","direction":"asc"}]`)}, 0, 25) + requireBadRequest(t, err) + }) + t.Run("fields selection", func(t *testing.T) { + fx := newFx(t, shape) + _, _, _, _, err := fx.SearchObjects(ctx, testSpaceId, v2model.SearchRequest{ + Fields: []string{corpseBsonKey}}, 0, 25) + requireBadRequest(t, err) + }) + }) +} + +// TestV2CloneToleranceSurvivesTheProdShape (was +// TestV2CloneToleranceProdCorpseGap, which pinned the gap this fixes). +// +// The advertised loop "a pasted read body creates a copy" is protected by +// propertyKeyHeldByAnyRelation (keys.go) → relationObjectHoldingKey, whose +// query used to suppress only the injected isArchived default. A REAL +// uninstalled corpse also carries isDeleted=true (see the file header), so +// against the prod shape the tolerance found nothing and the create 400'd on +// a document the API itself served — the archived case worked, the +// uninstalled case, which is the common one, did not. §8.40 fixed that with +// the explicit no-op `isDeleted Condition None` clause; §8.41 then found the +// SAME loop dead again for the whole tombstone window — the query keys on +// relationKey, a field the tombstone does not have — and added the +// derived-id fallback. All three shapes now round-trip. +// +// How this fixture can fail: the corpse is BSON-keyed WITH a stored slug, so +// dropping either no-op clause fails the prod leg; dropping the derived-id +// fallback fails the tombstone leg alone; and any resolution of the corpse's +// SLUG instead of its stored key fails the second subtest. +// Revert checks (executed): removing the `isDeleted Condition None` filter +// from relationObjectHoldingKey fails the prod-shape leg — the create 400s +// with "unknown property keys"; removing the derivedRelationRow fallback +// fails the tombstone leg the same way while both full-detail legs stay +// green. +func TestV2CloneToleranceSurvivesTheProdShape(t *testing.T) { + cloneBody := []byte(`{"version":1,"type":"page","properties":{"name":"Fresh","` + corpseBsonKey + `":"x"}}`) + + t.Run("the clone loop round-trips on EVERY shape", func(t *testing.T) { + corpseShapes(t, func(t *testing.T, shape corpseShape) { + // given + fx := newV2Fixture(t) + fx.addCorpseProperty(t, shape) + captured := fx.expectCreate("clone1") + fx.expectEtagRead("clone1") + + // when — the bytes a GET of a corpse-held object serves + _, err := fx.CreateObject(context.Background(), testSpaceId, cloneBody, false, true) + + // then — the value lands under the STORED key it was served under + require.NoError(t, err) + require.NotNil(t, *captured) + assert.Equal(t, "x", (*captured).Details.Fields[corpseBsonKey].GetStringValue()) + }) + }) + + t.Run("the corpse SLUG is refused on create in both shapes", func(t *testing.T) { + // the tolerance is a round-trip escape for the STORED key only; the + // slug vacated the namespace and must not be an address + corpseShapes(t, func(t *testing.T, shape corpseShape) { + fx := newV2Fixture(t) + fx.addCorpseProperty(t, shape) + + _, err := fx.CreateObject(context.Background(), testSpaceId, + []byte(`{"version":1,"type":"page","properties":{"name":"Fresh","`+corpseSlug+`":"x"}}`), false, true) + + apiErr := v2Err(t, err) + assert.Equal(t, http.StatusBadRequest, apiErr.Status) + }) + }) +} + +// TestV2PatchCorpseKeyChannels: PATCH's only corpse escape is the document +// itself, and it survives the prod shape — checkKey (stateops.go) passes any +// key already on the document without consulting the store, so a value the +// object legitimately carries stays editable and removable by its stored +// key. Everything else refuses: the same key off-document, and the corpse's +// slug in every case (the slug is severed by uninstall even for in-document +// values — canonicalization no longer maps it to the stored key). +// Revert check: dropping the in-document escape (`inDoc` in checkKey) fails +// the first subtest (executed). The unset subtest pins the cleanup channel: +// it fails only under a strictly live-only unset (checkKey WITHOUT the +// in-document escape) — merely routing unset through today's checkKey keeps +// it green, because the escape covers it. +func TestV2PatchCorpseKeyChannels(t *testing.T) { + ctx := context.Background() + corpseDoc := `{"version":1,"id":"obj1","type":"page","properties":{"name":"Doc","` + corpseBsonKey + `":"2027-01-01"},"blocks":[{"id":"blockOne1","type":"paragraph","text":"hi"}]}` + cleanDocNoCorpse := `{"version":1,"id":"obj1","type":"page","properties":{"name":"Doc"},"blocks":[{"id":"blockOne1","type":"paragraph","text":"hi"}]}` + + t.Run("set by stored key, value on the document: applies (prod shape)", func(t *testing.T) { + // given + fx := newV2Fixture(t) + fx.addCorpseProperty(t, corpseProd) + captured := fx.expectMutate(editRead(t, corpseDoc), "headB") + + // when + _, err := fx.PatchObject(ctx, testSpaceId, "obj1", + patchBody(`{"op":"set_properties","set":{"`+corpseBsonKey+`":"2030-12-31"}}`), "", false, true) + + // then + require.NoError(t, err) + assert.Equal(t, "2030-12-31", (*captured).CombinedDetails().GetString(domain.RelationKey(corpseBsonKey))) + }) + + t.Run("set by stored key, value NOT on the document: 400", func(t *testing.T) { + fx := newV2Fixture(t) + fx.addCorpseProperty(t, corpseProd) + fx.expectMutate(editRead(t, cleanDocNoCorpse), "headB") + + _, err := fx.PatchObject(ctx, testSpaceId, "obj1", + patchBody(`{"op":"set_properties","set":{"`+corpseBsonKey+`":"2030-12-31"}}`), "", false, true) + + apiErr := v2Err(t, err) + assert.Equal(t, http.StatusBadRequest, apiErr.Status) + }) + + t.Run("unset by stored key removes the corpse-held value", func(t *testing.T) { + // the one cleanup channel a caller has left + fx := newV2Fixture(t) + fx.addCorpseProperty(t, corpseProd) + captured := fx.expectMutate(editRead(t, corpseDoc), "headB") + + _, err := fx.PatchObject(ctx, testSpaceId, "obj1", + patchBody(`{"op":"set_properties","unset":["`+corpseBsonKey+`"]}`), "", false, true) + + require.NoError(t, err) + _, present := (*captured).CombinedDetails().TryString(domain.RelationKey(corpseBsonKey)) + assert.False(t, present, "unset removes the value") + }) + + t.Run("the corpse slug is refused even with the value on the document", func(t *testing.T) { + fx := newV2Fixture(t) + fx.addCorpseProperty(t, corpseProd) + fx.expectMutate(editRead(t, corpseDoc), "headB") + + _, err := fx.PatchObject(ctx, testSpaceId, "obj1", + patchBody(`{"op":"set_properties","set":{"`+corpseSlug+`":"2030-12-31"}}`), "", false, true) + + apiErr := v2Err(t, err) + assert.Equal(t, http.StatusBadRequest, apiErr.Status) + }) +} + +// TestV2ViewOpsCorpseKeys: update_view applies the same two-tier rule — a +// corpse key already ON the dataview (a column, filter or sort the surface +// already shows) stays editable and referencable (§8.17: an edit must not +// reject what the surface already shows), while introducing that key to a +// view that does not carry it is refused like any unknown key. +// Revert check: dropping the preKnown escape in validateViewKeys fails the +// in-view subtests. +func TestV2ViewOpsCorpseKeys(t *testing.T) { + ctx := context.Background() + corpseViewDocBody := `{"version":1,"id":"obj1","type":"set","properties":{"name":"Bugs","setOf":["ot-bug"]},"blocks":[` + + `{"id":"dataview","type":"dataview",` + + `"properties":[{"property":"name","format":"text"},{"property":"` + corpseBsonKey + `","format":"date"}],` + + `"views":[{"id":"viewAll1","name":"All",` + + `"filters":[{"property":"` + corpseBsonKey + `","condition":"equal","value":"x"}],` + + `"columns":[{"property":"name"},{"property":"` + corpseBsonKey + `","width":100}]}]}]}` + plainViewDocBody := `{"version":1,"id":"obj1","type":"set","properties":{"name":"Bugs","setOf":["ot-bug"]},"blocks":[` + + `{"id":"dataview","type":"dataview",` + + `"properties":[{"property":"name","format":"text"}],` + + `"views":[{"id":"viewAll1","name":"All","columns":[{"property":"name"}]}]}]}` + + t.Run("a view already showing the corpse key stays editable", func(t *testing.T) { + fx := newV2Fixture(t) + fx.addCorpseProperty(t, corpseProd) + captured := fx.expectMutate(editRead(t, corpseViewDocBody), "headB") + + _, err := fx.PatchObject(ctx, testSpaceId, "obj1", + patchBody(`{"op":"update_view","view":"viewAll1","set":{"name":"Renamed"}}`), "", false, true) + + require.NoError(t, err) + require.NotNil(t, *captured) + }) + + t.Run("groupBy an in-view corpse key is accepted", func(t *testing.T) { + fx := newV2Fixture(t) + fx.addCorpseProperty(t, corpseProd) + fx.expectMutate(editRead(t, corpseViewDocBody), "headB") + + _, err := fx.PatchObject(ctx, testSpaceId, "obj1", + patchBody(`{"op":"update_view","view":"viewAll1","set":{"group_by":"`+corpseBsonKey+`"}}`), "", false, true) + + require.NoError(t, err) + }) + + t.Run("introducing the corpse key to a view without it is refused", func(t *testing.T) { + fx := newV2Fixture(t) + fx.addCorpseProperty(t, corpseProd) + for _, op := range []string{ + `{"op":"update_view","view":"viewAll1","set":{"group_by":"` + corpseBsonKey + `"}}`, + `{"op":"update_view","view":"viewAll1","columns":{"` + corpseBsonKey + `":{"width":80}}}`, + } { + fx.expectMutate(editRead(t, plainViewDocBody), "headB") + _, err := fx.PatchObject(ctx, testSpaceId, "obj1", patchBody(op), "", false, true) + apiErr := v2Err(t, err) + assert.Equal(t, http.StatusBadRequest, apiErr.Status, "op %s", op) + } + }) +} + +// TestV2CorpseSquatterVacatesBundledSlug: a corpse whose stored slug equals +// a bundled DERIVED slug does not shadow the bundled property — the corpse +// vacated the namespace, so `due_date` resolves cleanly to bundled dueDate +// with no ambiguity. (A LIVE squatter is the loud §7.5a-6 shadow, +// TestV2ShadowedBundledSlugIsLoud.) +// Revert check: dropping the isUninstalled filter in livePropertyFilters +// turns this resolution ambiguous and the test fails. +func TestV2CorpseSquatterVacatesBundledSlug(t *testing.T) { + // given: an UNINSTALLED squatter of due_date + fx := newV2Fixture(t) + fx.addRelation(t, testSpaceId, objectstore.TestObject{ + bundle.RelationKeyId: domain.String("rel-squatter"), + bundle.RelationKeyRelationKey: domain.String(corpseBsonKey), + bundle.RelationKeyApiObjectKey: domain.String("due_date"), + bundle.RelationKeyName: domain.String("Due Date"), + bundle.RelationKeyIsUninstalled: domain.Bool(true), + }) + entries, err := fx.liveProperties(testSpaceId) + require.NoError(t, err) + + // when + entry, ok, ambiguous := fx.resolvePropertyInput("due_date", entries) + + // then + require.True(t, ok) + assert.Empty(t, ambiguous) + assert.Equal(t, "dueDate", entry.Key) +} + +// removedBundledPropertyId is where the removed-dueDate fixture rows live — +// the derived id (`drv-rel-` is the fixture stub's derivation function), so +// the tombstone leg exercises the same lookup production does. +const removedBundledPropertyId = "drv-rel-dueDate" + +// addRemovedBundledProperty registers a REMOVED bundled relation (dueDate) +// in the given store shape; removal flavor for the full-detail shapes is +// isUninstalled (the UI delete). See addArchivedBundledProperty for the v2 +// DELETE flavor. +func (fx *v2Fixture) addRemovedBundledProperty(t *testing.T, shape corpseShape) { + if shape == corpseTombstone { + fx.addTombstone(t, removedBundledPropertyId) + return + } + obj := objectstore.TestObject{ + bundle.RelationKeyId: domain.String(removedBundledPropertyId), + bundle.RelationKeyRelationKey: domain.String("dueDate"), + bundle.RelationKeyName: domain.String("Due date"), + bundle.RelationKeyIsUninstalled: domain.Bool(true), + } + if shape == corpseProd { + obj[bundle.RelationKeyIsDeleted] = domain.Bool(true) + } + fx.addRelation(t, testSpaceId, obj) +} + +// requireRemovalRefusal asserts a 400 that names the removal AND its repair +// (§8.34: a refusal a caller cannot act on is itself a defect). slug is the +// served spelling the message must carry. +func requireRemovalRefusal(t *testing.T, err error, slug string) { + t.Helper() + apiErr := v2Err(t, err) + assert.Equal(t, http.StatusBadRequest, apiErr.Status) + require.NotEmpty(t, apiErr.Issues) + assert.Contains(t, apiErr.Issues[0].Message, `"`+slug+`"`, "the refusal spells the served slug") + assert.Contains(t, apiErr.Issues[0].Message, "removed from this space") + assert.NotEmpty(t, apiErr.Issues[0].Hint, "the repair is named") + assert.NotContains(t, apiErr.Issues[0].Hint, "restore it in the app", + "the repair must be actionable for a headless caller (§8.41)") +} + +// TestV2UninstalledBundledPropertyRefusesWrites (was +// TestV2UninstalledBundledPropertyWriteAsymmetry, which pinned the +// half-applied policy this fixes). +// +// For a BUNDLED relation the corpse policy used to half-apply: uninstalling +// dueDate removed it from listings and 404'd its routes, but the bundled +// vocabulary (resolution chain step 3, propertyKeyExistsIn's +// bundle.HasRelation arm) kept `due_date` a valid DOCUMENT key in every +// space — so a create landed new data in the property the user deleted, and +// the reinstall would light it back up. Now every write channel consults the +// bundled-removal probes and refuses with a repair. +// +// The distinction that makes this safe is NEVER-INSTALLED vs REMOVED: a +// bundled relation nobody installed has no relation object at all — not even +// a tombstone — is invisible to every removal probe, and keeps working +// exactly as before (it is the common case in a fresh space, and conflating +// the two would break ordinary writes everywhere). +// +// Fixture notes: the removed entity here is bundled, so it CANNOT be +// BSON-keyed with a stored slug — its key is `dueDate` by definition and its +// slug `due_date` is derived in code, never stored; that is precisely the +// class this refusal is about. dueDate is also deliberately a key whose slug +// DIFFERS from it — TestV2RemovedBundledSlugEqualsKeyClass covers the 41 +// bundled relations where slug == key, the class that testing only dueDate +// hid for a full review round (§8.41-2). The BSON-keyed corpse with a stored +// slug rides along in the last subtest, which proves the refusal targets the +// bundled class only and leaves the §8.29 tolerance intact. +// +// All THREE store shapes run: flag-only would pass even with the isDeleted +// default unhandled (only the prod leg proves the removal set suppresses +// it), and both full-detail shapes would pass even if the probes keyed on +// fields a tombstone lacks (only the tombstone leg proves the derived-id +// fallback, §8.41-1). +// Revert checks (executed): dropping the removedPropertyIssue arm from +// validatePropertyKeys fails the create subtest; dropping it from stateops' +// checkKey fails the PATCH subtest; dropping the derived-id fallback in +// bundledPropertyRemoved fails BOTH tombstone legs while every full-detail +// leg stays green. +func TestV2UninstalledBundledPropertyRefusesWrites(t *testing.T) { + ctx := context.Background() + newFx := func(t *testing.T, shape corpseShape) *v2Fixture { + fx := newV2Fixture(t) + fx.addRemovedBundledProperty(t, shape) + return fx + } + + t.Run("the route side is corpse-aware: 404", func(t *testing.T) { + corpseShapes(t, func(t *testing.T, shape corpseShape) { + fx := newFx(t, shape) + _, err := fx.requireLiveProperty(testSpaceId, "due_date") + requireNotFoundError(t, err) + }) + }) + + t.Run("create refuses to land a value on it", func(t *testing.T) { + corpseShapes(t, func(t *testing.T, shape corpseShape) { + fx := newFx(t, shape) + + _, err := fx.CreateObject(ctx, testSpaceId, + []byte(`{"version":1,"type":"page","properties":{"name":"n","due_date":"2027-01-01"}}`), false, true) + + requireRemovalRefusal(t, err, "due_date") + // §8.41-10 coherence: the envelope names what happened (the key + // is KNOWN and removed — "unknown" was a lie the issue text then + // contradicted), and the issue path spells the key as the CALLER + // sent it, not as canonicalization rewrote it (/properties/dueDate + // for a request that said due_date was unactionable) + apiErr := v2Err(t, err) + assert.Contains(t, apiErr.Message, "removed property keys") + assert.NotContains(t, apiErr.Message, "unknown") + assert.Equal(t, "/properties/due_date", apiErr.Issues[0].Path) + }) + }) + + t.Run("PATCH refuses it off-document, keeps the in-document cleanup", func(t *testing.T) { + corpseShapes(t, func(t *testing.T, shape corpseShape) { + cleanDoc := `{"version":1,"id":"obj1","type":"page","properties":{"name":"Doc"},"blocks":[{"id":"blockOne1","type":"paragraph","text":"hi"}]}` + holdingDoc := `{"version":1,"id":"obj1","type":"page","properties":{"name":"Doc","dueDate":"2027-01-01"},"blocks":[{"id":"blockOne1","type":"paragraph","text":"hi"}]}` + + t.Run("off-document set: refused", func(t *testing.T) { + fx := newFx(t, shape) + fx.expectMutate(editRead(t, cleanDoc), "headB") + + _, err := fx.PatchObject(ctx, testSpaceId, "obj1", + patchBody(`{"op":"set_properties","set":{"due_date":"2030-12-31"}}`), "", false, true) + + requireRemovalRefusal(t, err, "due_date") + }) + + t.Run("unset of a value the document carries: still the cleanup channel", func(t *testing.T) { + fx := newFx(t, shape) + captured := fx.expectMutate(editRead(t, holdingDoc), "headB") + + _, err := fx.PatchObject(ctx, testSpaceId, "obj1", + patchBody(`{"op":"set_properties","unset":["due_date"]}`), "", false, true) + + require.NoError(t, err) + _, present := (*captured).CombinedDetails().TryString(bundle.RelationKeyDueDate) + assert.False(t, present, "removing a removed property's leftover value must stay possible") + }) + }) + }) + + t.Run("a view cannot gain a column for it either", func(t *testing.T) { + // §8.40 claimed this channel needed no removal check because "the + // bundled slug stops resolving" — true ONLY for the slug≠key class + // this fixture happens to be in; the slug==key class sailed through + // (§8.41-2, see TestV2RemovedBundledSlugEqualsKeyClass). The channel + // now runs the explicit removal gate, and the refusal says REMOVED + // with the repair, not "unknown key" with a did-you-mean. + corpseShapes(t, func(t *testing.T, shape corpseShape) { + fx := newFx(t, shape) + plainViewDoc := `{"version":1,"id":"obj1","type":"set","properties":{"name":"Bugs","setOf":["ot-bug"]},"blocks":[` + + `{"id":"dataview","type":"dataview","properties":[{"property":"name","format":"text"}],` + + `"views":[{"id":"viewAll1","name":"All","columns":[{"property":"name"}]}]}]}` + fx.expectMutate(editRead(t, plainViewDoc), "headB") + + _, err := fx.PatchObject(ctx, testSpaceId, "obj1", + patchBody(`{"op":"update_view","view":"viewAll1","columns":{"due_date":{"width":80}}}`), "", false, true) + + requireRemovalRefusal(t, err, "due_date") + }) + }) + + t.Run("a NEVER-installed bundled property still works", func(t *testing.T) { + // the whole distinction: no relation object exists for dueDate here — + // not even a tombstone at the derived id — so install-on-write is + // untouched, the common case in a fresh space. Both slug classes: a + // slug≠key relation (dueDate) and a slug==key one (description), + // because the removal probes consult per-class code paths (§8.41-2). + fx := newV2Fixture(t) + captured := fx.expectCreate("obj-due") + fx.expectEtagRead("obj-due") + + _, err := fx.CreateObject(ctx, testSpaceId, + []byte(`{"version":1,"type":"page","properties":{"name":"n","due_date":"2027-01-01","description":"d"}}`), false, true) + + require.NoError(t, err) + require.NotNil(t, *captured) + _, landed := (*captured).Details.Fields["dueDate"] + assert.True(t, landed, "a bundled property nobody removed installs on write") + _, landedSame := (*captured).Details.Fields["description"] + assert.True(t, landedSame, "the slug==key class installs on write too") + }) + + t.Run("an ARCHIVED bundled property refuses writes the same way", func(t *testing.T) { + // §8.40 pinned archived-accepts as an open question so that widening + // the removal set "cannot pass silently". §8.41 answers it: the API's + // OWN delete verb (DELETE /properties → ObjectSetIsArchived) creates + // this state, and a property whose route 404s must not keep accepting + // writes — the incoherence the whole corpse round exists to remove. + // This is the conscious edit that flips the old pin. + fx := newV2Fixture(t) + fx.addRelation(t, testSpaceId, objectstore.TestObject{ + bundle.RelationKeyId: domain.String(removedBundledPropertyId), + bundle.RelationKeyRelationKey: domain.String("dueDate"), + bundle.RelationKeyName: domain.String("Due date"), + bundle.RelationKeyIsArchived: domain.Bool(true), + }) + + _, err := fx.CreateObject(ctx, testSpaceId, + []byte(`{"version":1,"type":"page","properties":{"name":"n","due_date":"2027-01-01"}}`), false, true) + + requireRemovalRefusal(t, err, "due_date") + }) + + t.Run("the BSON-keyed custom corpse stays tolerated", func(t *testing.T) { + // the refusal is bundled-only: a custom corpse's stored key can never + // be reinstalled, so §8.29's clone tolerance still carries its value + corpseShapes(t, func(t *testing.T, shape corpseShape) { + fx := newFx(t, shape) + fx.addCorpseProperty(t, shape) + captured := fx.expectCreate("obj-both") + fx.expectEtagRead("obj-both") + + _, err := fx.CreateObject(ctx, testSpaceId, + []byte(`{"version":1,"type":"page","properties":{"name":"n","`+corpseBsonKey+`":"x"}}`), false, true) + + require.NoError(t, err) + assert.Equal(t, "x", (*captured).Details.Fields[corpseBsonKey].GetStringValue()) + }) + }) +} + +// TestV2CorpseSlugReAimsAfterRecreate: the executed consequence of the +// vacate lean (§8-OQ2) that round-trip testing cannot see. Property P held +// slug S and was uninstalled; a NEW property Q minted S (the namespace was +// free — delete-then-recreate is the (a) strategy's headline win). A +// document exported while P was LIVE spells S; sent back now, S +// canonicalizes onto Q's stored key: same bytes, different property. This is +// inherent to vacate-and-remint and pinned here as CURRENT, DOCUMENTED +// behavior — and it is the strongest argument against the proposed +// read-emit half: if reads emitted a corpse's slug, every post-uninstall +// export would join this re-aim class instead of pinning the stored key. +// Revert check: dropping the isUninstalled filter in livePropertyFilters +// makes S ambiguous (corpse + Q) on the flag-only leg and the +// canonicalization errors instead (the prod leg stays green there — the +// store's injected isDeleted default excludes prod corpses on its own, which +// is exactly why both shapes are run). +func TestV2CorpseSlugReAimsAfterRecreate(t *testing.T) { + corpseShapes(t, func(t *testing.T, shape corpseShape) { + // given + fx := newV2Fixture(t) + fx.addCorpseProperty(t, shape) + fx.addRelation(t, testSpaceId, objectstore.TestObject{ + bundle.RelationKeyId: domain.String("rel-recreated"), + bundle.RelationKeyRelationKey: domain.String("6a7663db61fab21cd4b9e202"), + bundle.RelationKeyApiObjectKey: domain.String(corpseSlug), + bundle.RelationKeyName: domain.String("Warranty until"), + }) + + // when — a document exported before the uninstall, naming the slug + body, _, err := fx.canonicalizeDocumentKeys(testSpaceId, + []byte(`{"version":1,"type":"page","properties":{"`+corpseSlug+`":"x"}}`)) + + // then — the value now binds the RECREATED property's stored key + require.NoError(t, err) + var doc struct { + Properties map[string]json.RawMessage `json:"properties"` + } + require.NoError(t, json.Unmarshal(body, &doc)) + assert.Contains(t, doc.Properties, "6a7663db61fab21cd4b9e202") + assert.NotContains(t, doc.Properties, corpseSlug) + }) +} + +// TestV2TypePropertiesCorpseEchoResolvesToItsHolder (was +// TestV2TypePropertiesCorpseEchoMintsDuplicate, which pinned the gap). +// +// GET /types/{key} serves typeProperties resolved BY ID (storeresolver's +// GetRelationById falls back to an unfiltered point lookup), so a corpse in +// recommendedRelations is served under its stored BSON key with its name — +// see the first subtest, and that read is DELIBERATELY unchanged: the type +// document mirrors the list the type actually stores, so dropping corpse +// entries would make the documented read-modify-write loop silently DELETE +// the type's reference to them (typeProperties is a whole-list replace). +// +// PATCHing that served list back used to walk creatingResolvers.PropertyId, +// which excludes corpses by design, and mint a brand-new property +// duplicating the corpse's name under a snake-cased-hex slug — once per +// PATCH, forever. Now PropertyId consults relationObjectHoldingKey after the +// live chain misses: a stored key held by a relation object resolves to that +// relation and never mints. The round trip is an identity. +// +// How this fixture can fail: the corpse is BSON-keyed with a stored slug, so +// a resolver that started answering the SLUG here (the vacated namespace) +// would keep the mint assertion green but change the resolved id, which the +// recommendedRelations assertion catches; a readable corpse key could not +// tell a mint from a resolve at all. The TOMBSTONE legs are the §8.41 +// additions: the GET leg fails if the read stops recovering the entry from +// the surviving tree (it silently vanishes and the loop deletes the +// reference), and the PATCH leg fails if the holder probe loses its +// derived-id fallback (the 6a7663… duplicate mints again — in that window +// only, which is why no two-shape fixture ever saw it). +// Revert checks (executed): removing the relationObjectHoldingKey arm from +// PropertyId fails the PATCH subtest on every shape — a mint reappears; +// removing only the derived-id fallback inside relationObjectHoldingKey +// fails the PATCH subtest's tombstone leg alone; removing the +// seedTombstonedTypeProperties call in GetObject fails the GET subtest's +// tombstone leg alone. +func TestV2TypePropertiesCorpseEchoResolvesToItsHolder(t *testing.T) { + newFx := func(t *testing.T, shape corpseShape) *v2Fixture { + fx := newV2Fixture(t) + fx.addCorpseProperty(t, shape) + fx.addType(t, testSpaceId, objectstore.TestObject{ + bundle.RelationKeyId: domain.String("type-live"), + bundle.RelationKeyUniqueKey: domain.String("ot-livetype"), + bundle.RelationKeyName: domain.String("Live type"), + bundle.RelationKeyRecommendedRelations: domain.StringList([]string{corpsePropertyId}), + }) + if shape == corpseTombstone { + // the read half recovers a tombstoned entry from the LIVE object — + // the tree survives a UI delete (ADDRESSING §2.4-5); the index row + // alone spells nothing + fx.readerMock.EXPECT().ReadObject(mock.Anything, testSpaceId, corpsePropertyId).Return(apicore.ObjectRead{ + SbType: model.SmartBlockType_STRelation, + Snapshot: &model.SmartBlockSnapshotBase{ + Details: &types.Struct{Fields: map[string]*types.Value{ + "id": pbtypes.String(corpsePropertyId), + "relationKey": pbtypes.String(corpseBsonKey), + "apiObjectKey": pbtypes.String(corpseSlug), + "name": pbtypes.String("Warranty until"), + "relationFormat": pbtypes.Int64(int64(model.RelationFormat_longtext)), + "isUninstalled": pbtypes.Bool(true), + }}, + }, + Heads: []string{"headR"}, + }, nil).Maybe() + } + return fx + } + liveTypeRead := func() apicore.ObjectRead { + return apicore.ObjectRead{ + SbType: model.SmartBlockType_STType, + Snapshot: &model.SmartBlockSnapshotBase{ + Details: &types.Struct{Fields: map[string]*types.Value{ + "id": pbtypes.String("type-live"), + "uniqueKey": pbtypes.String("ot-livetype"), + "name": pbtypes.String("Live type"), + "recommendedRelations": pbtypes.StringList([]string{corpsePropertyId}), + }}, + ObjectTypes: []string{"ot-objectType"}, + }, + Heads: []string{"headA"}, + } + } + + t.Run("GET type serves the corpse row in typeProperties", func(t *testing.T) { + // the by-id fallback escapes even the injected isDeleted default + // (GetRelationById reads unfiltered details), and that is the + // behaviour the write half is built around — see the header + corpseShapes(t, func(t *testing.T, shape corpseShape) { + // given + fx := newFx(t, shape) + fx.readerMock.EXPECT().ReadObject(mock.Anything, testSpaceId, "type-live").Return(liveTypeRead(), nil) + + // when + body, _, err := fx.GetType(context.Background(), testSpaceId, "livetype", ObjectQuery{}) + + // then — served under the stored BSON key, with the corpse's name + require.NoError(t, err) + var doc struct { + TypeSettings struct { + PropertyDefinitions []struct { + Property string `json:"property"` + InternalKey string `json:"internal_key"` + Name string `json:"name"` + } `json:"property_definitions"` + } `json:"type_settings"` + } + require.NoError(t, json.Unmarshal(body, &doc)) + defs := doc.TypeSettings.PropertyDefinitions + require.Len(t, defs, 1) + // §2e: the entry names the property by its document-facing + // spelling, and carries the stored key beside it — a BSON-keyed + // corpse has no slug, so both land on the stored key + assert.Equal(t, corpseBsonKey, defs[0].Property) + assert.Equal(t, "Warranty until", defs[0].Name) + }) + }) + + t.Run("PATCHing that list back resolves to the holder — no mint", func(t *testing.T) { + corpseShapes(t, func(t *testing.T, shape corpseShape) { + // given + fx := newFx(t, shape) + fx.readerMock.EXPECT().ReadObject(mock.Anything, testSpaceId, "type-live").Return(liveTypeRead(), nil).Maybe() + var minted []*pb.RpcObjectCreateRelationRequest + fx.mwMock.EXPECT().ObjectCreateRelation(mock.Anything, mock.Anything).RunAndReturn( + func(ctx context.Context, req *pb.RpcObjectCreateRelationRequest) *pb.RpcObjectCreateRelationResponse { + minted = append(minted, req) + return &pb.RpcObjectCreateRelationResponse{ + Error: &pb.RpcObjectCreateRelationResponseError{Code: pb.RpcObjectCreateRelationResponseError_NULL}, + ObjectId: "rel-minted-dup", Key: "6a7663db61fab21cd4b9e999", + } + }).Maybe() + var applied []*model.Detail + fx.mwMock.EXPECT().ObjectSetDetails(mock.Anything, mock.Anything).RunAndReturn( + func(ctx context.Context, req *pb.RpcObjectSetDetailsRequest) *pb.RpcObjectSetDetailsResponse { + applied = req.Details + return &pb.RpcObjectSetDetailsResponse{ + Error: &pb.RpcObjectSetDetailsResponseError{Code: pb.RpcObjectSetDetailsResponseError_NULL}} + }) + + // when — exactly the typeProperties GET just served + result, err := fx.UpdateType(context.Background(), testSpaceId, "livetype", + []byte(`{"type_settings":{"property_definitions":[{"property":"`+corpseBsonKey+`","name":"Warranty until","format":"text"}]}}`), false, true) + + // then — nothing is minted and the list still points at the very + // relation object the GET resolved it from: a round-trip identity + require.NoError(t, err) + assert.Empty(t, minted, "an echoed stored key is never a mint request") + assert.Nil(t, result.Created, "no property side effect is reported either") + var recommended []string + for _, detail := range applied { + if detail.Key == bundle.RelationKeyRecommendedRelations.String() { + recommended = pbtypes.GetStringListValue(detail.Value) + } + } + assert.Equal(t, []string{corpsePropertyId}, recommended) + }) + }) + + t.Run("the corpse SLUG in typeProperties still mints — the namespace vacated", func(t *testing.T) { + // the resolve is KEY-ONLY: a corpse's slug is free for re-minting + // (§8-OQ2), so naming it declares a NEW property, never the corpse + corpseShapes(t, func(t *testing.T, shape corpseShape) { + fx := newFx(t, shape) + fx.readerMock.EXPECT().ReadObject(mock.Anything, testSpaceId, "type-live").Return(liveTypeRead(), nil).Maybe() + var minted []*pb.RpcObjectCreateRelationRequest + fx.mwMock.EXPECT().ObjectCreateRelation(mock.Anything, mock.Anything).RunAndReturn( + func(ctx context.Context, req *pb.RpcObjectCreateRelationRequest) *pb.RpcObjectCreateRelationResponse { + minted = append(minted, req) + return &pb.RpcObjectCreateRelationResponse{ + Error: &pb.RpcObjectCreateRelationResponseError{Code: pb.RpcObjectCreateRelationResponseError_NULL}, + ObjectId: "rel-fresh", Key: "6a7663db61fab21cd4b9e777", + } + }) + fx.mwMock.EXPECT().ObjectSetDetails(mock.Anything, mock.Anything).Return(&pb.RpcObjectSetDetailsResponse{ + Error: &pb.RpcObjectSetDetailsResponseError{Code: pb.RpcObjectSetDetailsResponseError_NULL}}).Maybe() + + _, err := fx.UpdateType(context.Background(), testSpaceId, "livetype", + []byte(`{"type_settings":{"property_definitions":[{"property":"`+corpseSlug+`","name":"Warranty until","format":"text"}]}}`), false, true) + + require.NoError(t, err) + require.Len(t, minted, 1) + assert.Equal(t, corpseSlug, + minted[0].Details.Fields[bundle.RelationKeyApiObjectKey.String()].GetStringValue()) + }) + }) +} + +// TestV2RemovedBundledSlugEqualsKeyClass covers the bundled-key class the +// review round could not see: 41 of 194 bundled relations have +// ApiSlug(key) == key (`tag`, `status`, `description`, …), and every §8.40 +// verification used dueDate — one of the keys where they DIFFER. The +// difference is load-bearing on the view channel: view documents spell +// slugs, so a slug≠key removal was refused there by accident (the slug +// stopped resolving) while the slug==key class landed writes on removed +// properties across columns, groupBy, filters and sorts — executed at 40/40 +// accepted before this fix (§8.41-2). +// +// How these fixtures can fail: the removed relations are `description` +// (create/PATCH legs) and `tag` (view legs) — both slug==key, so any gate +// that only works when canonicalization changes the spelling (the §8.40 +// assumption) passes dueDate's test and fails these. Three shapes each; the +// tombstone leg fails if the removal verdict loses its derived-id fallback. +// Revert checks (executed): dropping the refusesRemovedBundled arm from +// viewops' validateViewKeys fails every view leg here while the dueDate +// column test stays green (its refusal comes from the slug fallback); +// dropping the removedPropertyIssue arm from stateops' checkKey fails the +// PATCH leg. +func TestV2RemovedBundledSlugEqualsKeyClass(t *testing.T) { + ctx := context.Background() + addRemoved := func(t *testing.T, fx *v2Fixture, key string, shape corpseShape) { + if shape == corpseTombstone { + fx.addTombstone(t, "drv-rel-"+key) + return + } + obj := objectstore.TestObject{ + bundle.RelationKeyId: domain.String("drv-rel-" + key), + bundle.RelationKeyRelationKey: domain.String(key), + bundle.RelationKeyName: domain.String(key), + bundle.RelationKeyIsUninstalled: domain.Bool(true), + } + if key == "tag" { + obj[bundle.RelationKeyRelationFormat] = domain.Int64(int64(model.RelationFormat_tag)) + } + if shape == corpseProd { + obj[bundle.RelationKeyIsDeleted] = domain.Bool(true) + } + fx.addRelation(t, testSpaceId, obj) + } + + t.Run("create refuses a removed slug==key property", func(t *testing.T) { + corpseShapes(t, func(t *testing.T, shape corpseShape) { + fx := newV2Fixture(t) + addRemoved(t, fx, "description", shape) + + _, err := fx.CreateObject(ctx, testSpaceId, + []byte(`{"version":1,"type":"page","properties":{"name":"n","description":"x"}}`), false, true) + + requireRemovalRefusal(t, err, "description") + }) + }) + + t.Run("PATCH refuses it off-document", func(t *testing.T) { + corpseShapes(t, func(t *testing.T, shape corpseShape) { + fx := newV2Fixture(t) + addRemoved(t, fx, "description", shape) + cleanDoc := `{"version":1,"id":"obj1","type":"page","properties":{"name":"Doc"},"blocks":[{"id":"blockOne1","type":"paragraph","text":"hi"}]}` + fx.expectMutate(editRead(t, cleanDoc), "headB") + + _, err := fx.PatchObject(ctx, testSpaceId, "obj1", + patchBody(`{"op":"set_properties","set":{"description":"x"}}`), "", false, true) + + requireRemovalRefusal(t, err, "description") + }) + }) + + t.Run("update_view refuses it on every channel", func(t *testing.T) { + // the executed §8.41-2 matrix: columns, groupBy, filters, sorts — + // the four channels that accepted a removed `tag` 40 times out of 40 + plainViewDoc := `{"version":1,"id":"obj1","type":"set","properties":{"name":"Bugs","setOf":["ot-bug"]},"blocks":[` + + `{"id":"dataview","type":"dataview","properties":[{"property":"name","format":"text"}],` + + `"views":[{"id":"viewAll1","name":"All","columns":[{"property":"name"}]}]}]}` + ops := map[string]string{ + "columns": `{"op":"update_view","view":"viewAll1","columns":{"tag":{"width":80}}}`, + "group_by": `{"op":"update_view","view":"viewAll1","set":{"group_by":"tag"}}`, + "filters": `{"op":"update_view","view":"viewAll1","set":{"filters":[{"property":"tag","condition":"empty"}]}}`, + "sorts": `{"op":"update_view","view":"viewAll1","set":{"sorts":[{"property":"tag","direction":"asc"}]}}`, + } + for channel, op := range ops { + t.Run(channel, func(t *testing.T) { + corpseShapes(t, func(t *testing.T, shape corpseShape) { + fx := newV2Fixture(t) + addRemoved(t, fx, "tag", shape) + fx.expectMutate(editRead(t, plainViewDoc), "headB") + + _, err := fx.PatchObject(ctx, testSpaceId, "obj1", patchBody(op), "", false, true) + + requireRemovalRefusal(t, err, "tag") + }) + }) + } + }) + + t.Run("insert_view runs the same gate", func(t *testing.T) { + // insert_view shares validateViewKeys with update_view — pinned so a + // future split of the two paths cannot reopen one of them + plainViewDoc := `{"version":1,"id":"obj1","type":"set","properties":{"name":"Bugs","setOf":["ot-bug"]},"blocks":[` + + `{"id":"dataview","type":"dataview","properties":[{"property":"name","format":"text"}],` + + `"views":[{"id":"viewAll1","name":"All","columns":[{"property":"name"}]}]}]}` + corpseShapes(t, func(t *testing.T, shape corpseShape) { + fx := newV2Fixture(t) + addRemoved(t, fx, "tag", shape) + fx.expectMutate(editRead(t, plainViewDoc), "headB") + + _, err := fx.PatchObject(ctx, testSpaceId, "obj1", + patchBody(`{"op":"insert_view","name":"Grouped","set":{"group_by":"tag"}}`), "", false, true) + + requireRemovalRefusal(t, err, "tag") + }) + }) + + t.Run("a view already showing the removed key stays editable (§8.17)", func(t *testing.T) { + // the preKnown escape: an edit must not reject what the surface + // already shows — the in-document twin of the PATCH escape + corpseShapes(t, func(t *testing.T, shape corpseShape) { + fx := newV2Fixture(t) + addRemoved(t, fx, "tag", shape) + holdingViewDoc := `{"version":1,"id":"obj1","type":"set","properties":{"name":"Bugs","setOf":["ot-bug"]},"blocks":[` + + `{"id":"dataview","type":"dataview","properties":[{"property":"name","format":"text"},{"property":"tag","format":"multi_select"}],` + + `"views":[{"id":"viewAll1","name":"All","columns":[{"property":"name"},{"property":"tag"}]}]}]}` + fx.expectMutate(editRead(t, holdingViewDoc), "headB") + + _, err := fx.PatchObject(ctx, testSpaceId, "obj1", + patchBody(`{"op":"update_view","view":"viewAll1","set":{"group_by":"tag"}}`), "", false, true) + + require.NoError(t, err) + }) + }) +} + +// TestV2RemovedBundledTypeRefusesWrites: the TYPE namespace has the same +// hole the property namespace had, previously untouched (§8.41-5): a +// bundled type key resolves through the bundled table forever, so with +// `task` uninstalled, GET /types/task 404'd while POST /objects +// {"type":"task"} created an object IN the removed type — and a reinstall +// lit the type back up with the new object already in it. Both bundled key +// classes run (`task`: slug==key; `diaryEntry`: slug `diary_entry` differs) +// and all three store shapes; rows sit at the fixture-derived ids +// (`drv-ot-`) so the tombstone leg exercises the derived-id probe. +// Revert checks (executed): dropping the refuseRemovedType call from +// validateDocumentRefs fails the create and templateFor legs; dropping the +// tombstone arm from bundledTypeRemoved fails only the tombstone legs. +func TestV2RemovedBundledTypeRefusesWrites(t *testing.T) { + ctx := context.Background() + addRemovedType := func(t *testing.T, fx *v2Fixture, key string, shape corpseShape) { + if shape == corpseTombstone { + fx.addTombstone(t, "drv-ot-"+key) + return + } + obj := objectstore.TestObject{ + bundle.RelationKeyId: domain.String("drv-ot-" + key), + bundle.RelationKeyUniqueKey: domain.String("ot-" + key), + bundle.RelationKeyName: domain.String(key), + bundle.RelationKeyIsUninstalled: domain.Bool(true), + } + if shape == corpseProd { + obj[bundle.RelationKeyIsDeleted] = domain.Bool(true) + } + fx.addType(t, testSpaceId, obj) + } + requireRemovedType := func(t *testing.T, err error, slug string) { + t.Helper() + apiErr := v2Err(t, err) + assert.Equal(t, http.StatusBadRequest, apiErr.Status) + require.NotEmpty(t, apiErr.Issues) + assert.Contains(t, apiErr.Issues[0].Message, `"`+slug+`"`, "the refusal spells the served slug") + assert.Contains(t, apiErr.Issues[0].Message, "removed from this space") + assert.NotEmpty(t, apiErr.Issues[0].Hint) + } + + t.Run("POST objects refuses both spellings of a removed type", func(t *testing.T) { + for _, spelling := range []struct{ key, input, slug string }{ + {"task", "task", "task"}, + {"diaryEntry", "diary_entry", "diary_entry"}, + {"diaryEntry", "diaryEntry", "diary_entry"}, + } { + t.Run(spelling.input, func(t *testing.T) { + corpseShapes(t, func(t *testing.T, shape corpseShape) { + fx := newV2Fixture(t) + addRemovedType(t, fx, spelling.key, shape) + + _, err := fx.CreateObject(ctx, testSpaceId, + []byte(`{"version":1,"type":"`+spelling.input+`","properties":{"name":"n"}}`), false, true) + + requireRemovedType(t, err, spelling.slug) + + // and the route side stays coherent: the type 404s + _, _, err = fx.GetType(ctx, testSpaceId, spelling.input, ObjectQuery{}) + requireNotFoundError(t, err) + }) + }) + } + }) + + t.Run("templateFor refuses a removed target type", func(t *testing.T) { + corpseShapes(t, func(t *testing.T, shape corpseShape) { + fx := newV2Fixture(t) + addRemovedType(t, fx, "task", shape) + + _, err := fx.CreateTemplate(ctx, testSpaceId, + []byte(`{"version":1,"type":"template","template_for":"task","properties":{"name":"Weekly"}}`), false, true) + + requireRemovedType(t, err, "task") + }) + }) + + t.Run("POST sets names the removal instead of unknown", func(t *testing.T) { + // a set over a bundled-but-uninstalled type was ALREADY refused (a + // set needs an installed type object), but as "unknown type key" with + // a did-you-mean — a lie about a key the space knows and removed + // (§8.41-10) + corpseShapes(t, func(t *testing.T, shape corpseShape) { + fx := newV2Fixture(t) + addRemovedType(t, fx, "task", shape) + + _, err := fx.CreateSet(ctx, testSpaceId, v2model.CreateSetRequest{Name: "Tasks", Type: "task"}, false, true) + + requireRemovedType(t, err, "task") + }) + }) + + t.Run("a NEVER-installed bundled type still creates", func(t *testing.T) { + // install-on-write is the common case in a fresh space — no type + // object, not even a tombstone, exists for task here + fx := newV2Fixture(t) + captured := fx.expectCreate("obj-task") + fx.expectEtagRead("obj-task") + + _, err := fx.CreateObject(ctx, testSpaceId, + []byte(`{"version":1,"type":"task","properties":{"name":"n"}}`), false, true) + + require.NoError(t, err) + require.NotNil(t, *captured) + assert.Equal(t, []string{"ot-task"}, (*captured).ObjectTypes) + }) + + t.Run("an ARCHIVED bundled type refuses too", func(t *testing.T) { + // DELETE /types archives; the API's own delete verb must not leave a + // type that 404s on its route yet accepts new objects (§8.41-8) + fx := newV2Fixture(t) + fx.addType(t, testSpaceId, objectstore.TestObject{ + bundle.RelationKeyId: domain.String("drv-ot-task"), + bundle.RelationKeyUniqueKey: domain.String("ot-task"), + bundle.RelationKeyName: domain.String("Task"), + bundle.RelationKeyIsArchived: domain.Bool(true), + }) + + _, err := fx.CreateObject(ctx, testSpaceId, + []byte(`{"version":1,"type":"task","properties":{"name":"n"}}`), false, true) + + requireRemovedType(t, err, "task") + }) +} + +// TestV2TypePropertiesRefusesRemovedBundledKey: the typeProperties channel +// was the one write channel the §8.40 refusal never reached (§8.41-4) — +// POST/PATCH /types with {"key":"due_date"} while dueDate was removed +// pointed the new type's recommendedRelations at the corpse, `created: +// null`, silently. (Before the §8.40 round this path was worse still: it +// REINSTALLED the deleted relation. That resurrection stays closed — the +// mocks here fail the test on any unexpected install or mint RPC.) +// +// The ONE escape is the type's own echo (echoPropertyIds): a type already +// referencing the removed relation's object gets its GET/PATCH loop back as +// an identity — refusing the echo would force-delete the reference, the +// §8.34 outcome the custom-corpse decision (§8.40-2) exists to prevent. +// Both bundled key classes run (due_date: slug≠key; tag: slug==key), three +// shapes each. +// Revert checks (executed): dropping the removal gate in PropertyId's +// bundled arm fails the two refusal subtests (the reference lands again); +// dropping the echoPropertyIds escape fails the echo subtest (it turns into +// a refusal). +func TestV2TypePropertiesRefusesRemovedBundledKey(t *testing.T) { + ctx := context.Background() + + t.Run("POST types refuses a removed bundled key in typeProperties", func(t *testing.T) { + corpseShapes(t, func(t *testing.T, shape corpseShape) { + fx := newV2Fixture(t) + fx.addRemovedBundledProperty(t, shape) + + _, err := fx.CreateType(ctx, testSpaceId, + []byte(`{"properties":{"name":"Gadget"},"type_settings":{"api_key":"gadget","property_definitions":[{"property":"due_date","format":"date"}]}}`), false, true) + + apiErr := v2Err(t, err) + assert.Equal(t, http.StatusBadRequest, apiErr.Status) + require.NotEmpty(t, apiErr.Issues) + assert.Contains(t, apiErr.Issues[0].Message, `"due_date"`) + assert.Contains(t, apiErr.Issues[0].Message, "removed from this space") + }) + }) + + t.Run("PATCH types refuses adding a removed slug==key property", func(t *testing.T) { + corpseShapes(t, func(t *testing.T, shape corpseShape) { + fx := newV2Fixture(t) + if shape == corpseTombstone { + fx.addTombstone(t, "drv-rel-tag") + } else { + obj := objectstore.TestObject{ + bundle.RelationKeyId: domain.String("drv-rel-tag"), + bundle.RelationKeyRelationKey: domain.String("tag"), + bundle.RelationKeyName: domain.String("Tag"), + bundle.RelationKeyRelationFormat: domain.Int64(int64(model.RelationFormat_tag)), + bundle.RelationKeyIsUninstalled: domain.Bool(true), + } + if shape == corpseProd { + obj[bundle.RelationKeyIsDeleted] = domain.Bool(true) + } + fx.addRelation(t, testSpaceId, obj) + } + // the PATCHed type does NOT reference the tag corpse — no echo + fx.addType(t, testSpaceId, objectstore.TestObject{ + bundle.RelationKeyId: domain.String("type-live"), + bundle.RelationKeyUniqueKey: domain.String("ot-livetype"), + bundle.RelationKeyName: domain.String("Live type"), + }) + + _, err := fx.UpdateType(ctx, testSpaceId, "livetype", + []byte(`{"type_settings":{"property_definitions":[{"property":"tag"}]}}`), false, true) + + apiErr := v2Err(t, err) + assert.Equal(t, http.StatusBadRequest, apiErr.Status) + require.NotEmpty(t, apiErr.Issues) + assert.Contains(t, apiErr.Issues[0].Message, `"tag"`) + assert.Contains(t, apiErr.Issues[0].Message, "removed from this space") + }) + }) + + t.Run("the type's own echo resolves as an identity", func(t *testing.T) { + corpseShapes(t, func(t *testing.T, shape corpseShape) { + // given — the type already references the removed relation + fx := newV2Fixture(t) + fx.addRemovedBundledProperty(t, shape) + fx.addType(t, testSpaceId, objectstore.TestObject{ + bundle.RelationKeyId: domain.String("type-live"), + bundle.RelationKeyUniqueKey: domain.String("ot-livetype"), + bundle.RelationKeyName: domain.String("Live type"), + bundle.RelationKeyRecommendedRelations: domain.StringList([]string{removedBundledPropertyId}), + }) + var applied []*model.Detail + fx.mwMock.EXPECT().ObjectSetDetails(mock.Anything, mock.Anything).RunAndReturn( + func(ctx context.Context, req *pb.RpcObjectSetDetailsRequest) *pb.RpcObjectSetDetailsResponse { + applied = req.Details + return &pb.RpcObjectSetDetailsResponse{ + Error: &pb.RpcObjectSetDetailsResponseError{Code: pb.RpcObjectSetDetailsResponseError_NULL}} + }) + fx.expectEtagRead("type-live") + + // when — the spelling the GET serves + result, err := fx.UpdateType(ctx, testSpaceId, "livetype", + []byte(`{"type_settings":{"property_definitions":[{"property":"due_date","format":"date"}]}}`), false, true) + + // then — the reference survives, nothing minted, nothing installed + require.NoError(t, err) + assert.Nil(t, result.Created) + var recommended []string + for _, detail := range applied { + if detail.Key == bundle.RelationKeyRecommendedRelations.String() { + recommended = pbtypes.GetStringListValue(detail.Value) + } + } + assert.Equal(t, []string{removedBundledPropertyId}, recommended) + }) + }) +} + +// TestV2SetsRefuseRemovedBundledProperty: POST /sets validated filter/sort +// keys against the queried type's recommended lists — resolved BY ID and +// never stripped of deleted relations, which is the DEFAULT state after any +// UI delete — so a new set could persist a query against a removed property +// (§8.41-6). Both bundled key classes; three shapes. On the tombstone leg +// the recommended-list resolution itself cannot spell the key (the row has +// none), so the key falls out of the type's reference set and the refusal +// comes from the has-no-property branch — a 400 either way, pinned as such. +// Revert check (executed): dropping the removal gate in list_create's +// validateViewKeys turns the flag-only and prod legs green-through (the +// set is created) and both fail. +func TestV2SetsRefuseRemovedBundledProperty(t *testing.T) { + ctx := context.Background() + newFx := func(t *testing.T, key string, format model.RelationFormat, shape corpseShape) *v2Fixture { + fx := newV2Fixture(t) + if shape == corpseTombstone { + fx.addTombstone(t, "drv-rel-"+key) + } else { + obj := objectstore.TestObject{ + bundle.RelationKeyId: domain.String("drv-rel-" + key), + bundle.RelationKeyRelationKey: domain.String(key), + bundle.RelationKeyName: domain.String(key), + bundle.RelationKeyRelationFormat: domain.Int64(int64(format)), + bundle.RelationKeyIsUninstalled: domain.Bool(true), + } + if shape == corpseProd { + obj[bundle.RelationKeyIsDeleted] = domain.Bool(true) + } + fx.addRelation(t, testSpaceId, obj) + } + fx.addType(t, testSpaceId, objectstore.TestObject{ + bundle.RelationKeyId: domain.String("type-bug"), + bundle.RelationKeyUniqueKey: domain.String("ot-bug"), + bundle.RelationKeyName: domain.String("Bug"), + bundle.RelationKeyRecommendedRelations: domain.StringList([]string{"drv-rel-" + key}), + }) + return fx + } + + t.Run("filters on a removed slug≠key property", func(t *testing.T) { + corpseShapes(t, func(t *testing.T, shape corpseShape) { + fx := newFx(t, "dueDate", model.RelationFormat_date, shape) + + _, err := fx.CreateSet(ctx, testSpaceId, v2model.CreateSetRequest{ + Name: "Late bugs", Type: "bug", + Filters: json.RawMessage(`[{"property":"due_date","condition":"empty"}]`), + }, false, true) + + apiErr := v2Err(t, err) + assert.Equal(t, http.StatusBadRequest, apiErr.Status) + if shape != corpseTombstone { + require.NotEmpty(t, apiErr.Issues) + assert.Contains(t, apiErr.Issues[0].Message, "removed from this space") + } + }) + }) + + t.Run("sorts on a removed slug==key property", func(t *testing.T) { + corpseShapes(t, func(t *testing.T, shape corpseShape) { + fx := newFx(t, "tag", model.RelationFormat_tag, shape) + + _, err := fx.CreateSet(ctx, testSpaceId, v2model.CreateSetRequest{ + Name: "By tag", Type: "bug", + Sorts: json.RawMessage(`[{"property":"tag","direction":"asc"}]`), + }, false, true) + + apiErr := v2Err(t, err) + assert.Equal(t, http.StatusBadRequest, apiErr.Status) + if shape != corpseTombstone { + require.NotEmpty(t, apiErr.Issues) + assert.Contains(t, apiErr.Issues[0].Message, "removed from this space") + } + }) + }) +} + +// TestV2HoldingKeyPrefersLive pins the §8.41-11 selection rule: with a +// corpse AND a live relation both holding one stored key, the probe answers +// the LIVE one. The old Limit:1 query with no sort returned whichever row +// the store yielded first; callers happened to consult the probe only after +// the live chain missed, but that ordering was a convention, not a contract. +// How this fixture can fail: the corpse row's id ("a-…") sorts BEFORE the +// live row's ("z-…"), so both a first-row regression and an id-ordered +// tie-break without the liveness preference return the corpse. +func TestV2HoldingKeyPrefersLive(t *testing.T) { + fx := newV2Fixture(t) + fx.addRelation(t, testSpaceId, objectstore.TestObject{ + bundle.RelationKeyId: domain.String("a-corpse"), + bundle.RelationKeyRelationKey: domain.String("sharedKey"), + bundle.RelationKeyName: domain.String("Corpse holder"), + bundle.RelationKeyIsUninstalled: domain.Bool(true), + }) + fx.addRelation(t, testSpaceId, objectstore.TestObject{ + bundle.RelationKeyId: domain.String("z-live"), + bundle.RelationKeyRelationKey: domain.String("sharedKey"), + bundle.RelationKeyName: domain.String("Live holder"), + }) + + id, held, err := fx.relationObjectHoldingKey(context.Background(), testSpaceId, "sharedKey") + + require.NoError(t, err) + require.True(t, held) + assert.Equal(t, "z-live", id, "a live holder outranks a corpse regardless of row order") +} diff --git a/core/api/v2/service/create.go b/core/api/v2/service/create.go new file mode 100644 index 0000000000..5076bbf814 --- /dev/null +++ b/core/api/v2/service/create.go @@ -0,0 +1,669 @@ +package v2service + +// create.go implements the Phase-2 object create surface (APIV2.md §2): +// POST /v2/spaces/{space_id}/objects (full AnyBlock document or the +// {type, name, properties, markdown} shortcut — discriminated per §8/R7 on +// the presence of version/blocks) and POST .../templates. The create path is +// snapshot-based: anyblockjson.Unmarshal → apicore.ObjectCreator (one change +// set), with create-missing resolvers (resolver.go) and the referential +// validation layer (refs.go) in front. + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "sort" + "strconv" + "strings" + + v2model "github.com/anyproto/anytype-heart/core/api/v2/model" + "github.com/anyproto/anytype-heart/core/domain" + "github.com/anyproto/anytype-heart/pkg/lib/anyblockjson" + "github.com/anyproto/anytype-heart/pkg/lib/bundle" + "github.com/anyproto/anytype-heart/util/pbtypes" + + "github.com/gogo/protobuf/types" +) + +// docEnvelope is the light envelope decode used for referential validation +// before Unmarshal runs (no side effects yet at that point). +type docEnvelope struct { + Kind string `json:"kind"` + Type string `json:"type"` + TemplateFor string `json:"template_for"` + Properties map[string]json.RawMessage `json:"properties"` + // TypeSettings is the §2a gated subtree of a TYPE document. The envelope + // `key` and the top-level `type_properties` array both moved in here — + // `key` as `api_key` (it was always the apiObjectKey slug) and the array + // as `property_definitions`. Only the two members this package's own + // logic reads are modelled: the rest reach the store through + // anyblockjson.Unmarshal and the snapshot, never through this struct. + TypeSettings *typeSettingsEnvelope `json:"type_settings"` + Items []string `json:"items"` +} + +// typeSettingsEnvelope is the slice of §2a's type_settings the API's own +// identity and validation layers read. +type typeSettingsEnvelope struct { + ApiKey string `json:"api_key"` + PropertyDefinitions json.RawMessage `json:"property_definitions"` +} + +// apiKey is the caller's proposed api slug, nil-safe. +func (e docEnvelope) apiKey() string { + if e.TypeSettings == nil { + return "" + } + return e.TypeSettings.ApiKey +} + +// propertyDefinitions is the §2a property-definition array, nil-safe. +func (e docEnvelope) propertyDefinitions() json.RawMessage { + if e.TypeSettings == nil { + return nil + } + return e.TypeSettings.PropertyDefinitions +} + +// v2ObjectShortcut is the R7 shortcut body: {type, name, properties, +// markdown}. Any other top-level key means the caller meant a full document +// and forgot version/blocks — rejected with steering. +type v2ObjectShortcut struct { + Type string `json:"type"` + Name string `json:"name"` + Properties map[string]json.RawMessage `json:"properties"` + Markdown string `json:"markdown"` +} + +var shortcutKeys = map[string]bool{"type": true, "name": true, "properties": true, "markdown": true} + +// docCreateOptions parameterizes the shared document create path. +type docCreateOptions struct { + dryRun bool + requireTemplate bool // POST /templates: template_for is mandatory + // createMissingOptions is the request's ?create_missing_options=true consent, carried + // to the resolver that would otherwise mint a select option for a name + // that matches nothing. + createMissingOptions bool +} + +// CreateObject implements POST /v2/spaces/{space_id}/objects. +func (s *Service) CreateObject(ctx context.Context, spaceId string, body []byte, dryRun, createMissingOptions bool) (*v2model.CreateResult, error) { + if err := s.ensureSpaceWrite(ctx, spaceId); err != nil { + return nil, err + } + fields, err := parseEnvelope(body) + if err != nil { + return nil, v2model.ValidationFailed("request body is not a JSON object", + v2model.Issue{Message: err.Error()}) + } + + // §8/R7 discriminator: presence of version or blocks ⇒ full document + _, hasVersion := fields["version"] + _, hasBlocks := fields["blocks"] + if hasVersion || hasBlocks { + return s.createFromDocument(ctx, spaceId, body, docCreateOptions{dryRun: dryRun, createMissingOptions: createMissingOptions}) + } + return s.createFromShortcut(ctx, spaceId, fields, dryRun, createMissingOptions) +} + +// CreateTemplate implements POST /v2/spaces/{space_id}/templates: an AnyBlock +// document with template_for, routed through the generic object-create path +// (no create-from-body template RPC exists — APIV2.md Phase 2). +// +// The endpoint IS the kind, exactly as POST /types is: a template must now +// say `kind: "template"` (the type "template" stopped carrying that meaning +// on its own), and requiring a caller to restate what the URL already said +// is the trap C2 exists to avoid. Injected here rather than defaulted deeper +// so the whole create path below sees a document that is already complete. +func (s *Service) CreateTemplate(ctx context.Context, spaceId string, body []byte, dryRun, createMissingOptions bool) (*v2model.CreateResult, error) { + if err := s.ensureSpaceWrite(ctx, spaceId); err != nil { + return nil, err + } + fields, err := parseEnvelope(body) + if err != nil { + return nil, v2model.ValidationFailed("request body is not a JSON object", + v2model.Issue{Message: err.Error()}) + } + if _, ok := fields["kind"]; !ok { + if fields["kind"], err = rawJSON("template"); err != nil { + return nil, err + } + if body, err = encodeEnvelope(fields); err != nil { + return nil, err + } + } + return s.createFromDocument(ctx, spaceId, body, docCreateOptions{dryRun: dryRun, requireTemplate: true, createMissingOptions: createMissingOptions}) +} + +// createFromShortcut synthesizes an AnyBlock document from the shortcut +// shape and reuses the full-document path. markdown is parsed into flat +// blocks server-side (anyblockjson.ParseMarkdownBlocks, the Phase-5 parser) +// and rides the same single-change-set create as an explicit blocks array — +// dry runs validate it, no half-built object on failure, and the C8 result +// cache replays it safely (the §7.2 two-change-set caveats are gone). +func (s *Service) createFromShortcut(ctx context.Context, spaceId string, fields map[string]json.RawMessage, dryRun, createMissingOptions bool) (*v2model.CreateResult, error) { + for key := range fields { + if !shortcutKeys[key] { + return nil, v2model.ValidationFailed("unknown field in create shortcut", + v2model.Issue{ + Path: "/" + key, + Message: fmt.Sprintf("unknown key %q — the shortcut accepts type, name, properties, markdown", key), + Hint: "to send a full AnyBlock document, include \"version\": 1", + }) + } + } + raw, err := encodeEnvelope(fields) + if err != nil { + return nil, err + } + var shortcut v2ObjectShortcut + if err := json.Unmarshal(raw, &shortcut); err != nil { + return nil, v2model.ValidationFailed("decode create shortcut: " + err.Error()) + } + if shortcut.Type == "" { + return nil, v2model.ValidationFailed("type is required", + v2model.Issue{Path: "/type", Message: "the shortcut needs a type key", Hint: "list keys with GET /v2/spaces/{space_id}/types"}) + } + + doc := map[string]json.RawMessage{} + if doc["version"], err = rawJSON(anyblockjson.FormatVersion); err != nil { + return nil, err + } + if doc["type"], err = rawJSON(shortcut.Type); err != nil { + return nil, err + } + properties := shortcut.Properties + if properties == nil { + properties = map[string]json.RawMessage{} + } + if shortcut.Name != "" { + if properties["name"], err = rawJSON(shortcut.Name); err != nil { + return nil, err + } + } + if len(properties) > 0 { + if doc["properties"], err = rawJSON(properties); err != nil { + return nil, err + } + } + markdownBlocks := false + if shortcut.Markdown != "" { + run, exceeded := anyblockjson.ParseMarkdownBlocksLimit(shortcut.Markdown, v2MaxCreateMarkdownBlocks) + if exceeded { + return nil, v2model.ValidationFailed("markdown produced too many blocks", + v2model.Issue{Path: "/markdown", Message: fmt.Sprintf( + "the markdown parses to more than %d blocks — the create limit is %d; create with a shorter body and add the rest with PATCH insert_blocks", + v2MaxCreateMarkdownBlocks, v2MaxCreateMarkdownBlocks)}) + } + if len(run) == 0 { + // same contract as the insert_blocks markdown channel — a silent + // empty object teaches the caller nothing (C6) + return nil, v2model.ValidationFailed("markdown produced no blocks", + v2model.Issue{Path: "/markdown", Message: "the markdown body contains no content — give at least one non-blank line, or omit markdown"}) + } + if doc["blocks"], err = rawJSON(run); err != nil { + return nil, err + } + markdownBlocks = true + } + docJSON, err := encodeEnvelope(doc) + if err != nil { + return nil, err + } + + result, err := s.createFromDocument(ctx, spaceId, docJSON, docCreateOptions{dryRun: dryRun}) + if err != nil && markdownBlocks { + // the blocks array is synthetic here — readdress its issues to the + // markdown channel the caller actually sent (C6) + err = rebaseMarkdownCreateError(err) + } + return result, err +} + +// v2MaxCreateMarkdownBlocks caps how many blocks a create shortcut's markdown +// body may parse to. Wider than the per-op insert_blocks cap (a whole document +// vs one insertion) but still a hard bound: the byte-bounded markdown channel +// would otherwise reach hundreds of thousands of blocks in one change set. +const v2MaxCreateMarkdownBlocks = 2048 + +// rebaseMarkdownCreateError rewrites /blocks/… issue paths onto +// /markdown[]… — the create-shortcut caller sent markdown, never a blocks +// array, so a path into the synthesized document is unactionable (C6). j is +// the parsed block position, the same convention the insert_blocks op's +// created_blocks keys document. +func rebaseMarkdownCreateError(err error) error { + var v2Err *v2model.Error + if !errors.As(err, &v2Err) { + return err + } + for i := range v2Err.Issues { + rest, ok := strings.CutPrefix(v2Err.Issues[i].Path, "/blocks/") + if !ok { + continue + } + if idx, tail, found := strings.Cut(rest, "/"); found { + v2Err.Issues[i].Path = fmt.Sprintf("/markdown[%s]/%s", idx, tail) + } else { + v2Err.Issues[i].Path = fmt.Sprintf("/markdown[%s]", rest) + } + } + return v2Err +} + +// normalizeCreateBody strips the v2 read-envelope additions (etag, +// warnings) so "read a document, create a copy" works from every GET shape, +// and refuses the partial ?block= subtree marker — a subtree is not a +// document. +func normalizeCreateBody(body []byte) ([]byte, error) { + fields, err := parseEnvelope(body) + if err != nil { + return nil, v2model.ValidationFailed("request body is not a JSON object", + v2model.Issue{Message: err.Error()}) + } + if _, partial := fields["subtree"]; partial { + return nil, v2model.ValidationFailed("this body is a partial ?block= subtree read, not a whole document", + v2model.Issue{Path: "/subtree", Message: "an object cannot be created from a subtree read — it is a fragment of another document", + Hint: "GET the source object with ?ids=full and without ?block= for a complete document"}) + } + delete(fields, "etag") // C7: concurrency lives in headers, never in create bodies + delete(fields, "warnings") + return encodeEnvelope(fields) +} + +// docLocalIds collects the doc-local ids a flat AnyBlock document carries +// explicitly, in document order and deduplicated — the create path's view of +// the id domain compact relabeling covers. The walk itself is +// v2EditDoc.localIds (ops.go), shared with the PATCH payload resolver so the +// two guards cannot drift into covering different slots. A body that is not +// decodable yields nil (later validation owns that failure). +func docLocalIds(doc []byte) []string { + parsed, err := parseEditDoc(doc) + if err != nil { + return nil + } + return parsed.localIds() +} + +// warnLabelShapedIds flags a create body whose local ids look like the +// default read's compact labels (5 lowercase-hex chars). Adopting them is +// legal — the new object has no other holders of those ids — but almost +// never intended: the clone's stored ids become the labels, and the +// source's real ids are one query parameter away. A warning, not a +// refusal: with no owned-id baseline to check against, a 5-hex authored id +// is indistinguishable from a label, and a clone of a document that truly +// owns such ids (they never relabel, so its export carries them verbatim) +// must keep working. +func warnLabelShapedIds(body []byte) []v2model.Issue { + var labelLike []string + for _, id := range docLocalIds(body) { + if anyblockjson.IsCompactLabelShaped(id) { + labelLike = append(labelLike, strconv.Quote(id)) + } + } + if len(labelLike) == 0 { + return nil + } + return []v2model.Issue{{ + Path: "/blocks", + Message: fmt.Sprintf("ids %s look like compact labels from a default read and were adopted as this object's real ids", strings.Join(labelLike, ", ")), + Hint: "to clone with the source's real ids, GET it with ?ids=full; to mint fresh ids, omit them", + }} +} + +// createFromDocument is the shared full-document create path: structural +// validation → referential validation → Unmarshal with create-missing +// resolvers → snapshot create (one change set) → etag read-back. +func (s *Service) createFromDocument(ctx context.Context, spaceId string, body []byte, opts docCreateOptions) (*v2model.CreateResult, error) { + // 0. envelope normalization: a pasted read body creates a copy instead of + // 400ing on its own etag + body, err := normalizeCreateBody(body) + if err != nil { + return nil, err + } + + // 1. structural + format-semantic validation (no side effects) + if err := s.rejectInvalidDocument(body); err != nil { + return nil, err + } + + // 1a. canonicalize addressing terms (§7.5a-5): api-key slugs in the + // envelope's type/templateFor and in the properties map resolve to + // their stored spellings before validation and import; the spelling map + // keeps refusal paths addressed to the request as sent + body, spellings, err := s.canonicalizeDocumentKeys(spaceId, body) + if err != nil { + return nil, err + } + + var envelope docEnvelope + if err := json.Unmarshal(body, &envelope); err != nil { + return nil, v2model.ValidationFailed("decode document envelope: " + err.Error()) + } + if envelope.Type == "" { + // absent type defaults to page on create (agent-friendly; SPEC §2 + // leaves it absent only for legacy/system objects); on the templates + // endpoint the default is the template type itself + envelope.Type = string(bundle.TypeKeyPage) + if opts.requireTemplate { + envelope.Type = string(bundle.TypeKeyTemplate) + } + var err error + fields, err := parseEnvelope(body) + if err != nil { + return nil, err + } + if fields["type"], err = rawJSON(envelope.Type); err != nil { + return nil, err + } + if body, err = encodeEnvelope(fields); err != nil { + return nil, err + } + } + + // 2. referential validation (R9) — reject before anything is created + if err := s.validateDocumentRefs(ctx, spaceId, &envelope, opts, spellings); err != nil { + return nil, err + } + + // 3. Unmarshal with create-missing resolvers (SPEC §3/§2a); on a dry run + // the resolvers only record would-be creations + resolvers := s.newCreatingResolvers(ctx, spaceId, opts.dryRun, opts.createMissingOptions) + _, snapshot, err := anyblockjson.Unmarshal(body, resolvers.Options()) + if err != nil { + return nil, mapUnmarshalError(body, err) + } + if err := resolvers.err(); err != nil { + return nil, fmt.Errorf("resolve document references: %w", err) + } + + result := &v2model.CreateResult{Type: envelope.Type, Created: resolvers.created()} + // the label-adoption tell rides real runs and dry runs alike (C9) + result.Warnings = warnLabelShapedIds(body) + if opts.dryRun { + result.DryRun = true + return result, nil + } + + // 4. template target: templateFor (a type key) becomes the + // targetObjectType detail (a type object id) the editor resolves + // layout from + if envelope.Type == string(bundle.TypeKeyTemplate) && envelope.TemplateFor != "" { + targetId, err := s.creator.TypeIdByKey(ctx, spaceId, domain.TypeKey(envelope.TemplateFor)) + if err != nil { + return nil, fmt.Errorf("resolve template target type %q: %w", envelope.TemplateFor, err) + } + if snapshot.Details == nil { + snapshot.Details = &types.Struct{Fields: map[string]*types.Value{}} + } + snapshot.Details.Fields[bundle.RelationKeyTargetObjectType.String()] = pbtypes.String(targetId) + } + + // 5. create — the whole document as the object's initial state + id, err := s.creator.CreateObjectFromSnapshot(ctx, spaceId, snapshot) + if err != nil { + return nil, fmt.Errorf("create object in space %s: %w", spaceId, err) + } + result.Id = id + + // 6. etag read-back (best effort — the create already succeeded) + if read, err := s.reader.ReadObject(ctx, spaceId, id); err == nil { + result.Etag = ComputeEtag(read.Heads) + } else { + result.Warnings = append(result.Warnings, v2model.Issue{ + Message: "created, but the etag read-back failed — GET the object for its etag", + }) + } + return result, nil +} + +// rejectInvalidDocument maps anyblockjson.Validate failures onto the C6 +// contract: path-addressed validation_failed, or version_unsupported when +// the document was produced by a newer format version (§8: on create an +// unparseable version must fail the write). +func (s *Service) rejectInvalidDocument(body []byte) error { + err := anyblockjson.Validate(body) + if err == nil { + return nil + } + return mapUnmarshalError(body, err) +} + +// mapUnmarshalError converts anyblockjson validation errors into C6 errors. +func mapUnmarshalError(body []byte, err error) error { + var validationErr *anyblockjson.ValidationError + if !errors.As(err, &validationErr) { + return v2model.ValidationFailed("invalid AnyBlock document", v2model.Issue{Message: err.Error()}) + } + if validationErr.NewerFormat { + docVersion, _, ok := anyblockjson.DetectFormat(body) + if !ok { + docVersion = anyblockjson.FormatVersion + 1 + } + return v2model.VersionUnsupported(docVersion, anyblockjson.FormatVersion) + } + issues := make([]v2model.Issue, 0, len(validationErr.Issues)) + for _, issue := range validationErr.Issues { + issues = append(issues, v2model.Issue{Path: issue.Path, Message: issue.Message}) + } + return v2model.ValidationFailed("the document failed AnyBlock validation", issues...) +} + +// validateDocumentRefs is the R9 layer for object creates: kind and type +// gating, template target, items-on-collections, and property-key existence +// in the properties map (reject with did-you-mean — creating properties from +// a possibly hallucinated key is reserved for typeProperties and +// POST /properties). spellings maps canonicalized keys back to the caller's +// own spelling (canonicalizeDocumentKeys), so refusals address the request +// that was actually sent. +func (s *Service) validateDocumentRefs(ctx context.Context, spaceId string, envelope *docEnvelope, opts docCreateOptions, spellings map[string]string) error { + switch envelope.Kind { + case "", "page", "template": + case "object_type": + return v2model.ValidationFailed("type documents are created via their own endpoint", + v2model.Issue{Path: "/kind", Message: "kind \"object_type\" is not accepted here", Hint: fmt.Sprintf("POST /v2/spaces/%s/types", spaceId)}) + default: + return v2model.ValidationFailed("unsupported document kind", + v2model.Issue{Path: "/kind", Message: fmt.Sprintf("kind %q cannot be created through the API", envelope.Kind), Hint: "omit kind (page) or use type \"template\""}) + } + // §2a's identity slots (`type_settings`, and the envelope `key` that + // preceded it) are refused by the format itself, path-addressed and on + // every kind — including the forged-identity case this layer used to + // guard (ADDRESSING §2.4). One statement of the rule, in the validator. + + if opts.requireTemplate { + if envelope.Type == "" { + envelope.Type = string(bundle.TypeKeyTemplate) + } + if envelope.Type != string(bundle.TypeKeyTemplate) { + return v2model.ValidationFailed("not a template document", + v2model.Issue{Path: "/type", Message: fmt.Sprintf("expected type \"template\", got %q", envelope.Type)}) + } + } + // a template must name its target type — the editor derives the layout + // from it (SPEC §2 template_for); enforced on both endpoints + if envelope.Type == string(bundle.TypeKeyTemplate) && envelope.TemplateFor == "" { + return v2model.ValidationFailed("template_for is required", + v2model.Issue{Path: "/template_for", Message: "a template document names its target type key", Hint: fmt.Sprintf("list keys with GET /v2/spaces/%s/types", spaceId)}) + } + + if envelope.Type != "" && envelope.Type != string(bundle.TypeKeyTemplate) { + if !s.typeKeyExists(spaceId, envelope.Type) { + return s.unknownTypeKeyError(spaceId, envelope.Type, "/type") + } + if err := rejectRestrictedType(envelope.Type); err != nil { + return err + } + // the bundled table answers for a type key forever, so existence alone + // cannot see that this SPACE removed the type — without this check a + // create landed a new object in a type whose route 404s, and a + // reinstall lit it back up (§8.41; the type twin of the property + // refusal below) + if err := s.refuseRemovedType(ctx, spaceId, envelope.Type, "/type"); err != nil { + return err + } + } + if envelope.TemplateFor != "" { + if !s.typeKeyExists(spaceId, envelope.TemplateFor) { + return s.unknownTypeKeyError(spaceId, envelope.TemplateFor, "/template_for") + } + if err := s.refuseRemovedType(ctx, spaceId, envelope.TemplateFor, "/template_for"); err != nil { + return err + } + } + + // SPEC §2: items on a non-collection document is a wiring-enforced error + if len(envelope.Items) > 0 && envelope.Type != string(bundle.TypeKeyCollection) { + return v2model.ValidationFailed("items on a non-collection document", + v2model.Issue{Path: "/items", Message: fmt.Sprintf("items requires type \"collection\", got %q", envelope.Type), Hint: fmt.Sprintf("POST /v2/spaces/%s/collections", spaceId)}) + } + + // property keys must exist — did-you-mean, never silent create (R9) + return s.validatePropertyKeys(ctx, spaceId, envelope.Properties, spellings) +} + +// refuseRemovedType is the type-namespace removal gate for one canonicalized +// type slot (§8.41). Fails closed on any probe error: an unverifiable +// removal set must not read as "nothing was removed". +func (s *Service) refuseRemovedType(ctx context.Context, spaceId, typeKey, path string) error { + entries, err := s.liveTypes(spaceId) + if err != nil { + return err + } + removed, err := s.bundledTypeRemovalSet(spaceId) + if err != nil { + return err + } + isRemoved, err := s.bundledTypeRemoved(ctx, spaceId, entries, removed, typeKey) + if err != nil { + return err + } + if isRemoved { + return v2model.ValidationFailed("removed type key", removedTypeIssue(spaceId, typeKey, path)) + } + return nil +} + +// validatePropertyKeys is the R9 unknown-property loop over a document's +// properties map. One primed live set for the whole loop (§7.5a-2), failing +// closed on a load error. +// +// A LIVE property passes as an address. A key no live property claims but +// SOME relation object still holds — a UI-deleted or archived one, a +// "corpse" — passes as a round-trip tolerance +// (propertyKeyHeldByAnyRelation): an object holding values of such a +// relation exports that key, and create is the channel a read body is +// pasted into ("a pasted read body creates a copy", §3(b)). Refusing it +// would make the advertised clone loop fail on a document the API itself +// served, and would do so with a did-you-mean pointing at some unrelated +// live key that happens to be spelled nearby — moving a value onto the +// wrong property is worse than carrying a dormant one. The tolerance is a +// bare existence probe BY DESIGN — it cannot, and does not claim to, +// distinguish a pasted clone from a freshly authored value (see +// propertyKeyHeldByAnyRelation for why no provenance signal is worth its +// cost on a key that resolves nowhere). +// +// This tolerance is not create-specific special pleading: PATCH has the +// same escape by another route (stateops.go checkKey passes any key already +// present on the document). Both say the same thing — a document may keep a +// value it legitimately already carries; neither is an ADDRESS, because +// neither channel will resolve a corpse key to a property object. +// +// The ONE key class the tolerance does not cover is a BUNDLED relation this +// space removed (removedPropertyIssue; §8.41 widened "removed" from +// uninstalled to archived too, and to the tombstone window): bundle. +// HasRelation answers for it forever, so without the explicit check a +// create lands new data on a property the user deleted, and the reinstall +// lights it back up. +// +// spellings maps a canonicalized key back to the caller's spelling — +// refusal paths must address the request as sent, not the rewrite +// (§8.41-10). +func (s *Service) validatePropertyKeys(ctx context.Context, spaceId string, props map[string]json.RawMessage, spellings map[string]string) error { + if len(props) == 0 { + return nil + } + entries, err := s.liveProperties(spaceId) + if err != nil { + return err + } + spelledAs := func(key string) string { + if original, ok := spellings[key]; ok { + return original + } + return key + } + var issues []v2model.Issue + var removedCount int + var known []string + // primed lazily and at most once (§7.5a-2), and only when a key reaches + // the bundled arm at all + var removedBundled map[string]bool + for _, key := range sortedKeys(props) { + if propertyKeyExistsIn(entries, key) { + if !propertyKeyInstalledIn(entries, key) { + if removedBundled == nil { + if removedBundled, err = s.bundledRemovalSet(spaceId); err != nil { + return err + } + } + isRemoved, err := s.bundledPropertyRemoved(ctx, spaceId, entries, removedBundled, key) + if err != nil { + return err + } + if isRemoved { + issues = append(issues, removedPropertyIssue(spaceId, key, spelledAs(key), "/properties/"+spelledAs(key))) + removedCount++ + } + } + continue + } + if s.propertyKeyHeldByAnyRelation(ctx, spaceId, key) { + continue + } + if known == nil { + known = knownPropertyKeysIn(entries) + } + issues = append(issues, unknownPropertyIssue(key, "/properties/"+spelledAs(key), known, + fmt.Sprintf("list all with GET /v2/spaces/%s/properties, or create it with POST /v2/spaces/%s/properties", spaceId, spaceId))) + } + if len(issues) > 0 { + // the envelope names what actually happened: "unknown" on a key the + // space knows and removed is a lie the issue text then contradicts + switch { + case removedCount == len(issues): + return v2model.ValidationFailed("removed property keys", issues...) + case removedCount > 0: + return v2model.ValidationFailed("unknown and removed property keys", issues...) + } + return v2model.ValidationFailed("unknown property keys", issues...) + } + return nil +} + +// rejectRestrictedType blocks creation of system-managed types through the +// generic object path (mirrors createObjectInSpace's guards). +func rejectRestrictedType(typeKey string) error { + key := domain.TypeKey(typeKey) + if t, err := bundle.GetType(key); err == nil && t.RestrictObjectCreation { + return v2model.ValidationFailed("this type cannot be created through the API", + v2model.Issue{Path: "/type", Message: fmt.Sprintf("creation of %q objects is restricted", typeKey)}) + } + switch key { + case bundle.TypeKeyFile, bundle.TypeKeyImage, bundle.TypeKeyAudio, bundle.TypeKeyVideo: + return v2model.ValidationFailed("file objects are created by upload", + v2model.Issue{Path: "/type", Message: fmt.Sprintf("%q objects come from file uploads", typeKey), Hint: "POST /v2/spaces/{space_id}/files"}) + } + return nil +} + +// sortedKeys returns the map's keys in deterministic order. +func sortedKeys[V any](m map[string]V) []string { + keys := make([]string, 0, len(m)) + for k := range m { + keys = append(keys, k) + } + sort.Strings(keys) + return keys +} diff --git a/core/api/v2/service/create_test.go b/core/api/v2/service/create_test.go new file mode 100644 index 0000000000..4f75d9b425 --- /dev/null +++ b/core/api/v2/service/create_test.go @@ -0,0 +1,544 @@ +package v2service + +import ( + "context" + "encoding/json" + "net/http" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + + apicore "github.com/anyproto/anytype-heart/core/api/core" + v2model "github.com/anyproto/anytype-heart/core/api/v2/model" + "github.com/anyproto/anytype-heart/core/domain" + "github.com/anyproto/anytype-heart/pb" + "github.com/anyproto/anytype-heart/pkg/lib/anyblockjson" + "github.com/anyproto/anytype-heart/pkg/lib/bundle" + "github.com/anyproto/anytype-heart/pkg/lib/localstore/objectstore" + "github.com/anyproto/anytype-heart/pkg/lib/pb/model" + "github.com/anyproto/anytype-heart/util/pbtypes" +) + +// addSelectProperty registers a select property "severity" with one existing +// option "High" in the test space. +func (fx *v2Fixture) addSelectProperty(t *testing.T) { + fx.objectStore.AddObjects(t, testSpaceId, []objectstore.TestObject{ + { + bundle.RelationKeyId: domain.String("rel-severity"), + bundle.RelationKeyRelationKey: domain.String("severity"), + bundle.RelationKeyName: domain.String("Severity"), + bundle.RelationKeyRelationFormat: domain.Int64(int64(model.RelationFormat_status)), + bundle.RelationKeyResolvedLayout: domain.Int64(int64(model.ObjectType_relation)), + }, + { + bundle.RelationKeyId: domain.String("opt-high"), + bundle.RelationKeyRelationKey: domain.String("severity"), + bundle.RelationKeyName: domain.String("High"), + bundle.RelationKeyResolvedLayout: domain.Int64(int64(model.ObjectType_relationOption)), + }, + }) +} + +// addTagProperty registers a multiSelect property "tags" with two existing +// options "Urgent" and "Later" in the test space. +func (fx *v2Fixture) addTagProperty(t *testing.T) { + fx.objectStore.AddObjects(t, testSpaceId, []objectstore.TestObject{ + { + bundle.RelationKeyId: domain.String("rel-tags"), + bundle.RelationKeyRelationKey: domain.String("tags"), + bundle.RelationKeyName: domain.String("Tags"), + bundle.RelationKeyRelationFormat: domain.Int64(int64(model.RelationFormat_tag)), + bundle.RelationKeyResolvedLayout: domain.Int64(int64(model.ObjectType_relation)), + }, + { + bundle.RelationKeyId: domain.String("opt-urgent"), + bundle.RelationKeyRelationKey: domain.String("tags"), + bundle.RelationKeyName: domain.String("Urgent"), + bundle.RelationKeyResolvedLayout: domain.Int64(int64(model.ObjectType_relationOption)), + }, + { + bundle.RelationKeyId: domain.String("opt-later"), + bundle.RelationKeyRelationKey: domain.String("tags"), + bundle.RelationKeyName: domain.String("Later"), + bundle.RelationKeyResolvedLayout: domain.Int64(int64(model.ObjectType_relationOption)), + }, + }) +} + +// expectCreate captures the snapshot handed to the creator and returns id. +func (fx *v2Fixture) expectCreate(id string) **model.SmartBlockSnapshotBase { + var captured *model.SmartBlockSnapshotBase + fx.creatorMock.EXPECT().CreateObjectFromSnapshot(mock.Anything, testSpaceId, mock.Anything). + RunAndReturn(func(ctx context.Context, spaceId string, snapshot *model.SmartBlockSnapshotBase) (string, error) { + captured = snapshot + return id, nil + }) + return &captured +} + +func (fx *v2Fixture) expectEtagRead(objectId string) { + fx.readerMock.EXPECT().ReadObject(mock.Anything, testSpaceId, objectId). + Return(apicore.ObjectRead{Heads: []string{"headX"}}, nil) +} + +func v2Err(t *testing.T, err error) *v2model.Error { + t.Helper() + var apiErr *v2model.Error + require.ErrorAs(t, err, &apiErr) + return apiErr +} + +func TestV2CreateObjectShortcut(t *testing.T) { + t.Run("shortcut creates a typed object with name", func(t *testing.T) { + // given + fx := newV2Fixture(t) + captured := fx.expectCreate("newObj") + fx.expectEtagRead("newObj") + + // when + result, err := fx.CreateObject(context.Background(), testSpaceId, + []byte(`{"type":"task","name":"Buy milk"}`), false, true) + + // then + require.NoError(t, err) + assert.Equal(t, "newObj", result.Id) + assert.Equal(t, "task", result.Type) + assert.Equal(t, ComputeEtag([]string{"headX"}), result.Etag) + require.NotNil(t, *captured) + snapshot := *captured + assert.Equal(t, []string{"ot-task"}, snapshot.ObjectTypes) + assert.Equal(t, "Buy milk", pbtypes.GetString(snapshot.Details, "name")) + }) + + t.Run("markdown is parsed into the create snapshot (one change set)", func(t *testing.T) { + // given + fx := newV2Fixture(t) + captured := fx.expectCreate("newObj") + fx.expectEtagRead("newObj") + + // when — no BlockCreate/BlockPaste expectations: the paste path is gone + result, err := fx.CreateObject(context.Background(), testSpaceId, + []byte(`{"type":"page","name":"Doc","markdown":"# Hello\n\n- [ ] first task"}`), false, true) + + // then + require.NoError(t, err) + assert.Equal(t, "newObj", result.Id) + snapshot := *captured + require.NotNil(t, snapshot) + var heading, checkbox *model.Block + for _, b := range snapshot.Blocks { + if text := b.GetText(); text != nil { + switch { + case text.Style == model.BlockContentText_Header1 && text.Text == "Hello": + heading = b + case text.Style == model.BlockContentText_Checkbox && text.Text == "first task": + checkbox = b + } + } + } + require.NotNil(t, heading, "markdown heading must be part of the create snapshot") + require.NotNil(t, checkbox, "markdown checkbox must be part of the create snapshot") + }) + + t.Run("dry run validates the markdown body too", func(t *testing.T) { + // given + fx := newV2Fixture(t) + + // when + result, err := fx.CreateObject(context.Background(), testSpaceId, + []byte(`{"type":"page","name":"Doc","markdown":"- [x] done item"}`), true, true) + + // then + require.NoError(t, err) + assert.True(t, result.DryRun) + assert.Empty(t, result.Id) + assert.Empty(t, result.Warnings, "the two-change-set dry-run caveat warning is gone") + }) + + t.Run("whitespace-only markdown is rejected, not a silent empty create", func(t *testing.T) { + // given: no create expectation — nothing may reach the creator + fx := newV2Fixture(t) + + // when + _, err := fx.CreateObject(context.Background(), testSpaceId, + []byte(`{"type":"page","name":"Doc","markdown":" \n\t\n"}`), false, true) + + // then: the same contract as the insert_blocks markdown channel (C6) + apiErr := v2Err(t, err) + assert.Equal(t, v2model.CodeValidationFailed, apiErr.Code) + assert.Equal(t, "markdown produced no blocks", apiErr.Message) + require.Len(t, apiErr.Issues, 1) + assert.Equal(t, "/markdown", apiErr.Issues[0].Path) + assert.Contains(t, apiErr.Issues[0].Message, "give at least one non-blank line") + }) + + t.Run("markdown over the create block cap is rejected with the limit", func(t *testing.T) { + // given: 3 bytes per block would reach ~350k blocks in 1 MiB without + // the parsed-run cap; no create expectation — nothing may be built + fx := newV2Fixture(t) + body, err := json.Marshal(map[string]any{ + "type": "page", "name": "Doc", + "markdown": strings.Repeat("- x\n", v2MaxCreateMarkdownBlocks+1), + }) + require.NoError(t, err) + + // when + _, err = fx.CreateObject(context.Background(), testSpaceId, body, false, true) + + // then + apiErr := v2Err(t, err) + assert.Equal(t, "markdown produced too many blocks", apiErr.Message) + require.Len(t, apiErr.Issues, 1) + assert.Equal(t, "/markdown", apiErr.Issues[0].Path) + assert.Contains(t, apiErr.Issues[0].Message, "2048") + assert.Contains(t, apiErr.Issues[0].Message, "insert_blocks") + }) + + t.Run("markdown-derived issue paths readdress /blocks to /markdown", func(t *testing.T) { + // the caller sent markdown, never a blocks array — a /blocks path into + // the synthesized document is unactionable (C6) + err := rebaseMarkdownCreateError(v2model.ValidationFailed("the document failed AnyBlock validation", + v2model.Issue{Path: "/blocks/1", Message: "nested under a divider block"}, + v2model.Issue{Path: "/blocks/3/text", Message: "too long"}, + v2model.Issue{Path: "/type", Message: "untouched"})) + apiErr := v2Err(t, err) + assert.Equal(t, "/markdown[1]", apiErr.Issues[0].Path) + assert.Equal(t, "/markdown[3]/text", apiErr.Issues[1].Path) + assert.Equal(t, "/type", apiErr.Issues[2].Path) + }) + + t.Run("unknown shortcut key steers to the full document", func(t *testing.T) { + // given + fx := newV2Fixture(t) + + // when + _, err := fx.CreateObject(context.Background(), testSpaceId, + []byte(`{"type":"task","title":"oops"}`), false, true) + + // then + apiErr := v2Err(t, err) + assert.Equal(t, v2model.CodeValidationFailed, apiErr.Code) + require.Len(t, apiErr.Issues, 1) + assert.Equal(t, "/title", apiErr.Issues[0].Path) + assert.Contains(t, apiErr.Issues[0].Hint, `"version": 1`) + }) + + t.Run("missing type is rejected", func(t *testing.T) { + // given + fx := newV2Fixture(t) + + // when + _, err := fx.CreateObject(context.Background(), testSpaceId, []byte(`{"name":"x"}`), false, true) + + // then + apiErr := v2Err(t, err) + assert.Equal(t, v2model.CodeValidationFailed, apiErr.Code) + require.Len(t, apiErr.Issues, 1) + assert.Equal(t, "/type", apiErr.Issues[0].Path) + }) +} + +func TestV2CreateObjectDocument(t *testing.T) { + t.Run("a pasted read body creates a copy — etag and warnings are stripped", func(t *testing.T) { + // reproduced live before the fix: POST /objects 400ed on the etag of + // every GET shape — "read a document, create a copy" required + // knowing which envelope fields to hand-strip. + fx := newV2Fixture(t) + captured := fx.expectCreate("cloneObj") + fx.expectEtagRead("cloneObj") + body := `{"version":1,"etag":"abcd1234","id":"sourceObj","type":"page",` + + `"warnings":[{"message":"from the read"}],` + + `"blocks":[{"id":"blockHeading1","type":"heading_1","text":"Section"}]}` + + result, err := fx.CreateObject(context.Background(), testSpaceId, []byte(body), false, true) + + require.NoError(t, err, "a GET body must clone without hand-stripping envelope fields") + assert.Equal(t, "cloneObj", result.Id) + assert.Empty(t, result.Warnings, "the read's warnings are the source's, not the clone's") + require.NotNil(t, *captured) + }) + + t.Run("label-shaped block ids on create ride a warning", func(t *testing.T) { + // a clone from a DEFAULT-shape read adopts the compact labels as the + // new object's real ids — legal (the clone has no other id holders), + // but almost never intended, so the adoption is named + fx := newV2Fixture(t) + fx.expectCreate("cloneObj") + fx.expectEtagRead("cloneObj") + body := `{"version":1,"type":"page","blocks":[` + + `{"id":"aaaa1","type":"paragraph","text":"x"},` + + `{"id":"keeper","type":"paragraph","text":"y"},` + + `{"id":"tblOne1","type":"table","columns":[{"id":"colA"}],` + + `"rows":[{"id":"rowA","cells":[[{"type":"toggle","text":"cell"},` + + `{"indent":1,"id":"bbbb1","type":"paragraph","text":"inside"}]]}]}]}` + + result, err := fx.CreateObject(context.Background(), testSpaceId, []byte(body), false, true) + + require.NoError(t, err) + require.Len(t, result.Warnings, 1) + assert.Contains(t, result.Warnings[0].Message, `"aaaa1"`) + assert.Contains(t, result.Warnings[0].Message, `"bbbb1"`, "a label inside a cell's descendants is flagged too") + assert.NotContains(t, result.Warnings[0].Message, `"keeper"`, "only label-shaped ids are flagged") + assert.Contains(t, result.Warnings[0].Hint, "?ids=full") + }) + + t.Run("a ?block= subtree read cannot be cloned (partial marker)", func(t *testing.T) { + // a ?block= read is a fragment of another document: schema-valid on + // its face, so the envelope carries an explicit partial marker and + // create names it, the way the equally partial outline is named. + fx := newV2Fixture(t) + fx.readerMock.EXPECT().ReadObject(mock.Anything, testSpaceId, "obj1").Return(editRead(t, editBaseDoc), nil) + subtree, _, err := fx.GetObject(context.Background(), testSpaceId, "obj1", ObjectQuery{Block: "blockParent1"}) + require.NoError(t, err) + assert.Contains(t, string(subtree), `"subtree":true`, "the subtree envelope carries the partial marker") + require.Error(t, anyblockjson.Validate(subtree), + "the partial envelope must not validate as a whole document (the way outline does not)") + + _, err = fx.CreateObject(context.Background(), testSpaceId, subtree, false, true) + + apiErr := v2Err(t, err) + require.NotEmpty(t, apiErr.Issues) + assert.Equal(t, "/subtree", apiErr.Issues[0].Path) + }) + + t.Run("full document creates with blocks", func(t *testing.T) { + // given + fx := newV2Fixture(t) + captured := fx.expectCreate("newObj") + fx.expectEtagRead("newObj") + + // when + result, err := fx.CreateObject(context.Background(), testSpaceId, + []byte(`{"version":1,"type":"page","properties":{"name":"Doc"},"blocks":[{"type":"paragraph","text":"hi"}]}`), false, true) + + // then + require.NoError(t, err) + assert.Equal(t, "newObj", result.Id) + snapshot := *captured + require.NotNil(t, snapshot) + require.Len(t, snapshot.Blocks, 2, "root + paragraph") + assert.NotNil(t, snapshot.Blocks[0].GetSmartblock()) + assert.Equal(t, "hi", snapshot.Blocks[1].GetText().GetText()) + }) + + t.Run("absent type defaults to page", func(t *testing.T) { + // given + fx := newV2Fixture(t) + captured := fx.expectCreate("newObj") + fx.expectEtagRead("newObj") + + // when + result, err := fx.CreateObject(context.Background(), testSpaceId, + []byte(`{"version":1,"blocks":[{"type":"paragraph","text":"hi"}]}`), false, true) + + // then + require.NoError(t, err) + assert.Equal(t, "page", result.Type) + assert.Equal(t, []string{"ot-page"}, (*captured).ObjectTypes) + }) + + t.Run("unknown type key gets a did-you-mean 400", func(t *testing.T) { + // given + fx := newV2Fixture(t) + fx.objectStore.AddObjects(t, testSpaceId, []objectstore.TestObject{{ + bundle.RelationKeyId: domain.String("type-recipe"), + bundle.RelationKeyName: domain.String("Recipe"), + bundle.RelationKeyUniqueKey: domain.String("ot-recipe"), + bundle.RelationKeyResolvedLayout: domain.Int64(int64(model.ObjectType_objectType)), + }}) + + // when + _, err := fx.CreateObject(context.Background(), testSpaceId, + []byte(`{"version":1,"type":"recipes","blocks":[]}`), false, true) + + // then + apiErr := v2Err(t, err) + assert.Equal(t, v2model.CodeValidationFailed, apiErr.Code) + require.Len(t, apiErr.Issues, 1) + assert.Equal(t, "/type", apiErr.Issues[0].Path) + assert.Contains(t, apiErr.Issues[0].Message, "recipe") + assert.Contains(t, apiErr.Issues[0].Hint, "recipe") + }) + + t.Run("unknown property key is rejected, never silently created", func(t *testing.T) { + // given + fx := newV2Fixture(t) + + // when + _, err := fx.CreateObject(context.Background(), testSpaceId, + []byte(`{"version":1,"type":"page","properties":{"name":"ok","madeUpProp":"x"}}`), false, true) + + // then + apiErr := v2Err(t, err) + assert.Equal(t, v2model.CodeValidationFailed, apiErr.Code) + require.Len(t, apiErr.Issues, 1) + assert.Equal(t, "/properties/madeUpProp", apiErr.Issues[0].Path) + assert.Contains(t, apiErr.Issues[0].Hint, "POST /v2/spaces/"+testSpaceId+"/properties") + }) + + t.Run("structurally invalid document returns path-addressed issues", func(t *testing.T) { + // given + fx := newV2Fixture(t) + + // when + _, err := fx.CreateObject(context.Background(), testSpaceId, + []byte(`{"version":1,"type":"page","blocks":[{"type":"wat"}]}`), false, true) + + // then + apiErr := v2Err(t, err) + assert.Equal(t, v2model.CodeValidationFailed, apiErr.Code) + require.NotEmpty(t, apiErr.Issues) + assert.Contains(t, apiErr.Issues[0].Path, "/blocks/0") + }) + + t.Run("newer format version is version_unsupported", func(t *testing.T) { + // given + fx := newV2Fixture(t) + + // when + _, err := fx.CreateObject(context.Background(), testSpaceId, + []byte(`{"version":2,"type":"page"}`), false, true) + + // then + apiErr := v2Err(t, err) + assert.Equal(t, v2model.CodeVersionUnsupported, apiErr.Code) + assert.Contains(t, apiErr.Message, "document version 2") + assert.Contains(t, apiErr.Message, "supported version 1") + }) + + t.Run("restricted type cannot be created", func(t *testing.T) { + // given + fx := newV2Fixture(t) + + // when + _, err := fx.CreateObject(context.Background(), testSpaceId, + []byte(`{"version":1,"type":"participant"}`), false, true) + + // then + apiErr := v2Err(t, err) + assert.Equal(t, v2model.CodeValidationFailed, apiErr.Code) + }) + + t.Run("object_type kind steers to POST types", func(t *testing.T) { + // given + fx := newV2Fixture(t) + + // when + _, err := fx.CreateObject(context.Background(), testSpaceId, + []byte(`{"version":1,"kind":"object_type","type_settings":{"api_key":"thing","property_definitions":[]}}`), false, true) + + // then + apiErr := v2Err(t, err) + require.Len(t, apiErr.Issues, 1) + assert.Contains(t, apiErr.Issues[0].Hint, "/types") + }) + + t.Run("items on a non-collection document is rejected", func(t *testing.T) { + // given + fx := newV2Fixture(t) + + // when + _, err := fx.CreateObject(context.Background(), testSpaceId, + []byte(`{"version":1,"type":"page","items":["obj1"]}`), false, true) + + // then + apiErr := v2Err(t, err) + require.Len(t, apiErr.Issues, 1) + assert.Equal(t, "/items", apiErr.Issues[0].Path) + }) + + t.Run("unknown select option names are created (SPEC §3)", func(t *testing.T) { + // given + fx := newV2Fixture(t) + fx.addSelectProperty(t) + captured := fx.expectCreate("newObj") + fx.expectEtagRead("newObj") + fx.mwMock.EXPECT().ObjectCreateRelationOption(mock.Anything, mock.MatchedBy(func(req *pb.RpcObjectCreateRelationOptionRequest) bool { + return req.SpaceId == testSpaceId && + pbtypes.GetString(req.Details, bundle.RelationKeyRelationKey.String()) == "severity" && + pbtypes.GetString(req.Details, bundle.RelationKeyName.String()) == "Blocker" + })).Return(&pb.RpcObjectCreateRelationOptionResponse{ + ObjectId: "opt-blocker", + Error: &pb.RpcObjectCreateRelationOptionResponseError{Code: pb.RpcObjectCreateRelationOptionResponseError_NULL}, + }) + + // when + result, err := fx.CreateObject(context.Background(), testSpaceId, + []byte(`{"version":1,"type":"task","properties":{"severity":["High","Blocker"]}}`), false, true) + + // then + require.NoError(t, err) + require.NotNil(t, result.Created) + assert.Equal(t, []v2model.CreatedOption{{Property: "severity", Name: "Blocker"}}, result.Created.Options) + values := pbtypes.GetStringList((*captured).Details, "severity") + assert.Equal(t, []string{"opt-high", "opt-blocker"}, values, "existing option resolved, missing one created") + }) + + t.Run("dry run creates nothing and reports would-be side effects", func(t *testing.T) { + // given: no creator or RPC expectations — any call would fail the test + fx := newV2Fixture(t) + fx.addSelectProperty(t) + + // when + result, err := fx.CreateObject(context.Background(), testSpaceId, + []byte(`{"version":1,"type":"task","properties":{"severity":["Blocker"]}}`), true, true) + + // then + require.NoError(t, err) + assert.True(t, result.DryRun) + assert.Empty(t, result.Id) + require.NotNil(t, result.Created) + assert.Equal(t, []v2model.CreatedOption{{Property: "severity", Name: "Blocker"}}, result.Created.Options) + }) +} + +func TestV2CreateTemplate(t *testing.T) { + t.Run("template resolves templateFor into targetObjectType", func(t *testing.T) { + // given + fx := newV2Fixture(t) + captured := fx.expectCreate("newTemplate") + fx.expectEtagRead("newTemplate") + // the fixture's derived-id stub answers TypeIdByKey ("drv-ot-task") — + // a per-test override cannot shadow it (see newV2FixtureBare) + + // when + result, err := fx.CreateTemplate(context.Background(), testSpaceId, + []byte(`{"version":1,"type":"template","template_for":"task","properties":{"name":"Weekly"}}`), false, true) + + // then + require.NoError(t, err) + assert.Equal(t, "newTemplate", result.Id) + snapshot := *captured + assert.Equal(t, []string{"ot-template", "ot-task"}, snapshot.ObjectTypes) + assert.Equal(t, "drv-ot-task", pbtypes.GetString(snapshot.Details, bundle.RelationKeyTargetObjectType.String())) + }) + + t.Run("templateFor is required", func(t *testing.T) { + // given + fx := newV2Fixture(t) + + // when + _, err := fx.CreateTemplate(context.Background(), testSpaceId, + []byte(`{"version":1,"type":"template","properties":{"name":"Weekly"}}`), false, true) + + // then + apiErr := v2Err(t, err) + require.Len(t, apiErr.Issues, 1) + assert.Equal(t, "/template_for", apiErr.Issues[0].Path) + }) + + t.Run("unknown space is a 404", func(t *testing.T) { + // given + fx := newV2Fixture(t) + + // when + _, err := fx.CreateObject(context.Background(), "ghost", []byte(`{"type":"page"}`), false, true) + + // then + apiErr := v2Err(t, err) + assert.Equal(t, http.StatusNotFound, apiErr.Status) + }) +} diff --git a/core/api/v2/service/delete.go b/core/api/v2/service/delete.go new file mode 100644 index 0000000000..26fc14d192 --- /dev/null +++ b/core/api/v2/service/delete.go @@ -0,0 +1,219 @@ +package v2service + +// delete.go implements DELETE /v2/spaces/{space_id}/objects/{object_id} +// (plan 3.3, APIV2_OBJECT_DELETE.md): archive semantics (Bin, reversible in +// the app — v1 parity and v2 uniformity with DeleteType/DeleteProperty), +// gated by CREATOR PROVENANCE — deletion is permitted only for objects the +// calling API key created, read from validated change storage (§10), never +// from a detail. Fail-closed everywhere: no recorded key (every object that +// predates the stamp, every app/import/other-member creation) refuses for +// every caller — the settled §8 decision, not a gap — and any error or +// ambiguity in the enforcement read refuses, never allows. + +import ( + "context" + "fmt" + "net/http" + "strings" + + v2model "github.com/anyproto/anytype-heart/core/api/v2/model" + "github.com/anyproto/anytype-heart/core/domain" + "github.com/anyproto/anytype-heart/pb" + "github.com/anyproto/anytype-heart/pkg/lib/bundle" + "github.com/anyproto/anytype-heart/pkg/lib/pb/model" +) + +// deleteProbeIssue is the C6 issue every ownership refusal carries (§9.5): +// the probe idiom and where provenance is visible. +var deleteProbeIssue = v2model.Issue{ + Path: "object_id", + Hint: "probe deletability without writing via DELETE …?dry_run=true; " + + "the created_date/creator properties on GET show who created the object", +} + +// DeleteObject implements DELETE /v2/spaces/{space_id}/objects/{object_id} +// (§9.4). The authorization is a CONJUNCTION (§9.3): for scoped keys the +// space and write grants run first and unchanged; the creator check is in +// addition — a readwrite grant means "create and edit broadly, destroy only +// your own output". A dry run executes every check this route OWNS — +// existence, steer, allowlist, grant, provenance — and skips the archive +// (§9.6 — the deletability probe). Its contract, stated plainly: archive- +// time restriction checks (restriction.CheckRestrictions, CanDeleteFile) +// run inside the archive RPC only, so a dry run does NOT evaluate them — a +// "deletable" verdict can still meet a 403 on the real call. Deliberate: +// those checks have no read-only surface, and after the allowlist (which +// excludes every restriction-carrying system type) and the provenance +// clause (own-account creations only) they are all but unreachable. +func (s *Service) DeleteObject(ctx context.Context, spaceId, objectId string, dryRun bool) (*v2model.CreateResult, error) { + if err := s.ensureSpaceWrite(ctx, spaceId); err != nil { + return nil, err + } + + // resolve via the live read (the v2 house read path): unknown tree → 404 + read, err := s.reader.ReadObject(ctx, spaceId, objectId) + if err != nil { + return nil, mapReadError(spaceId, objectId, err) + } + // a tombstoned row (deleted derived object whose tree survives) is gone + // as far as the API is concerned + row, rowErr := s.store.SpaceIndex(spaceId).GetDetails(objectId) + if rowErr == nil && row.GetBool(bundle.RelationKeyIsDeleted) { + return nil, v2model.NotFound(fmt.Sprintf("object %q not found in space %q", objectId, spaceId)) + } + + // schema objects have their own delete routes with their own semantics — + // steer, do not sort-of-serve (§9.4-3) + if err := steerSchemaDelete(read.SbType, spaceId); err != nil { + return nil, err + } + // POSITIVE user-content allowlist, BEFORE the provenance read: anything + // outside it refuses regardless of what provenance would say. This is + // load-bearing, not belt-and-braces — "derived trees can never pass the + // root clause" is FALSE: derivePersonalPayload signs derived roots with + // the account identity whenever personalSpaceId == space.Id() or + // UseAccountSignature (objectcache/tree.go:81), and objectcreator sets + // UseAccountSignature for EVERY FileObject (smartblock.go:143) — so a + // derived system object can show accountMatch=true, and a derived tree + // whose root exists locally with no content change yet could even take + // its first content change (stamp included) from an API request + // (cacheLoad sets IsNewObject unconditionally; smartBlock.Init refuses + // only a non-empty doc). Provenance answers "whose is it", never "is + // this deletable content" — this list answers that. + if !deletableSbType(read.SbType) { + return nil, v2model.NewError(http.StatusForbidden, v2model.CodeForbidden, + fmt.Sprintf("%s objects are not deletable through the API — DELETE serves user content only (pages, templates, files, chats). "+ + "This is a system or derived surface; manage it in the Anytype app.", read.SbType.String()), + v2model.Issue{Path: "object_id", Message: fmt.Sprintf("object %s is a %s", objectId, read.SbType.String())}) + } + + // the provenance conjunction (§10): both clauses from validated storage + if err := s.checkDeleteProvenance(ctx, spaceId, objectId); err != nil { + return nil, err + } + + result := &v2model.CreateResult{Id: objectId, Type: objectTypeKey(read)} + if dryRun { + result.DryRun = true + } + // already archived → idempotent no-op receipt, consistent with C8 retries + if rowErr == nil && row.GetBool(bundle.RelationKeyIsArchived) { + result.Warnings = append(result.Warnings, v2model.Issue{Message: "already archived"}) + return result, nil + } + if dryRun { + return result, nil + } + + resp := s.mw.ObjectSetIsArchived(ctx, &pb.RpcObjectSetIsArchivedRequest{ContextId: objectId, IsArchived: true}) + if resp.Error != nil && resp.Error.Code != pb.RpcObjectSetIsArchivedResponseError_NULL { + // a permanent refusal is a 403, not a retry-shaped 500 (the + // mapWriteError lesson, M2a). The RPC serves only UNKNOWN_ERROR plus + // a description, so the match is textual — and there are TWO + // permanent shapes behind it, not one: restriction.ErrRestricted + // ("restricted") and fileobject CanDeleteFile's + // "can't delete other's file" (fileobject/service.go). The first + // build matched only the former; the review (F4) executed the + // latter into a 500 two lines under the M2a citation. + desc := resp.Error.Description + if strings.Contains(desc, "restricted") || strings.Contains(desc, "can't delete other's file") { + return nil, restrictionForbidden(objectId, fmt.Errorf("%s", desc)) + } + return nil, fmt.Errorf("archive object %s: %s", objectId, desc) + } + return result, nil +} + +// deletableSbType is the positive allowlist of what object-DELETE may ever +// archive: user content only. Derived from the creation surface — +// objectTypeKeysToSmartBlockType (core/block/object/objectcreator/ +// smartblock.go) produces exactly Page (every layout-based object: pages, +// notes, tasks, bookmarks, sets, collections), Template, FileObject and the +// store-backed chat shapes (ChatDerivedObject, DiscussionObject) as user +// content; the schema trio it also produces is steered to its own routes +// before this check. Everything else — Workspace, Archive, Home, Widget, +// SpaceView, Participant, Profile, Date, the deprecated chat container, +// tech-space shapes — is a system surface and refuses here, whatever its +// root signature looks like. Note the chat shapes are allowlisted as user +// content but still refuse at the provenance read today (their changes are +// StoreChange, which carries no stamp): the list states the product +// surface, provenance stays fail-closed. +func deletableSbType(sbType model.SmartBlockType) bool { + switch sbType { + case model.SmartBlockType_Page, + model.SmartBlockType_Template, + model.SmartBlockType_FileObject, + model.SmartBlockType_ChatDerivedObject, + model.SmartBlockType_DiscussionObject: + return true + } + return false +} + +// steerSchemaDelete refuses type/property/tag-option targets with the route +// that owns their deletion — a steer is more useful than the allowlist's +// generic refusal, so it runs first. Participants, space views and other +// system objects are not steered: the deletableSbType allowlist refuses +// them, and the message names the app as the repair. +func steerSchemaDelete(sbType model.SmartBlockType, spaceId string) error { + switch sbType { + case model.SmartBlockType_STType: + return v2model.ValidationFailed("types are deleted through their own route", + v2model.Issue{Path: "object_id", + Message: "this object is a type", + Hint: fmt.Sprintf("use DELETE /v2/spaces/%s/types/{typeKey}", spaceId)}) + case model.SmartBlockType_STRelation: + return v2model.ValidationFailed("properties are deleted through their own route", + v2model.Issue{Path: "object_id", + Message: "this object is a property", + Hint: fmt.Sprintf("use DELETE /v2/spaces/%s/properties/{property_key}", spaceId)}) + case model.SmartBlockType_STRelationOption: + return v2model.ValidationFailed("tag options are managed through their property", + v2model.Issue{Path: "object_id", + Message: "this object is a select/multiSelect option", + Hint: fmt.Sprintf("options are edited via their property — see GET /v2/spaces/%s/properties/{property_key}/options", spaceId)}) + } + return nil +} + +// checkDeleteProvenance evaluates the §10 conjunction and words the §9.5 +// refusal variants — each names what IS recorded, so the repair is +// discoverable. Fail-closed: a nil provenance dependency and every read +// error refuse. +func (s *Service) checkDeleteProvenance(ctx context.Context, spaceId, objectId string) error { + if s.provenance == nil { + return fmt.Errorf("delete refused: creator-provenance reader not configured") + } + accountMatch, recorded, err := s.provenance.CreatorProvenance(ctx, spaceId, objectId) + if err != nil { + return fmt.Errorf("read creator provenance for %s: %w", objectId, err) + } + if !accountMatch { + return v2model.NotCreatedByThisKey( + "DELETE is limited to objects this API key created, and this object was created by another space member or by the system. "+ + "Ask its creator, or archive it in the Anytype app if your role permits.", deleteProbeIssue) + } + if recorded == "" { + return v2model.NotCreatedByThisKey( + "DELETE is limited to objects this API key created, and no API key is recorded as this object's creator "+ + "(created by the Anytype app or before provenance existed). To remove it, archive it in the Anytype app.", deleteProbeIssue) + } + caller := domain.IntegrationNameFromCtx(ctx) + if caller == "" { + // a nameless key can never match a recorded name (§5): name the repair + return v2model.NotCreatedByThisKey(fmt.Sprintf( + "DELETE is limited to objects this API key created, and this key has no recorded app name to compare against the recorded creator (%q). "+ + "Re-pair the app under that name, or archive the object in the Anytype app.", recorded), deleteProbeIssue) + } + // EXACT comparison of raw names, deliberately (§5): the former slug + // normalization was many-to-one ("Claude/Desktop" could archive "Claude + // Desktop"'s output) and lossy (a non-Latin name slugged to "" and its + // key could delete nothing). The tolerance normalization bought — + // re-pairing under a case-variant name still matching — is given up: + // for an authorization comparison, tolerance is a liability. + if recorded != caller { + return v2model.NotCreatedByThisKey(fmt.Sprintf( + "DELETE is limited to objects this API key created: this object was created via %q, not via this key (%q). "+ + "App names are compared exactly. Use a key paired as %q, or archive it in the Anytype app.", recorded, caller, recorded), deleteProbeIssue) + } + return nil +} diff --git a/core/api/v2/service/delete_test.go b/core/api/v2/service/delete_test.go new file mode 100644 index 0000000000..4a98c9699c --- /dev/null +++ b/core/api/v2/service/delete_test.go @@ -0,0 +1,452 @@ +package v2service + +// DELETE /v2/spaces/{space_id}/objects/{object_id} service tests +// (APIV2_OBJECT_DELETE.md §15). The fixture discipline: deleteFixture wires +// the FULL allow shape — live object, store row, matching provenance, +// matching caller — and every refusal case flips exactly ONE input. The +// success case is what makes each refusal meaningful: a fixture that can +// only refuse cannot distinguish "refused because legacy" from "refused +// because the check is broken". The ClientCommands mock is strict, so any +// path that archives when it must not (an error path falling through to the +// RPC) fails on the unexpected call, not silently. + +import ( + "context" + "errors" + "net/http" + "testing" + + "github.com/anyproto/any-sync/commonspace/object/tree/treestorage" + "github.com/gogo/protobuf/types" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + + apicore "github.com/anyproto/anytype-heart/core/api/core" + "github.com/anyproto/anytype-heart/core/api/util" + v2model "github.com/anyproto/anytype-heart/core/api/v2/model" + "github.com/anyproto/anytype-heart/core/domain" + "github.com/anyproto/anytype-heart/pb" + "github.com/anyproto/anytype-heart/pkg/lib/bundle" + "github.com/anyproto/anytype-heart/pkg/lib/localstore/objectstore" + "github.com/anyproto/anytype-heart/pkg/lib/pb/model" + "github.com/anyproto/anytype-heart/util/pbtypes" +) + +const deleteObjId = "objDel1" + +// callerCtx is the request context the auth middleware produces for a key +// named "Claude Desktop" — the RAW name, exactly as the app link records it. +func callerCtx() context.Context { + return domain.CtxWithIntegrationName(context.Background(), "Claude Desktop") +} + +// deleteRead is the live read of an ordinary deletable page. +func deleteRead(sbType model.SmartBlockType) apicore.ObjectRead { + return apicore.ObjectRead{ + SbType: sbType, + Snapshot: &model.SmartBlockSnapshotBase{ + Details: &types.Struct{Fields: map[string]*types.Value{ + "id": pbtypes.String(deleteObjId), + "name": pbtypes.String("Doomed"), + }}, + ObjectTypes: []string{"ot-page"}, + Blocks: []*model.Block{{Id: deleteObjId, Content: &model.BlockContentOfSmartblock{Smartblock: &model.BlockContentSmartblock{}}}}, + }, + Heads: []string{"headA"}, + } +} + +// newDeleteFixture wires the COMPLETE allow shape; tests then break one +// input each. recordedName parameterizes what provenance recorded — a RAW +// app name (§5: never a slug). +func newDeleteFixture(t *testing.T, accountMatch bool, recordedName string) *v2Fixture { + fx := newV2Fixture(t) + fx.readerMock.EXPECT().ReadObject(mock.Anything, testSpaceId, deleteObjId).Return(deleteRead(model.SmartBlockType_Page), nil).Maybe() + fx.provenanceMock.EXPECT().CreatorProvenance(mock.Anything, testSpaceId, deleteObjId).Return(accountMatch, recordedName, nil).Maybe() + fx.objectStore.AddObjects(t, testSpaceId, []objectstore.TestObject{{ + bundle.RelationKeyId: domain.String(deleteObjId), + bundle.RelationKeyName: domain.String("Doomed"), + bundle.RelationKeyResolvedLayout: domain.Int64(int64(model.ObjectType_basic)), + }}) + return fx +} + +func requireNotCreatedByThisKey(t *testing.T, err error) *v2model.Error { + t.Helper() + var v2Err *v2model.Error + require.ErrorAs(t, err, &v2Err) + assert.Equal(t, http.StatusForbidden, v2Err.Status) + assert.Equal(t, v2model.CodeNotCreatedByThisKey, v2Err.Code) + // every ownership refusal carries the §9.5 probe hint + require.NotEmpty(t, v2Err.Issues) + assert.Contains(t, v2Err.Issues[0].Hint, "dry_run=true") + return v2Err +} + +func TestDeleteObject(t *testing.T) { + t.Run("the allow shape archives", func(t *testing.T) { + // given: created by this account via this key — the ONE shape that + // may archive. This case is what gives every refusal below its + // meaning: the same fixture with one input flipped must refuse. + fx := newDeleteFixture(t, true, "Claude Desktop") + fx.mwMock.On("ObjectSetIsArchived", mock.Anything, &pb.RpcObjectSetIsArchivedRequest{ + ContextId: deleteObjId, IsArchived: true, + }).Return(&pb.RpcObjectSetIsArchivedResponse{ + Error: &pb.RpcObjectSetIsArchivedResponseError{Code: pb.RpcObjectSetIsArchivedResponseError_NULL}, + }).Once() + want := &v2model.CreateResult{Id: deleteObjId, Type: "page"} + + // when + got, err := fx.DeleteObject(callerCtx(), testSpaceId, deleteObjId, false) + + // then + require.NoError(t, err) + assert.Equal(t, want, got) + }) + + t.Run("unknown object is a 404", func(t *testing.T) { + fx := newV2Fixture(t) + fx.readerMock.EXPECT().ReadObject(mock.Anything, testSpaceId, "ghost").Return(apicore.ObjectRead{}, treestorage.ErrUnknownTreeId).Once() + + _, err := fx.DeleteObject(callerCtx(), testSpaceId, "ghost", false) + + var v2Err *v2model.Error + require.ErrorAs(t, err, &v2Err) + assert.Equal(t, http.StatusNotFound, v2Err.Status) + }) + + t.Run("a tombstoned row is a 404 even though its tree survives", func(t *testing.T) { + // given: the corpse shape — the live read still answers (derived + // trees survive deletion) but the row says isDeleted + fx := newDeleteFixture(t, true, "Claude Desktop") + fx.objectStore.AddObjects(t, testSpaceId, []objectstore.TestObject{{ + bundle.RelationKeyId: domain.String(deleteObjId), + bundle.RelationKeyIsDeleted: domain.Bool(true), + }}) + + _, err := fx.DeleteObject(callerCtx(), testSpaceId, deleteObjId, false) + + var v2Err *v2model.Error + require.ErrorAs(t, err, &v2Err) + assert.Equal(t, http.StatusNotFound, v2Err.Status) + }) + + t.Run("type and property targets are steered to their own routes", func(t *testing.T) { + // provenance mock deliberately has NO expectation here: the steer + // must fire BEFORE the provenance read, or a type created via this + // key would archive through the wrong route + cases := []struct { + sbType model.SmartBlockType + hint string + }{ + {model.SmartBlockType_STType, "/types/"}, + {model.SmartBlockType_STRelation, "/properties/"}, + {model.SmartBlockType_STRelationOption, "options"}, + } + for _, tc := range cases { + fx := newV2Fixture(t) + fx.readerMock.EXPECT().ReadObject(mock.Anything, testSpaceId, deleteObjId).Return(deleteRead(tc.sbType), nil).Once() + + _, err := fx.DeleteObject(callerCtx(), testSpaceId, deleteObjId, false) + + var v2Err *v2model.Error + require.ErrorAs(t, err, &v2Err) + assert.Equal(t, http.StatusBadRequest, v2Err.Status, tc.sbType.String()) + assert.Equal(t, v2model.CodeValidationFailed, v2Err.Code, tc.sbType.String()) + require.NotEmpty(t, v2Err.Issues, tc.sbType.String()) + assert.Contains(t, v2Err.Issues[0].Hint, tc.hint, tc.sbType.String()) + } + }) + + t.Run("system objects refuse whatever provenance would say — the F1 allowlist", func(t *testing.T) { + // The claim "derived trees can never pass the root clause" was false: + // derivePersonalPayload signs derived roots with the account identity + // (personal space; and UseAccountSignature = every FileObject), so + // provenance CAN answer accountMatch=true for system objects. These + // cases are deliberately NON-steered types — a test using only the + // steered trio would pass with no allowlist at all. No provenance + // expectation is set: reaching the read fails the test, which pins + // that the allowlist short-circuits BEFORE provenance; and under an + // allowlist revert the strict mocks fail on the unexpected calls. + for _, sbType := range []model.SmartBlockType{ + model.SmartBlockType_Workspace, + model.SmartBlockType_Archive, + model.SmartBlockType_Home, + model.SmartBlockType_Widget, + model.SmartBlockType_SpaceView, + model.SmartBlockType_Participant, + model.SmartBlockType_ProfilePage, + model.SmartBlockType_Date, + model.SmartBlockType_ChatObjectDeprecated, + } { + fx := newV2Fixture(t) + fx.readerMock.EXPECT().ReadObject(mock.Anything, testSpaceId, deleteObjId).Return(deleteRead(sbType), nil).Once() + + _, err := fx.DeleteObject(callerCtx(), testSpaceId, deleteObjId, false) + + var v2Err *v2model.Error + require.ErrorAs(t, err, &v2Err, sbType.String()) + assert.Equal(t, http.StatusForbidden, v2Err.Status, sbType.String()) + assert.Equal(t, v2model.CodeForbidden, v2Err.Code, sbType.String()) + assert.Contains(t, v2Err.Message, "user content only", sbType.String()) + } + }) + + t.Run("user-content types pass the allowlist — files and templates stay deletable", func(t *testing.T) { + // the other direction: an allowlist that quietly excluded FileObject + // (the very shape F1 found passing the root clause — account-signed + // derived roots) would break legitimate own-file deletion. Full + // provenance match → archive succeeds. + for _, sbType := range []model.SmartBlockType{ + model.SmartBlockType_FileObject, + model.SmartBlockType_Template, + } { + fx := newV2Fixture(t) + fx.readerMock.EXPECT().ReadObject(mock.Anything, testSpaceId, deleteObjId).Return(deleteRead(sbType), nil).Once() + fx.provenanceMock.EXPECT().CreatorProvenance(mock.Anything, testSpaceId, deleteObjId).Return(true, "Claude Desktop", nil).Once() + fx.objectStore.AddObjects(t, testSpaceId, []objectstore.TestObject{{ + bundle.RelationKeyId: domain.String(deleteObjId), + bundle.RelationKeyResolvedLayout: domain.Int64(int64(model.ObjectType_basic)), + }}) + fx.mwMock.On("ObjectSetIsArchived", mock.Anything, mock.Anything).Return(&pb.RpcObjectSetIsArchivedResponse{ + Error: &pb.RpcObjectSetIsArchivedResponseError{Code: pb.RpcObjectSetIsArchivedResponseError_NULL}, + }).Once() + + _, err := fx.DeleteObject(callerCtx(), testSpaceId, deleteObjId, false) + + require.NoError(t, err, sbType.String()) + } + }) + + t.Run("no recorded key refuses — the legacy/app/import shape", func(t *testing.T) { + // same fixture as the allow case, ONLY the recorded name removed — + // so this refusal is attributable to the missing record, not to a + // broken check (the fixture rule) + fx := newDeleteFixture(t, true, "") + + _, err := fx.DeleteObject(callerCtx(), testSpaceId, deleteObjId, false) + + v2Err := requireNotCreatedByThisKey(t, err) + assert.Contains(t, v2Err.Message, "no API key is recorded") + assert.Contains(t, v2Err.Message, "archive it in the Anytype app") + }) + + t.Run("a different integration's object refuses, naming both apps", func(t *testing.T) { + fx := newDeleteFixture(t, true, "Linear") + + _, err := fx.DeleteObject(callerCtx(), testSpaceId, deleteObjId, false) + + v2Err := requireNotCreatedByThisKey(t, err) + assert.Contains(t, v2Err.Message, `"Linear"`) + assert.Contains(t, v2Err.Message, `"Claude Desktop"`) + }) + + t.Run("names that normalize identically are DIFFERENT principals — the F2 regression", func(t *testing.T) { + // given: recorded "Claude Desktop", caller "Claude/Desktop". The old + // slug normalization collapsed both to claude-desktop, so a key + // paired under the visibly different name could archive this object + // end-to-end — a consent dialog showed the user one string while the + // system treated it as another principal. This fixture is the pair + // that proves exact match: two names ONE slug apart. A revert to + // normalized comparison archives (the strict mock fails on the + // unexpected RPC) instead of refusing. + fx := newDeleteFixture(t, true, "Claude Desktop") + ctx := domain.CtxWithIntegrationName(context.Background(), "Claude/Desktop") + + _, err := fx.DeleteObject(ctx, testSpaceId, deleteObjId, false) + + v2Err := requireNotCreatedByThisKey(t, err) + assert.Contains(t, v2Err.Message, `"Claude Desktop"`) + assert.Contains(t, v2Err.Message, `"Claude/Desktop"`) + assert.Contains(t, v2Err.Message, "compared exactly") + }) + + t.Run("a non-Latin app name can delete its own output — the F3 regression", func(t *testing.T) { + // given: recorded and caller both "日本語アプリ". The old normalization + // slugged every Cyrillic/CJK/emoji name to "", so such a key's + // objects were permanently unprovenanced and undeletable by their + // own creator, with no signal at pairing time. An all-ASCII fixture + // could never catch this; a revert that normalizes the caller name + // turns it empty and this DELETE refuses as "nameless". + fx := newDeleteFixture(t, true, "日本語アプリ") + fx.mwMock.On("ObjectSetIsArchived", mock.Anything, &pb.RpcObjectSetIsArchivedRequest{ + ContextId: deleteObjId, IsArchived: true, + }).Return(&pb.RpcObjectSetIsArchivedResponse{ + Error: &pb.RpcObjectSetIsArchivedResponseError{Code: pb.RpcObjectSetIsArchivedResponseError_NULL}, + }).Once() + ctx := domain.CtxWithIntegrationName(context.Background(), "日本語アプリ") + + got, err := fx.DeleteObject(ctx, testSpaceId, deleteObjId, false) + + require.NoError(t, err) + assert.Equal(t, &v2model.CreateResult{Id: deleteObjId, Type: "page"}, got) + }) + + t.Run("another member's object refuses", func(t *testing.T) { + fx := newDeleteFixture(t, false, "") + + _, err := fx.DeleteObject(callerCtx(), testSpaceId, deleteObjId, false) + + v2Err := requireNotCreatedByThisKey(t, err) + assert.Contains(t, v2Err.Message, "another space member") + }) + + t.Run("a nameless caller can never delete, even its own output", func(t *testing.T) { + // recorded provenance exists; the CALLER has no app name (§5 empty + // AppName) — flip is on the caller side only + fx := newDeleteFixture(t, true, "Claude Desktop") + + _, err := fx.DeleteObject(context.Background(), testSpaceId, deleteObjId, false) + + v2Err := requireNotCreatedByThisKey(t, err) + assert.Contains(t, v2Err.Message, "no recorded app name") + assert.Contains(t, v2Err.Message, `"Claude Desktop"`) + }) + + t.Run("a provenance read failure refuses and never archives", func(t *testing.T) { + // fail-closed on the ERROR path: the strict mw mock has no + // ObjectSetIsArchived expectation, so falling through to the archive + // fails the test on the unexpected call + fx := newV2Fixture(t) + fx.readerMock.EXPECT().ReadObject(mock.Anything, testSpaceId, deleteObjId).Return(deleteRead(model.SmartBlockType_Page), nil).Once() + fx.provenanceMock.EXPECT().CreatorProvenance(mock.Anything, testSpaceId, deleteObjId).Return(false, "", errors.New("storage failure")).Once() + + _, err := fx.DeleteObject(callerCtx(), testSpaceId, deleteObjId, false) + + require.Error(t, err) + var v2Err *v2model.Error + assert.False(t, errors.As(err, &v2Err), "an infrastructure failure is a 500, not a shaped refusal") + }) + + t.Run("a nil provenance dependency refuses and never archives", func(t *testing.T) { + fx := newV2Fixture(t) + fx.Service = NewService(fx.mwMock, fx.readerMock, fx.creatorMock, fx.mutatorMock, nil, fx.objectStore, objectstore.TestTechSpaceId, testAccountId) + fx.registerSpace(t, testSpaceId) + fx.readerMock.EXPECT().ReadObject(mock.Anything, testSpaceId, deleteObjId).Return(deleteRead(model.SmartBlockType_Page), nil).Once() + + _, err := fx.DeleteObject(callerCtx(), testSpaceId, deleteObjId, false) + + require.Error(t, err) + assert.Contains(t, err.Error(), "not configured") + }) + + t.Run("already archived is an idempotent 200 with a warning", func(t *testing.T) { + // no ObjectSetIsArchived expectation: the no-op must not re-archive + fx := newDeleteFixture(t, true, "Claude Desktop") + fx.objectStore.AddObjects(t, testSpaceId, []objectstore.TestObject{{ + bundle.RelationKeyId: domain.String(deleteObjId), + bundle.RelationKeyIsArchived: domain.Bool(true), + }}) + want := &v2model.CreateResult{ + Id: deleteObjId, Type: "page", + Warnings: []v2model.Issue{{Message: "already archived"}}, + } + + got, err := fx.DeleteObject(callerCtx(), testSpaceId, deleteObjId, false) + + require.NoError(t, err) + assert.Equal(t, want, got) + }) + + t.Run("dry run allowed: full verdict, no archive", func(t *testing.T) { + // C9's real-not-writing assertion: allowed verdict, and the strict + // mw mock proves nothing was written + fx := newDeleteFixture(t, true, "Claude Desktop") + want := &v2model.CreateResult{Id: deleteObjId, Type: "page", DryRun: true} + + got, err := fx.DeleteObject(callerCtx(), testSpaceId, deleteObjId, true) + + require.NoError(t, err) + assert.Equal(t, want, got) + }) + + t.Run("dry run refused: the same 403 as the real call", func(t *testing.T) { + fx := newDeleteFixture(t, true, "Linear") + + _, err := fx.DeleteObject(callerCtx(), testSpaceId, deleteObjId, true) + + requireNotCreatedByThisKey(t, err) + }) + + t.Run("the grant conjunction fires before provenance", func(t *testing.T) { + // scoped-key ordering (§9.3): space_not_granted / write_not_granted + // win over the ownership check. No reader and no provenance + // expectations — reaching either fails the test, which is what pins + // the ordering rather than just the final code. + t.Run("space not granted", func(t *testing.T) { + fx := newV2Fixture(t) + ctx := util.CtxWithApiGrant(callerCtx(), &util.ApiGrant{Spaces: []string{"someOtherSpace"}, Perms: util.GrantPermsReadWrite}) + + _, err := fx.DeleteObject(ctx, testSpaceId, deleteObjId, false) + + requireSpaceNotGranted(t, err) + }) + t.Run("write not granted", func(t *testing.T) { + fx := newV2Fixture(t) + ctx := util.CtxWithApiGrant(callerCtx(), &util.ApiGrant{Spaces: []string{testSpaceId}, Perms: util.GrantPermsRead}) + + _, err := fx.DeleteObject(ctx, testSpaceId, deleteObjId, false) + + var v2Err *v2model.Error + require.ErrorAs(t, err, &v2Err) + assert.Equal(t, v2model.CodeWriteNotGranted, v2Err.Code) + }) + }) + + t.Run("an archive-RPC restriction refusal maps to a permanent 403", func(t *testing.T) { + fx := newDeleteFixture(t, true, "Claude Desktop") + fx.mwMock.On("ObjectSetIsArchived", mock.Anything, mock.Anything).Return(&pb.RpcObjectSetIsArchivedResponse{ + Error: &pb.RpcObjectSetIsArchivedResponseError{ + Code: pb.RpcObjectSetIsArchivedResponseError_UNKNOWN_ERROR, + Description: "restricted", + }, + }).Once() + + _, err := fx.DeleteObject(callerCtx(), testSpaceId, deleteObjId, false) + + var v2Err *v2model.Error + require.ErrorAs(t, err, &v2Err) + assert.Equal(t, http.StatusForbidden, v2Err.Status) + assert.Equal(t, v2model.CodeForbidden, v2Err.Code) + assert.Contains(t, v2Err.Message, "do not retry") + }) + + t.Run("the file-ownership refusal is a permanent 403, not a 500 — F4", func(t *testing.T) { + // CanDeleteFile's refusal reads "can't delete other's file" — it does + // NOT contain "restricted", so a match on that word alone (the first + // build) let this permanent refusal fall through to the retry-shaped + // 500 branch. The fixture uses the exact fileobject/service.go text; + // it fails if either textual match is narrowed back. + fx := newDeleteFixture(t, true, "Claude Desktop") + fx.mwMock.On("ObjectSetIsArchived", mock.Anything, mock.Anything).Return(&pb.RpcObjectSetIsArchivedResponse{ + Error: &pb.RpcObjectSetIsArchivedResponseError{ + Code: pb.RpcObjectSetIsArchivedResponseError_UNKNOWN_ERROR, + Description: "can't delete other's file", + }, + }).Once() + + _, err := fx.DeleteObject(callerCtx(), testSpaceId, deleteObjId, false) + + var v2Err *v2model.Error + require.ErrorAs(t, err, &v2Err) + assert.Equal(t, http.StatusForbidden, v2Err.Status) + assert.Equal(t, v2model.CodeForbidden, v2Err.Code) + assert.Contains(t, v2Err.Message, "do not retry") + }) + + t.Run("an archive-RPC failure surfaces as an internal error", func(t *testing.T) { + fx := newDeleteFixture(t, true, "Claude Desktop") + fx.mwMock.On("ObjectSetIsArchived", mock.Anything, mock.Anything).Return(&pb.RpcObjectSetIsArchivedResponse{ + Error: &pb.RpcObjectSetIsArchivedResponseError{ + Code: pb.RpcObjectSetIsArchivedResponseError_UNKNOWN_ERROR, + Description: "boom", + }, + }).Once() + + _, err := fx.DeleteObject(callerCtx(), testSpaceId, deleteObjId, false) + + require.Error(t, err) + assert.Contains(t, err.Error(), "archive object") + }) +} diff --git a/core/api/v2/service/diff.go b/core/api/v2/service/diff.go new file mode 100644 index 0000000000..66d2d6370a --- /dev/null +++ b/core/api/v2/service/diff.go @@ -0,0 +1,140 @@ +package v2service + +// diff.go computes the Phase-3 diff_stats (APIV2.md §2 Phase 3): both +// PATCH and PUT diff the canonical before-document (the live state's +// marshal) against the canonical after-document (the applied snapshot's +// marshal), so normalization noise cancels and the numbers reflect real +// content movement. On PUT the stats are the accidental-full-rewrite signal +// (a body that lost its block ids shows as everything removed + added). + +import ( + "encoding/json" + "fmt" + "reflect" + + v2model "github.com/anyproto/anytype-heart/core/api/v2/model" +) + +// diffDocBlock is one block's diff identity. +type diffDocBlock struct { + id string + parent string + content string // canonical JSON of the block minus indent and id +} + +// diffDocShape is the parsed diff-relevant part of a document. +type diffDocShape struct { + blocks []diffDocBlock + byId map[string]int + properties map[string]any + items []string +} + +func parseDiffDoc(body []byte) (*diffDocShape, error) { + var doc struct { + Properties map[string]any `json:"properties"` + Blocks []map[string]any `json:"blocks"` + Items []string `json:"items"` + } + if err := json.Unmarshal(body, &doc); err != nil { + return nil, fmt.Errorf("decode document for diff: %w", err) + } + shape := &diffDocShape{byId: map[string]int{}, properties: doc.Properties, items: doc.Items} + // parent derivation: the SPEC §4 stack walk over indents + type frame struct { + id string + indent int + } + stack := []frame{{id: "", indent: -1}} + for _, b := range doc.Blocks { + indent := blockIndent(b) + for stack[len(stack)-1].indent >= indent { + stack = stack[:len(stack)-1] + } + content := map[string]any{} + for k, v := range b { + if k == "indent" || k == "id" { + continue + } + content[k] = v + } + contentJSON, err := json.Marshal(content) // map keys sort — deterministic + if err != nil { + return nil, fmt.Errorf("encode block for diff: %w", err) + } + id := blockId(b) + shape.byId[id] = len(shape.blocks) + shape.blocks = append(shape.blocks, diffDocBlock{ + id: id, + parent: stack[len(stack)-1].id, + content: string(contentJSON), + }) + stack = append(stack, frame{id: id, indent: indent}) + } + return shape, nil +} + +// prevCommonSibling finds the nearest preceding block with the same parent +// that also exists in the other document — the movement anchor that keeps +// pure insertions from marking their following siblings as moved. +func (s *diffDocShape) prevCommonSibling(i int, other *diffDocShape) string { + b := s.blocks[i] + for j := i - 1; j >= 0; j-- { + if s.blocks[j].parent != b.parent { + continue + } + if _, common := other.byId[s.blocks[j].id]; common { + return s.blocks[j].id + } + } + return "" +} + +// diffEditDocs computes the diff_stats between two canonical documents. +func diffEditDocs(beforeDoc, afterDoc []byte) (v2model.DiffStats, error) { + var stats v2model.DiffStats + before, err := parseDiffDoc(beforeDoc) + if err != nil { + return stats, fmt.Errorf("before document: %w", err) + } + after, err := parseDiffDoc(afterDoc) + if err != nil { + return stats, fmt.Errorf("after document: %w", err) + } + + for _, b := range after.blocks { + if _, ok := before.byId[b.id]; !ok { + stats.BlocksAdded++ + } + } + for i, b := range before.blocks { + j, ok := after.byId[b.id] + if !ok { + stats.BlocksRemoved++ + continue + } + a := after.blocks[j] + if a.content != b.content { + stats.BlocksChanged++ + } + if a.parent != b.parent || before.prevCommonSibling(i, after) != after.prevCommonSibling(j, before) { + stats.BlocksMoved++ + } + } + + keys := map[string]bool{} + for k := range before.properties { + keys[k] = true + } + for k := range after.properties { + keys[k] = true + } + for k := range keys { + bv, inB := before.properties[k] + av, inA := after.properties[k] + if inB != inA || !reflect.DeepEqual(bv, av) { + stats.PropertiesChanged++ + } + } + return stats, nil +} diff --git a/core/api/v2/service/discovery.go b/core/api/v2/service/discovery.go new file mode 100644 index 0000000000..6420d0d080 --- /dev/null +++ b/core/api/v2/service/discovery.go @@ -0,0 +1,362 @@ +package v2service + +// discovery.go implements the Phase-1 discovery lists (APIV2.md): +// spaces, members, types, the per-type AnyBlock document, properties, and +// property options. All lists paginate per C10; rows are minimal and speak +// the format's vocabulary (C2: keys and names, no id/key duality). + +import ( + "context" + "fmt" + "net/http" + "sort" + "strings" + + "github.com/anyproto/anytype-heart/core/api/pagination" + "github.com/anyproto/anytype-heart/core/api/util" + v2model "github.com/anyproto/anytype-heart/core/api/v2/model" + "github.com/anyproto/anytype-heart/core/domain" + "github.com/anyproto/anytype-heart/pkg/lib/anyblockjson" + "github.com/anyproto/anytype-heart/pkg/lib/bundle" + "github.com/anyproto/anytype-heart/pkg/lib/database" + "github.com/anyproto/anytype-heart/pkg/lib/pb/model" +) + +// ListSpaces returns minimal space rows from the tech space's space views — +// LIVE spaces only (isLiveSpaceView, the predicate shared with GET-one and +// the global-search fan-out): a deleted or left space's row is +// indistinguishable from a live one, and an agent picking it would write +// into a space that can never load. The row carries description too — it +// sits in the same record for free, and withholding it forced a 1+N of +// GET-one calls on the canonical "list my spaces, pick one" trace. +// +// A granted key sees ONLY its granted spaces: the route has no :space_id, +// so the gate lets it through as service-filtered and the intersection with +// the ctx grant happens here — a non-granted space's row (id, name, +// description alike) must never leave this method. +func (s *Service) ListSpaces(ctx context.Context, offset, limit int) ([]v2model.SpaceRow, int, bool, error) { + rows, err := s.liveSpaceRows(ctx) + if err != nil { + return nil, 0, false, err + } + // §8.35: the census runs over the WHOLE visible set, before pagination — + // a page must not hand out a tail a space on another page also claims. + // §8.36: `?ids=full` skips it, and the rows keep the full ids they + // already carry — the spelling a caller can persist outside this API. + ids := make([]string, len(rows)) + for i, row := range rows { + ids[i] = row.Id + } + served := s.servedSpaceRefs(ctx, ids) + for i := range rows { + if short, ok := served[rows[i].Id]; ok { + rows[i].Id = short + } + } + + total := len(rows) + page, hasMore := pagination.Paginate(rows, offset, limit) + return page, total, hasMore, nil +} + +// liveSpaceRows is the ONE enumeration of the spaces a caller can SEE: the +// tech space's space views, filtered to live ones (isLiveSpaceView) and +// intersected with the ctx grant, sorted by full id. Every surface that +// needs the caller's space set reads it here — the spaces list, the +// global-search fan-out (spaceRefs), whoami's name resolution and the +// §8.35 short-reference census and resolution — so the set a short +// reference resolves within is by construction the set the list served it +// from. Two enumerations would be two answers to "which spaces exist", and +// the short form is only unambiguous relative to a fixed set. +// +// Rows carry the FULL id: shortening is a serving decision, made by each +// caller of this method. +func (s *Service) liveSpaceRows(ctx context.Context) ([]v2model.SpaceRow, error) { + grant := util.ApiGrantFromCtx(ctx) + records, err := s.store.SpaceIndex(s.techSpaceId).Query(database.Query{ + Filters: []database.FilterRequest{{ + RelationKey: bundle.RelationKeyResolvedLayout, + Condition: model.BlockContentDataviewFilter_Equal, + Value: domain.Int64(int64(model.ObjectType_spaceView)), + }}, + }) + if err != nil { + return nil, fmt.Errorf("query space views: %w", err) + } + + rows := make([]v2model.SpaceRow, 0, len(records)) + seen := map[string]bool{} + for _, record := range records { + id := record.Details.GetString(bundle.RelationKeyTargetSpaceId) + if id == "" || seen[id] { + continue + } + if !isLiveSpaceView(record.Details) { + continue + } + if grant != nil && !grant.AllowsSpace(id) { + continue + } + seen[id] = true + rows = append(rows, v2model.SpaceRow{ + Id: id, + Name: record.Details.GetString(bundle.RelationKeyName), + Description: record.Details.GetString(bundle.RelationKeyDescription), + }) + } + sort.Slice(rows, func(i, j int) bool { return rows[i].Id < rows[j].Id }) + return rows, nil +} + +// ListMembers returns minimal member rows (active participants) — agents +// need member ids for assignee/creator property values. +func (s *Service) ListMembers(ctx context.Context, spaceId string, offset, limit int) ([]v2model.MemberRow, int, bool, error) { + if err := s.ensureSpace(ctx, spaceId); err != nil { + return nil, 0, false, err + } + records, total, err := s.store.SpaceIndex(spaceId).QueryAndCount(database.Query{ + Filters: []database.FilterRequest{ + { + RelationKey: bundle.RelationKeyResolvedLayout, + Condition: model.BlockContentDataviewFilter_Equal, + Value: domain.Int64(int64(model.ObjectType_participant)), + }, + { + RelationKey: bundle.RelationKeyParticipantStatus, + Condition: model.BlockContentDataviewFilter_Equal, + Value: domain.Int64(int64(model.ParticipantStatus_Active)), + }, + }, + Sorts: []database.SortRequest{{ + RelationKey: bundle.RelationKeyName, + Type: model.BlockContentDataviewSort_Asc, + }}, + Offset: offset, + Limit: limit + 1, + }) + if err != nil { + return nil, 0, false, fmt.Errorf("query members in space %s: %w", spaceId, err) + } + + hasMore := len(records) > limit + if hasMore { + records = records[:limit] + } + rows := make([]v2model.MemberRow, 0, len(records)) + for _, record := range records { + rows = append(rows, v2model.MemberRow{ + Id: record.Details.GetString(bundle.RelationKeyId), + Name: record.Details.GetString(bundle.RelationKeyName), + Role: memberRole(model.ParticipantPermissions(record.Details.GetInt64(bundle.RelationKeyParticipantPermissions))), + Identity: record.Details.GetString(bundle.RelationKeyIdentity), + }) + } + return rows, total, hasMore, nil +} + +// GetMemberMe implements GET /v2/spaces/{space_id}/members/me: the caller's +// own member row (§7.3 — the server-side identity behind the wrapper's `@me` +// sentinel; the same identity Phase 4's placeholder substitution uses). The +// participant id is deterministic, so the row is served even before the +// participant object reaches the store index (name/role empty then) — the id +// is what assignee/creator values need. +func (s *Service) GetMemberMe(ctx context.Context, spaceId string) (v2model.MemberRow, error) { + if err := s.ensureSpace(ctx, spaceId); err != nil { + return v2model.MemberRow{}, err + } + if s.accountId == "" { + return v2model.MemberRow{}, v2model.NotFound( + "the caller's account identity is not available on this server — list members with GET /v2/spaces/{space_id}/members instead") + } + row := v2model.MemberRow{ + Id: domain.NewParticipantId(spaceId, s.accountId), + Identity: s.accountId, + } + // the store returns empty details (no error) for an unindexed id — only + // trust the row's name/role once the participant object actually exists + if details, err := s.store.SpaceIndex(spaceId).GetDetails(row.Id); err == nil && details.GetString(bundle.RelationKeyId) == row.Id { + row.Name = details.GetString(bundle.RelationKeyName) + row.Role = memberRole(model.ParticipantPermissions(details.GetInt64(bundle.RelationKeyParticipantPermissions))) + } + return row, nil +} + +// memberRole maps participant permissions to the API role vocabulary +// (mirrors v1's mapping). +func memberRole(permissions model.ParticipantPermissions) string { + switch permissions { + case model.ParticipantPermissions_Reader: + return "viewer" + case model.ParticipantPermissions_Writer: + return "editor" + case model.ParticipantPermissions_Admin: + return "admin" + case model.ParticipantPermissions_Owner: + return "owner" + default: + return "no_permissions" + } +} + +// ListTypes returns minimal type rows: keys + names. +func (s *Service) ListTypes(ctx context.Context, spaceId string, offset, limit int) ([]v2model.TypeRow, int, bool, error) { + if err := s.ensureSpace(ctx, spaceId); err != nil { + return nil, 0, false, err + } + records, total, err := s.store.SpaceIndex(spaceId).QueryAndCount(database.Query{ + // live filters: a UI-deleted (uninstalled) type must not list — the + // §7.5-requirement-2 corpse policy (keys.go) + Filters: append(liveTypeFilters(), + database.FilterRequest{ + RelationKey: bundle.RelationKeyIsHidden, + Condition: model.BlockContentDataviewFilter_NotEqual, + Value: domain.Bool(true), + }), + Sorts: []database.SortRequest{{ + RelationKey: bundle.RelationKeyName, + Type: model.BlockContentDataviewSort_Asc, + }}, + Offset: offset, + Limit: limit + 1, + }) + if err != nil { + return nil, 0, false, fmt.Errorf("query types in space %s: %w", spaceId, err) + } + + hasMore := len(records) > limit + if hasMore { + records = records[:limit] + } + liveEntries, err := s.liveTypes(spaceId) + if err != nil { + return nil, 0, false, err + } + keyTaken, slugHolders := servedTypeKeySets(liveEntries) + rows := make([]v2model.TypeRow, 0, len(records)) + for _, record := range records { + key, err := domain.GetTypeKeyFromRawUniqueKey(record.Details.GetString(bundle.RelationKeyUniqueKey)) + if err != nil { + continue + } + rows = append(rows, v2model.TypeRow{ + Key: servedTypeKeyOf(string(key), record.Details.GetString(bundle.RelationKeyApiObjectKey), keyTaken, slugHolders), + Name: record.Details.GetString(bundle.RelationKeyName), + }) + } + return rows, total, hasMore, nil +} + +// GetType returns the kind:"object_type" AnyBlock document for one type key, +// read via the live smartblock state like any object (§8). The query rides +// through to GetObject, so `?ids=full` works here exactly as on objects — +// the export shape must be one query parameter away on every document read +// (§8.25 promised it; hardcoding ObjectQuery{} broke it for types). +func (s *Service) GetType(ctx context.Context, spaceId, typeKey string, q ObjectQuery) ([]byte, string, error) { + if err := s.ensureSpace(ctx, spaceId); err != nil { + return nil, "", err + } + entry, err := s.requireLiveType(spaceId, typeKey, "/key") + if err != nil { + return nil, "", err + } + return s.GetObject(ctx, spaceId, entry.Id, q) +} + +// GetTypeSchema is the [build] GenerateSchema endpoint — the derived +// artifact does not exist yet (SPEC §2a: planned, not implemented), so the +// route reports 501 until it lands. +func (s *Service) GetTypeSchema(ctx context.Context, spaceId, typeKey string) error { + if err := s.ensureSpace(ctx, spaceId); err != nil { + return err + } + return v2model.NewError(http.StatusNotImplemented, v2model.CodeNotImplemented, + fmt.Sprintf("type schema generation is not implemented yet — read the type document at GET /v2/spaces/%s/types/%s instead", spaceId, typeKey)) +} + +// ListProperties returns minimal property rows: key, name, format. +func (s *Service) ListProperties(ctx context.Context, spaceId string, offset, limit int) ([]v2model.PropertyRow, int, bool, error) { + if err := s.ensureSpace(ctx, spaceId); err != nil { + return nil, 0, false, err + } + records, total, err := s.store.SpaceIndex(spaceId).QueryAndCount(database.Query{ + // live filters: a UI-deleted (uninstalled) property still carried the + // relation layout and passed every filter here — the §2.3-6 defect; + // the corpse policy excludes it (keys.go) + Filters: append(livePropertyFilters(), + database.FilterRequest{ + RelationKey: bundle.RelationKeyIsHidden, + Condition: model.BlockContentDataviewFilter_NotEqual, + Value: domain.Bool(true), + }), + Sorts: []database.SortRequest{{ + RelationKey: bundle.RelationKeyName, + Type: model.BlockContentDataviewSort_Asc, + }}, + Offset: offset, + Limit: limit + 1, + }) + if err != nil { + return nil, 0, false, fmt.Errorf("query properties in space %s: %w", spaceId, err) + } + + hasMore := len(records) > limit + if hasMore { + records = records[:limit] + } + // the served spelling (§7.5a): a BSON-keyed property answers to its + // slug, so the slug is what the row advertises — iff it round-trips + // (servedKey); the count maps come from the full live set, not the page + liveEntries, err := s.liveProperties(spaceId) + if err != nil { + return nil, 0, false, err + } + keyTaken, slugHolders := servedPropertyKeySets(liveEntries) + rows := make([]v2model.PropertyRow, 0, len(records)) + for _, record := range records { + key := record.Details.GetString(bundle.RelationKeyRelationKey) + if key == "" { + continue + } + rows = append(rows, v2model.PropertyRow{ + Key: servedKey(key, record.Details.GetString(bundle.RelationKeyApiObjectKey), keyTaken, slugHolders), + Name: record.Details.GetString(bundle.RelationKeyName), + Format: anyblockjson.FormatName(model.RelationFormat(record.Details.GetInt64(bundle.RelationKeyRelationFormat))), + }) + } + return rows, total, hasMore, nil +} + +// ListPropertyOptions returns the option names (+color) of one +// select/multiSelect property, with a prefix filter (C10 — tag-like +// properties can hold thousands of options). +func (s *Service) ListPropertyOptions(ctx context.Context, spaceId, propertyKey, prefix string, offset, limit int) ([]v2model.OptionRow, int, bool, error) { + if err := s.ensureSpace(ctx, spaceId); err != nil { + return nil, 0, false, err + } + // live lookup, slug-aware — a corpse property's options are not an API + // surface; options bind to the STORED key, so list by entry.Key + entry, err := s.requireLiveProperty(spaceId, propertyKey) + if err != nil { + return nil, 0, false, err + } + options, err := s.store.SpaceIndex(spaceId).ListRelationOptions(domain.RelationKey(entry.Key)) + if err != nil { + return nil, 0, false, fmt.Errorf("list options of property %s: %w", propertyKey, err) + } + + rows := make([]v2model.OptionRow, 0, len(options)) + for _, option := range options { + if option == nil { + continue + } + if prefix != "" && !strings.HasPrefix(strings.ToLower(option.Text), strings.ToLower(prefix)) { + continue + } + rows = append(rows, v2model.OptionRow{Name: option.Text, Color: option.Color}) + } + sort.Slice(rows, func(i, j int) bool { return rows[i].Name < rows[j].Name }) + + total := len(rows) + page, hasMore := pagination.Paginate(rows, offset, limit) + return page, total, hasMore, nil +} diff --git a/core/api/v2/service/discovery_test.go b/core/api/v2/service/discovery_test.go new file mode 100644 index 0000000000..a47ad3a6c5 --- /dev/null +++ b/core/api/v2/service/discovery_test.go @@ -0,0 +1,484 @@ +package v2service + +import ( + "context" + "net/http" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + + v2model "github.com/anyproto/anytype-heart/core/api/v2/model" + "github.com/anyproto/anytype-heart/core/domain" + "github.com/anyproto/anytype-heart/pkg/lib/bundle" + "github.com/anyproto/anytype-heart/pkg/lib/localstore/objectstore" + "github.com/anyproto/anytype-heart/pkg/lib/pb/model" +) + +func TestV2ListSpaces(t *testing.T) { + t.Run("space views become minimal rows", func(t *testing.T) { + // given + fx := newV2FixtureBare(t) + fx.objectStore.AddObjects(t, objectstore.TestTechSpaceId, []objectstore.TestObject{ + { + bundle.RelationKeyId: domain.String("spaceView1"), + bundle.RelationKeyResolvedLayout: domain.Int64(int64(model.ObjectType_spaceView)), + bundle.RelationKeyTargetSpaceId: domain.String("space1"), + bundle.RelationKeyName: domain.String("Work"), + }, + { + bundle.RelationKeyId: domain.String("spaceView2"), + bundle.RelationKeyResolvedLayout: domain.Int64(int64(model.ObjectType_spaceView)), + bundle.RelationKeyTargetSpaceId: domain.String("space2"), + bundle.RelationKeyName: domain.String("Personal"), + }, + }) + want := []v2model.SpaceRow{ + {Id: "space1", Name: "Work"}, + {Id: "space2", Name: "Personal"}, + } + + // when + rows, total, hasMore, err := fx.ListSpaces(context.Background(), 0, 25) + + // then + require.NoError(t, err) + assert.Equal(t, want, rows) + assert.Equal(t, 2, total) + assert.False(t, hasMore) + }) + + t.Run("rows carry the description — no GET-one hop to disambiguate", func(t *testing.T) { + // given: description sits in the same tech-space record for free; + // withholding it forced 1+N reads on "list my spaces, pick one" + fx := newV2FixtureBare(t) + fx.registerSpaceView(t, "spaceS", "Work", "The local-first wiki") + + // when + rows, _, _, err := fx.ListSpaces(context.Background(), 0, 25) + + // then + require.NoError(t, err) + require.Len(t, rows, 1) + assert.Equal(t, "The local-first wiki", rows[0].Description) + }) + + t.Run("deleted and non-active spaces are filtered out (the live predicate)", func(t *testing.T) { + // given: a live space and a deleted one — v1's GetSpace filters both + // status axes; a dead row is indistinguishable from a live one and an + // agent picking it would write into a space that can never load + fx := newV2FixtureBare(t) + fx.registerSpaceView(t, "spaceLive", "Live", "") + fx.objectStore.AddObjects(t, objectstore.TestTechSpaceId, []objectstore.TestObject{{ + bundle.RelationKeyId: domain.String("spaceView_dead"), + bundle.RelationKeyResolvedLayout: domain.Int64(int64(model.ObjectType_spaceView)), + bundle.RelationKeyTargetSpaceId: domain.String("deadSpace"), + bundle.RelationKeyName: domain.String("Deleted space"), + bundle.RelationKeySpaceAccountStatus: domain.Int64(int64(model.SpaceStatus_SpaceDeleted)), + bundle.RelationKeySpaceLocalStatus: domain.Int64(int64(model.SpaceStatus_Missing)), + }}) + + // when + rows, total, _, err := fx.ListSpaces(context.Background(), 0, 25) + + // then + require.NoError(t, err) + assert.Equal(t, 1, total) + require.Len(t, rows, 1) + assert.Equal(t, "spaceLive", rows[0].Id) + }) + + t.Run("empty tech space lists nothing", func(t *testing.T) { + // given + fx := newV2FixtureBare(t) + + // when + rows, total, _, err := fx.ListSpaces(context.Background(), 0, 25) + + // then + require.NoError(t, err) + assert.Empty(t, rows) + assert.Zero(t, total) + }) +} + +func TestV2EnsureSpace(t *testing.T) { + // C2: an unknown space_id must be rejected with 404 before any per-space + // objectstore access, so a bogus id cannot mint an unbounded store index. + t.Run("unknown space is rejected 404 without touching the store", func(t *testing.T) { + // given: fixture registers only testSpaceId + fx := newV2Fixture(t) + + // when: a space that has no spaceView + _, _, _, err := fx.ListObjects(context.Background(), "bogus-space", nil, 0, 25) + + // then + var apiErr *v2model.Error + require.ErrorAs(t, err, &apiErr) + assert.Equal(t, http.StatusNotFound, apiErr.Status) + assert.Equal(t, v2model.CodeNotFound, apiErr.Code) + }) + + t.Run("a registered space passes the guard", func(t *testing.T) { + // given + fx := newV2Fixture(t) + + // when: testSpaceId is registered by the fixture + _, total, _, err := fx.ListObjects(context.Background(), testSpaceId, nil, 0, 25) + + // then: no space error (empty result is fine) + require.NoError(t, err) + assert.Zero(t, total) + }) +} + +func TestV2ListMembers(t *testing.T) { + t.Run("active members become rows with roles", func(t *testing.T) { + // given + fx := newV2Fixture(t) + fx.objectStore.AddObjects(t, testSpaceId, []objectstore.TestObject{ + { + bundle.RelationKeyId: domain.String("_participant_a"), + bundle.RelationKeyName: domain.String("Alice"), + bundle.RelationKeyResolvedLayout: domain.Int64(int64(model.ObjectType_participant)), + bundle.RelationKeyParticipantStatus: domain.Int64(int64(model.ParticipantStatus_Active)), + bundle.RelationKeyParticipantPermissions: domain.Int64(int64(model.ParticipantPermissions_Owner)), + bundle.RelationKeyIdentity: domain.String("idA"), + }, + { + bundle.RelationKeyId: domain.String("_participant_b"), + bundle.RelationKeyName: domain.String("Bob"), + bundle.RelationKeyResolvedLayout: domain.Int64(int64(model.ObjectType_participant)), + bundle.RelationKeyParticipantStatus: domain.Int64(int64(model.ParticipantStatus_Joining)), + bundle.RelationKeyParticipantPermissions: domain.Int64(int64(model.ParticipantPermissions_Reader)), + }, + }) + want := []v2model.MemberRow{ + {Id: "_participant_a", Name: "Alice", Role: "owner", Identity: "idA"}, + } + + // when + rows, total, hasMore, err := fx.ListMembers(context.Background(), testSpaceId, 0, 25) + + // then + require.NoError(t, err) + assert.Equal(t, want, rows, "joining members are not listed") + assert.Equal(t, 1, total) + assert.False(t, hasMore) + }) +} + +func TestV2GetMemberMe(t *testing.T) { + meId := domain.NewParticipantId(testSpaceId, testAccountId) + + t.Run("returns the caller's member row from the store", func(t *testing.T) { + // given + fx := newV2Fixture(t) + fx.objectStore.AddObjects(t, testSpaceId, []objectstore.TestObject{ + { + bundle.RelationKeyId: domain.String(meId), + bundle.RelationKeyName: domain.String("Me Myself"), + bundle.RelationKeyResolvedLayout: domain.Int64(int64(model.ObjectType_participant)), + bundle.RelationKeyParticipantPermissions: domain.Int64(int64(model.ParticipantPermissions_Owner)), + }, + }) + want := v2model.MemberRow{Id: meId, Name: "Me Myself", Role: "owner", Identity: testAccountId} + + // when + row, err := fx.GetMemberMe(context.Background(), testSpaceId) + + // then + require.NoError(t, err) + assert.Equal(t, want, row) + }) + + t.Run("serves the deterministic id before the participant object is indexed", func(t *testing.T) { + // given + fx := newV2Fixture(t) + want := v2model.MemberRow{Id: meId, Identity: testAccountId} + + // when + row, err := fx.GetMemberMe(context.Background(), testSpaceId) + + // then + require.NoError(t, err) + assert.Equal(t, want, row, "the id is what assignee values need — served without a store row") + }) + + t.Run("no account identity is a 404 steering to the members list", func(t *testing.T) { + // given: a service constructed without an account id (degraded mode) + fx := newV2Fixture(t) + svc := NewService(fx.mwMock, fx.readerMock, fx.creatorMock, fx.mutatorMock, nil, fx.objectStore, objectstore.TestTechSpaceId, "") + + // when + _, err := svc.GetMemberMe(context.Background(), testSpaceId) + + // then + apiErr := v2Err(t, err) + assert.Equal(t, v2model.CodeNotFound, apiErr.Code) + assert.Contains(t, apiErr.Message, "GET /v2/spaces/{space_id}/members") + }) +} + +func TestV2ListTypes(t *testing.T) { + t.Run("types become key+name rows", func(t *testing.T) { + // given + fx := newV2Fixture(t) + fx.objectStore.AddObjects(t, testSpaceId, []objectstore.TestObject{ + { + bundle.RelationKeyId: domain.String("type-task"), + bundle.RelationKeyName: domain.String("Task"), + bundle.RelationKeyUniqueKey: domain.String("ot-task"), + bundle.RelationKeyResolvedLayout: domain.Int64(int64(model.ObjectType_objectType)), + }, + { + bundle.RelationKeyId: domain.String("type-hidden"), + bundle.RelationKeyName: domain.String("Hidden"), + bundle.RelationKeyUniqueKey: domain.String("ot-hidden"), + bundle.RelationKeyIsHidden: domain.Bool(true), + bundle.RelationKeyResolvedLayout: domain.Int64(int64(model.ObjectType_objectType)), + }, + }) + want := []v2model.TypeRow{{Key: "task", Name: "Task"}} + + // when + rows, total, hasMore, err := fx.ListTypes(context.Background(), testSpaceId, 0, 25) + + // then + require.NoError(t, err) + assert.Equal(t, want, rows, "hidden types are not listed") + assert.Equal(t, 1, total) + assert.False(t, hasMore) + }) +} + +func TestV2GetType(t *testing.T) { + t.Run("resolves the key and reads the type document", func(t *testing.T) { + // given + fx := newV2Fixture(t) + fx.objectStore.AddObjects(t, testSpaceId, []objectstore.TestObject{ + { + bundle.RelationKeyId: domain.String("type-task"), + bundle.RelationKeyName: domain.String("Task"), + bundle.RelationKeyUniqueKey: domain.String("ot-task"), + bundle.RelationKeyResolvedLayout: domain.Int64(int64(model.ObjectType_objectType)), + }, + }) + read := testObjectRead() + fx.readerMock.EXPECT().ReadObject(mock.Anything, testSpaceId, "type-task").Return(read, nil) + + // when + body, etag, err := fx.GetType(context.Background(), testSpaceId, "task", ObjectQuery{}) + + // then + require.NoError(t, err) + assert.NotEmpty(t, etag) + assert.NotEmpty(t, body) + }) + + t.Run("?ids= rides through to the type read — the export shape is one query parameter away", func(t *testing.T) { + // given: a type document with minted-shape block ids; GetType used to + // hardcode ObjectQuery{}, so §8.25's "the export shape is one query + // parameter away" was false for types + fx := newV2Fixture(t) + fx.objectStore.AddObjects(t, testSpaceId, []objectstore.TestObject{ + { + bundle.RelationKeyId: domain.String("type-task"), + bundle.RelationKeyName: domain.String("Task"), + bundle.RelationKeyUniqueKey: domain.String("ot-task"), + bundle.RelationKeyResolvedLayout: domain.Int64(int64(model.ObjectType_objectType)), + }, + }) + fx.readerMock.EXPECT().ReadObject(mock.Anything, testSpaceId, "type-task").Return(testObjectReadLongIds(), nil).Times(2) + + // when / then: default = labels, full = the stored ids + compact, _, err := fx.GetType(context.Background(), testSpaceId, "task", ObjectQuery{}) + require.NoError(t, err) + assert.Contains(t, string(compact), `"id":"bbbb1"`, "the default type read is the edit shape") + + full, _, err := fx.GetType(context.Background(), testSpaceId, "task", ObjectQuery{Ids: V2IdsFull}) + require.NoError(t, err) + assert.Contains(t, string(full), `"id":"`+testMintedParentId+`"`, "?ids=full serves the stored ids") + }) + + t.Run("unknown key is a 404 listing the space's type keys with did-you-mean", func(t *testing.T) { + // given: the candidate-less form of this tip was a dead end — a + // benchmarked small model did not retry at all (§8.21); the message + // must carry the actual keys and the nearest match, like the + // property-key path always did + fx := newV2Fixture(t) + fx.objectStore.AddObjects(t, testSpaceId, []objectstore.TestObject{ + { + bundle.RelationKeyId: domain.String("type-task"), + bundle.RelationKeyName: domain.String("Task"), + bundle.RelationKeyUniqueKey: domain.String("ot-task"), + bundle.RelationKeyResolvedLayout: domain.Int64(int64(model.ObjectType_objectType)), + }, + { + bundle.RelationKeyId: domain.String("type-page"), + bundle.RelationKeyName: domain.String("Page"), + bundle.RelationKeyUniqueKey: domain.String("ot-page"), + bundle.RelationKeyResolvedLayout: domain.Int64(int64(model.ObjectType_objectType)), + }, + }) + + // the benchmark's literal miss — the Title-Case guess — now resolves + // through the §7.5a-3 fold layer (exact-first, fold as fallback): + // zero retries instead of one repaired retry (§8.21) + fx.readerMock.EXPECT().ReadObject(mock.Anything, testSpaceId, "type-page").Return(testObjectRead(), nil) + _, _, err := fx.GetType(context.Background(), testSpaceId, "Page", ObjectQuery{}) + require.NoError(t, err) + + // when: a genuine miss (no fold candidate) keeps the keyed 404 + _, _, err = fx.GetType(context.Background(), testSpaceId, "Pages", ObjectQuery{}) + + // then + var v2Err *v2model.Error + require.ErrorAs(t, err, &v2Err) + assert.Equal(t, 404, v2Err.Status) + assert.Contains(t, v2Err.Message, "known type keys: page, task") + assert.Contains(t, v2Err.Message, "did you mean page?") + }) + + t.Run("unknown key in an empty space says so", func(t *testing.T) { + // given + fx := newV2Fixture(t) + + // when + _, _, err := fx.GetType(context.Background(), testSpaceId, "nope", ObjectQuery{}) + + // then + var v2Err *v2model.Error + require.ErrorAs(t, err, &v2Err) + assert.Equal(t, 404, v2Err.Status) + assert.Contains(t, v2Err.Message, "the space has no type keys yet") + }) +} + +func TestV2GetTypeSchema(t *testing.T) { + // given + fx := newV2Fixture(t) + + // when + err := fx.GetTypeSchema(context.Background(), testSpaceId, "task") + + // then + var v2Err *v2model.Error + require.ErrorAs(t, err, &v2Err) + assert.Equal(t, 501, v2Err.Status) + assert.Equal(t, v2model.CodeNotImplemented, v2Err.Code) +} + +func TestV2ListProperties(t *testing.T) { + t.Run("properties become key+name+format rows", func(t *testing.T) { + // given + fx := newV2Fixture(t) + fx.objectStore.AddObjects(t, testSpaceId, []objectstore.TestObject{ + { + bundle.RelationKeyId: domain.String("rel-priority"), + bundle.RelationKeyRelationKey: domain.String("priority"), + bundle.RelationKeyName: domain.String("Priority"), + bundle.RelationKeyRelationFormat: domain.Int64(int64(model.RelationFormat_status)), + bundle.RelationKeyResolvedLayout: domain.Int64(int64(model.ObjectType_relation)), + }, + }) + want := []v2model.PropertyRow{{Key: "priority", Name: "Priority", Format: "select"}} + + // when + rows, total, hasMore, err := fx.ListProperties(context.Background(), testSpaceId, 0, 25) + + // then + require.NoError(t, err) + assert.Equal(t, want, rows) + assert.Equal(t, 1, total) + assert.False(t, hasMore) + }) +} + +func TestV2ListPropertyOptions(t *testing.T) { + addOptions := func(fx *v2Fixture, t *testing.T) { + fx.objectStore.AddObjects(t, testSpaceId, []objectstore.TestObject{ + { + bundle.RelationKeyId: domain.String("rel-priority"), + bundle.RelationKeyRelationKey: domain.String("priority"), + bundle.RelationKeyName: domain.String("Priority"), + bundle.RelationKeyRelationFormat: domain.Int64(int64(model.RelationFormat_status)), + bundle.RelationKeyResolvedLayout: domain.Int64(int64(model.ObjectType_relation)), + }, + { + bundle.RelationKeyId: domain.String("opt-high"), + bundle.RelationKeyRelationKey: domain.String("priority"), + bundle.RelationKeyName: domain.String("High"), + bundle.RelationKeyRelationOptionColor: domain.String("red"), + bundle.RelationKeyResolvedLayout: domain.Int64(int64(model.ObjectType_relationOption)), + }, + { + bundle.RelationKeyId: domain.String("opt-low"), + bundle.RelationKeyRelationKey: domain.String("priority"), + bundle.RelationKeyName: domain.String("Low"), + bundle.RelationKeyResolvedLayout: domain.Int64(int64(model.ObjectType_relationOption)), + }, + }) + } + + t.Run("options become name+color rows", func(t *testing.T) { + // given + fx := newV2Fixture(t) + addOptions(fx, t) + want := []v2model.OptionRow{{Name: "High", Color: "red"}, {Name: "Low"}} + + // when + rows, total, hasMore, err := fx.ListPropertyOptions(context.Background(), testSpaceId, "priority", "", 0, 25) + + // then + require.NoError(t, err) + assert.Equal(t, want, rows) + assert.Equal(t, 2, total) + assert.False(t, hasMore) + }) + + t.Run("prefix filters case-insensitively", func(t *testing.T) { + // given + fx := newV2Fixture(t) + addOptions(fx, t) + + // when + rows, total, _, err := fx.ListPropertyOptions(context.Background(), testSpaceId, "priority", "hi", 0, 25) + + // then + require.NoError(t, err) + require.Len(t, rows, 1) + assert.Equal(t, "High", rows[0].Name) + assert.Equal(t, 1, total) + }) + + t.Run("a case-variant key resolves through the fold layer", func(t *testing.T) { + // §7.5a-3: exact match first, fold (lowercase, _- stripped) as the + // forgiving fallback — the Title-Case guess works without a retry + fx := newV2Fixture(t) + addOptions(fx, t) + + rows, _, _, err := fx.ListPropertyOptions(context.Background(), testSpaceId, "Priority", "", 0, 25) + + require.NoError(t, err) + require.Len(t, rows, 2) + }) + + t.Run("unknown property is a 404 listing the space's keys with did-you-mean", func(t *testing.T) { + // given: the same candidate-listing contract as the type path (§8.21 + // — the family is fixed together, not one string) + fx := newV2Fixture(t) + addOptions(fx, t) + + // when: a genuine miss — no fold candidate either + _, _, _, err := fx.ListPropertyOptions(context.Background(), testSpaceId, "prio", "", 0, 25) + + // then + var v2Err *v2model.Error + require.ErrorAs(t, err, &v2Err) + assert.Equal(t, 404, v2Err.Status) + assert.Contains(t, v2Err.Message, "known property keys: priority") + assert.Contains(t, v2Err.Message, "did you mean priority?") + }) +} diff --git a/core/api/v2/service/edit.go b/core/api/v2/service/edit.go new file mode 100644 index 0000000000..a12a5b05d9 --- /dev/null +++ b/core/api/v2/service/edit.go @@ -0,0 +1,471 @@ +package v2service + +// edit.go implements the Phase-3 edit surface (APIV2.md §2 Phase 3): +// PATCH /v2/spaces/{space_id}/objects/{object_id} — the batched, atomic, +// id-addressed op set (ops.go). It is the ONLY edit surface: SNAPSHOTS ARE +// FOR CREATES, EDITS ARE OPS (APIV2.md §8.27). The full-document PUT that +// once sat beside it was removed with its whole pipeline — a surface that +// makes a caller materialize the whole document to change part of it pays +// whole-document tokens both ways and takes block ids literally, which is +// where every id-identity defect of the hardening came from. +// +// PATCH applies the ops to a child *state.State of the live object +// (stateops.go) and the adapter commits it with ONE ordinary sb.Apply — +// the Block* RPC handler model. The flat document is still rendered under +// the lock, but only as the read-only view the ops address (refs, indents, +// error texts) and as the diff_stats input; nothing round-trips the whole +// document through Unmarshal. Create-missing option resolution runs BEFORE +// the object lock (resolver.go prewarm), so no create-RPC ever holds the +// lock. diff_stats come from the canonical before/after documents (diff.go). + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "sort" + "strings" + + apicore "github.com/anyproto/anytype-heart/core/api/core" + v2model "github.com/anyproto/anytype-heart/core/api/v2/model" + "github.com/anyproto/anytype-heart/core/block/editor/state" + "github.com/anyproto/anytype-heart/pb" + "github.com/anyproto/anytype-heart/pkg/lib/pb/model" +) + +// v2MaxOpsPerPatch bounds one PATCH batch. Each op re-renders the document +// view while the object lock is held, so an unbounded batch would hold the +// lock for O(ops × document) work (review A′2). The op count alone does not +// bound that product — the document factor is bounded separately by +// v2MaxPatchRenderWork. +const v2MaxOpsPerPatch = 512 + +// v2MaxPatchRenderWork bounds the marshal work one PATCH may do under the +// object lock, in block-renders: every view-rebuilding op (v2OpRebuildsView) +// forces the next op to re-marshal the WHOLE document, so a batch's worst +// case is rebuildingOps × (document blocks + payload blocks) block-renders. +// The 512-op cap bounds only the first factor (surface review M7): 400 +// trivial replace_text ops on a 24,000-block document measured 71 s inside +// PatchObject (~7 µs per block-render on a desktop machine) — all of it +// while ObjectOpen, sync and every other RPC on the object wait. 2^20 +// block-renders ≈ 7 s worst case; an over-bound batch is refused before any +// op applies, with the numbers, and splitting the edit across several PATCH +// requests releases the object between batches — same total work, no +// minutes-long lock hold. +const v2MaxPatchRenderWork = 1 << 20 + +// checkPatchRenderWork computes the worst-case block-render product of a +// batch against the document and refuses an over-bound batch whole, before +// any op applies — with the numbers, so the caller can size its batches. +// Payload blocks count into the document factor (an insert inflates what +// every later op re-renders); a markdown payload counts as the parsed-run +// cap, since parsing happens later. +// +// A dataview is ONE block whose marshal cost is O(views × columns) — the +// §8.19-B correction: counting it as one render let a fully legal +// 512×insert_view batch on a wide set hold the object lock for tens of +// seconds while scoring 0.05% of the budget. The document factor therefore +// counts dataview weight (per view: 1 + columns + sorts + filters), and +// every insert_view adds the document's heaviest per-view weight to the +// payload factor — the copy_from worst case. Ops that fail to probe +// contribute nothing — they fail in the applier, on their own op path, +// before any rebuild. +func checkPatchRenderWork(ops []json.RawMessage, blocks []map[string]any) error { + docWork := len(blocks) + dataviewRenderWork(blocks) + perViewWork := heaviestViewRenderWork(blocks) + rebuilds, payload := 0, 0 + for _, raw := range ops { + var probe struct { + Op string `json:"op"` + Blocks []json.RawMessage `json:"blocks"` + Markdown string `json:"markdown"` + } + if err := json.Unmarshal(raw, &probe); err != nil { + continue + } + if v2OpRebuildsView[probe.Op] { + rebuilds++ + } + payload += len(probe.Blocks) + if probe.Markdown != "" { + payload += v2MaxBlocksPerOp + } + if probe.Op == "insert_view" { + payload += perViewWork + } + } + work := rebuilds * (docWork + payload) + if work <= v2MaxPatchRenderWork { + return nil + } + return v2model.ValidationFailed("this PATCH is too much re-rendering work for one atomic batch", + v2model.Issue{ + Path: "/ops", + Message: fmt.Sprintf( + "%d view-rebuilding ops each re-render the whole document (%d block-render units incl. dataview views×columns, %d more from payloads): ~%d block-renders exceeds the %d limit", + rebuilds, docWork, payload, work, v2MaxPatchRenderWork), + Hint: "split the edit across several smaller PATCH requests — the object is released between batches", + }) +} + +// dataviewRenderWork counts the render weight dataview blocks add beyond +// their single block: one unit per view plus its columns, sorts and filters. +func dataviewRenderWork(blocks []map[string]any) int { + work := 0 + for _, b := range blocks { + if blockType(b) != "dataview" { + continue + } + views, _ := b["views"].([]any) + for _, raw := range views { + work += viewRenderWork(raw) + } + } + return work +} + +// heaviestViewRenderWork is the largest single-view weight in the document — +// what one insert_view may add (its copy_from worst case). +func heaviestViewRenderWork(blocks []map[string]any) int { + heaviest := 1 + for _, b := range blocks { + if blockType(b) != "dataview" { + continue + } + views, _ := b["views"].([]any) + for _, raw := range views { + if w := viewRenderWork(raw); w > heaviest { + heaviest = w + } + } + } + return heaviest +} + +func viewRenderWork(raw any) int { + view, ok := raw.(map[string]any) + if !ok { + return 1 + } + columns, _ := view["columns"].([]any) + sorts, _ := view["sorts"].([]any) + filters, _ := view["filters"].([]any) + return 1 + len(columns) + len(sorts) + len(filters) +} + +// v2PatchRequest is the PATCH body: the closed op list, nothing else. +type v2PatchRequest struct { + Ops []json.RawMessage `json:"ops"` +} + +// PatchObject implements PATCH /v2/spaces/{space_id}/objects/{object_id}: the +// ops apply to a child state of the live object, committed with one ordinary +// Apply (stateops.go). +func (s *Service) PatchObject(ctx context.Context, spaceId, objectId string, body []byte, ifMatch string, dryRun, createMissingOptions bool) (*v2model.EditResult, error) { + if err := s.ensureSpaceWrite(ctx, spaceId); err != nil { + return nil, err + } + ops, err := parsePatchRequest(body) + if err != nil { + return nil, err + } + resolvers := s.newCreatingResolvers(ctx, spaceId, dryRun, createMissingOptions) + + // Create-missing option resolution runs before the object lock, so no + // create-RPC ever holds it (review B6/A6) — but it must NOT run before the + // request is known to be legitimate: prewarming first meant a PATCH to a + // nonexistent object (404), with a stale If-Match (412) or on a restricted + // object (403) still permanently created every option the batch named + // (review A′1). So: read the object and check the preconditions first, + // prewarm only once they pass, then take the lock. + cur, err := s.reader.ReadObject(ctx, spaceId, objectId) + if err != nil { + return nil, mapReadError(spaceId, objectId, err) + } + if err := checkEditPreconditions(cur.SbType, cur.Heads, ifMatch); err != nil { + return nil, err + } + // the object's own restrictions, from the same read — so a dry run reaches + // the same verdict as the real edit rather than reporting a success the + // adapter would refuse (review C′3). Per-op, not per-request: a set and a + // collection restrict Blocks but not Details, so a blanket check refused + // renames and every add_items (surface review M1). + needs, err := editNeedsForOps(ops, cur) + if err != nil { + return nil, err + } + if err := s.guardCreateMissing(ctx, spaceId, objectId, ops, ifMatch, cur, dryRun, createMissingOptions); err != nil { + return nil, err + } + s.prewarmCreateMissing(ops, resolvers) + + var result *v2model.EditResult + run := func(edit apicore.ObjectEdit) error { + res, err := s.applyPatchOps(ctx, spaceId, objectId, ops, ifMatch, edit, resolvers) + if err != nil { + return err + } + result = res + return nil + } + if dryRun { + edit, err := editFromRead(objectId, cur) + if err != nil { + return nil, err + } + if err := run(edit); err != nil { + return nil, err + } + result.DryRun = true + return result, nil + } + heads, err := s.mutator.MutateObject(ctx, spaceId, objectId, needs, run) + if err != nil { + var v2Err *v2model.Error + if errors.As(err, &v2Err) { + return nil, v2Err + } + return nil, mapWriteError(spaceId, objectId, err) + } + result.Etag = ComputeEtag(heads) + return result, nil +} + +// v2MaxCreatedOptionsPerPatch bounds how many select/multiSelect options one +// PATCH may bring into existence. Create-missing is deliberate (SPEC §3: +// option NAMES are the identity, so an unknown name is created, not +// rejected), but it is also irreversible: options are objects, v2 has no +// option-delete surface, and they sync to every device. A batch naming +// dozens of genuinely new options is already extreme for a single object +// edit; thousands is a hallucinated array, not intent. +const v2MaxCreatedOptionsPerPatch = 64 + +// guardCreateMissing is the M5 prevention pass. Before this, one FAILING +// PATCH permanently created every option it named — 5,000 real objects from +// a ~60 KB body, ~10^6 at the body cap — because prewarm ran before the +// batch was known to be applicable and nothing bounded it. +// +// There is no transaction to lean on: options are objects, each its own CRDT +// tree, so "create N options and mutate a document" cannot be one commit. +// The irreversible part therefore goes LAST and SMALL, in two halves that +// catch different requests: +// +// 1. THE BOUND. Only this stops a *well-formed* batch — one that would +// succeed — from creating a million options. Enforced on a probe pass +// that resolves without creating, so the rejection costs nothing. +// 2. THE ORDERING. Only this stops a *failing* batch from leaving debris: +// the whole batch is validated against a private state first, so an op +// that cannot apply is discovered before any create RPC fires. This +// subsumes case-by-case skip lists (a key claimed by both `set` and +// `unset`, a scalar where a list is required, …) — enumerating the ways +// a batch can fail is open-ended; validating it is not. +// +// The second half runs only when the batch actually names new options, so +// the ordinary PATCH pays one extra JSON walk and nothing more. Both halves +// run for dry runs too, so C9's preview reaches the same verdict. +// +// What remains after this is a crash or cancellation between the creates and +// the apply. That cannot be eliminated without a cross-object transaction, +// but it is now bounded by the cap, convergent on retry (OptionId resolves an +// existing option by name before creating, so a retry adopts what the first +// attempt made instead of duplicating it), and detectable — created options +// carry ObjectOrigin_api. +func (s *Service) guardCreateMissing(ctx context.Context, spaceId, objectId string, ops []json.RawMessage, ifMatch string, cur apicore.ObjectRead, dryRun, createMissingOptions bool) error { + // a resolver in dry mode records would-be creations instead of performing + // them: no RPCs, no document work, just a walk of the op payloads + // the probe must see the SAME consent as the real run: it exists to + // preview what that run would create, and a probe with a different answer + // previews a different request + probe := s.newCreatingResolvers(ctx, spaceId, true, createMissingOptions) + s.prewarmCreateMissing(ops, probe) + pending := probe.sideEffects.Options + if len(pending) == 0 { + return nil + } + if !createMissingOptions { + // A2: refuse BEFORE the lock and before any RPC, naming every option + // the batch would have minted rather than only the first — a caller + // fixing typos one round trip at a time is the failure mode a + // pre-lock guard exists to avoid. A dry run gets the same refusal: + // its job is to preview what the real run would do. + return optionConsentError(spaceId, pending[0].Property, pending[0].Name) + } + if len(pending) > v2MaxCreatedOptionsPerPatch { + props := map[string]int{} + for _, o := range pending { + props[o.Property]++ + } + issue := v2model.Issue{ + Path: "/ops", + Message: fmt.Sprintf("this batch would create %d new options (limit %d): %s", + len(pending), v2MaxCreatedOptionsPerPatch, describeCreateCounts(props)), + Hint: "creating an option is permanent and there is no delete surface — " + + "check the names against GET /v2/spaces/{space_id}/properties/{property_key}/options, " + + "or set values in smaller batches if they are all genuinely new", + } + return v2model.ValidationFailed("too many new options in one request", issue) + } + if dryRun { + // the caller's own run is already create-free; it reports the same + // pending list, so re-validating here would only duplicate the work + return nil + } + // ORDERING: prove the batch applies before anything is created. The probe + // resolvers create nothing, so a failure here leaves the space untouched; + // the error is the same one the real pass would raise, in the same order. + edit, err := editFromRead(objectId, cur) + if err != nil { + return err + } + if _, err := s.applyPatchOps(ctx, spaceId, objectId, ops, ifMatch, edit, probe); err != nil { + return err + } + return nil +} + +// describeCreateCounts renders "status: 3, tag: 4997" for the over-limit +// message, so the caller can see which property the runaway array belongs to. +func describeCreateCounts(counts map[string]int) string { + keys := make([]string, 0, len(counts)) + for k := range counts { + keys = append(keys, k) + } + sort.Strings(keys) + parts := make([]string, 0, len(keys)) + for _, k := range keys { + parts = append(parts, fmt.Sprintf("%s: %d", k, counts[k])) + } + return strings.Join(parts, ", ") +} + +// editFromRead builds a dry-run editing session from a plain read: a private +// state reconstructed from the snapshot, never committed (C9). +func editFromRead(objectId string, cur apicore.ObjectRead) (apicore.ObjectEdit, error) { + st, err := state.NewDocFromSnapshot(objectId, &pb.ChangeSnapshot{Data: cur.Snapshot}) + if err != nil { + return apicore.ObjectEdit{}, fmt.Errorf("state from read snapshot: %w", err) + } + return apicore.ObjectEdit{SbType: cur.SbType, Heads: cur.Heads, State: st}, nil +} + +// applyPatchOps runs the whole PATCH against one editing session: the C11 +// write-safety guard, the ops (each validated with its ops[i] paths and +// applied to the state), the resolver error check, the flag-gated safety +// net, and the diff_stats. The caller commits (or, on dry run, discards) the +// state. +func (s *Service) applyPatchOps(ctx context.Context, spaceId, objectId string, ops []json.RawMessage, ifMatch string, edit apicore.ObjectEdit, resolvers *creatingResolvers) (*v2model.EditResult, error) { + if err := checkEditPreconditions(edit.SbType, edit.Heads, ifMatch); err != nil { + return nil, err + } + applier := newV2StateApplier(s, spaceId, objectId, edit.SbType, edit.State, resolvers) + beforeDoc, err := applier.begin() + if err != nil { + return nil, err + } + // the M7 render-work bound, checked against the authoritative view the + // begin() marshal just produced: refusing here costs one marshal — the + // same floor a GET pays — instead of the batch's whole product + if err := checkPatchRenderWork(ops, applier.view.blocks); err != nil { + return nil, err + } + for i, raw := range ops { + // the loop runs under the object lock: honour cancellation so an + // abandoned request stops holding it (review A′2) + if err := ctx.Err(); err != nil { + return nil, err + } + if err := applier.apply(i, raw); err != nil { + return nil, err + } + } + if err := resolvers.err(); err != nil { + return nil, fmt.Errorf("resolve document references: %w", err) + } + // reuse the view's document when the last op left it valid (review A′2) + afterDoc, err := applier.currentDoc() + if err != nil { + return nil, err + } + // R5: the whole-document net, on by default (review B′3) — catches what a + // payload fragment cannot see (V3 containment, the document-wide id + // domain, the absolute depth bound) + if err := validateEditedDoc(objectId, afterDoc); err != nil { + return nil, err + } + stats, err := diffEditDocs(beforeDoc, afterDoc) + if err != nil { + return nil, err + } + result := &v2model.EditResult{Created: resolvers.created(), DiffStats: stats} + if len(applier.createdBlocks) > 0 { + result.CreatedBlocks = applier.createdBlocks + } + if len(applier.createdViews) > 0 { + result.CreatedViews = applier.createdViews + } + result.Warnings = applier.warnings + return result, nil +} + +// parsePatchRequest decodes the PATCH body strictly. +func parsePatchRequest(body []byte) ([]json.RawMessage, error) { + fields, err := parseEnvelope(body) + if err != nil { + return nil, v2model.ValidationFailed("the PATCH body must be a JSON object", + v2model.Issue{Message: err.Error(), Hint: `send {"ops": [...]} — GET /v2/schemas/ops/{op} documents each op`}) + } + for key := range fields { + if key != "ops" { + return nil, v2model.ValidationFailed("unknown field in PATCH body", + v2model.Issue{Path: "/" + key, Message: fmt.Sprintf("unknown key %q — the PATCH body carries only ops", key), Hint: "the If-Match precondition is a header, not a body field (C7)"}) + } + } + var req v2PatchRequest + if err := json.Unmarshal(body, &req); err != nil { + return nil, v2model.ValidationFailed("decode ops: " + err.Error()) + } + if len(req.Ops) == 0 { + return nil, v2model.ValidationFailed("ops must not be empty", + v2model.Issue{Path: "/ops", Message: "give at least one op", Hint: "allowed ops: " + joinOpNames()}) + } + // bound the batch: every op re-renders the document view under the object + // lock, so an unbounded batch is a self-inflicted DoS (review A′2/B6). + if len(req.Ops) > v2MaxOpsPerPatch { + return nil, v2model.ValidationFailed("too many ops in one PATCH", + v2model.Issue{ + Path: "/ops", + Message: fmt.Sprintf("%d ops exceeds the %d-op limit", len(req.Ops), v2MaxOpsPerPatch), + Hint: "split the edit across several PATCH requests", + }) + } + return req.Ops, nil +} + +// checkEditPreconditions applies the C7 If-Match check against the live +// heads (advisory: absent = last-write-wins) and the canUpdateObject system +// exclusions (system-managed smartblock types are not editable through the +// generic object surface). +func checkEditPreconditions(sbType model.SmartBlockType, heads []string, ifMatch string) error { + switch sbType { + case model.SmartBlockType_STRelation, model.SmartBlockType_STRelationOption, + model.SmartBlockType_FileObject, model.SmartBlockType_Participant: + return v2model.ValidationFailed( + fmt.Sprintf("this object is system-managed (%s) and cannot be edited through the object surface", sbType.String()), + v2model.Issue{Message: "properties, types and files have their own endpoints"}) + } + if !EtagMatches(ifMatch, heads) { + return v2model.EtagMismatch(ComputeEtag(heads)) + } + return nil +} + +func joinOpNames() string { + out := "" + for i, name := range v2OpNames { + if i > 0 { + out += ", " + } + out += name + } + return out +} diff --git a/core/api/v2/service/edit_test.go b/core/api/v2/service/edit_test.go new file mode 100644 index 0000000000..92534367af --- /dev/null +++ b/core/api/v2/service/edit_test.go @@ -0,0 +1,2342 @@ +package v2service + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + + apicore "github.com/anyproto/anytype-heart/core/api/core" + v2model "github.com/anyproto/anytype-heart/core/api/v2/model" + "github.com/anyproto/anytype-heart/core/block/editor/state" + "github.com/anyproto/anytype-heart/core/block/editor/template" + "github.com/anyproto/anytype-heart/core/block/restriction" + "github.com/anyproto/anytype-heart/core/domain" + "github.com/anyproto/anytype-heart/pb" + "github.com/anyproto/anytype-heart/pkg/lib/anyblockjson" + "github.com/anyproto/anytype-heart/pkg/lib/bundle" + "github.com/anyproto/anytype-heart/pkg/lib/pb/model" + "github.com/anyproto/anytype-heart/util/pbtypes" +) + +// editBaseDoc is the base test document: a heading, a parent paragraph with +// one child, and a sibling whose text has two "Q3" occurrences. +const editBaseDoc = `{"version":1,"id":"obj1","type":"page","properties":{"name":"Doc","description":"about"},"blocks":[` + + `{"id":"blockHeading1","type":"heading_1","text":"Section"},` + + `{"id":"blockParent1","type":"paragraph","text":"parent"},` + + `{"indent":1,"id":"blockChild1","type":"paragraph","text":"child"},` + + `{"id":"blockSibling2","type":"paragraph","text":"the Q3 report and Q3 plan"}]}` + +// editTableDoc holds one 2x2 table. +const editTableDoc = `{"version":1,"id":"obj1","type":"page","blocks":[` + + `{"id":"tblOne1","type":"table",` + + `"columns":[{"id":"colA"},{"id":"colB"}],` + + `"rows":[{"id":"rowH","is_header":true,"cells":["Name","Status"]},{"id":"rowB","cells":["Export"]}]}]}` + +// editEmptyDoc has no blocks at all — SPEC §7 keeps title/description out of +// the document, so a fresh object has zero addressable blocks. +const editEmptyDoc = `{"version":1,"id":"obj1","type":"page","properties":{"name":"Empty"},"blocks":[]}` + +// editSoleParentDoc is one top-level block with one child: the document is +// exactly one subtree, so moving that subtree leaves no other block to anchor +// against. +const editSoleParentDoc = `{"version":1,"id":"obj1","type":"page","blocks":[` + + `{"id":"blockParent1","type":"paragraph","text":"parent"},` + + `{"indent":1,"id":"blockChild1","type":"paragraph","text":"child"}]}` + +// editTwinDoc mirrors §5.4's locator eval doc: two sections whose body text +// is IDENTICAL ("Budget: TBD" under both Planning and Execution). A fixture +// whose blocks all have distinct text cannot catch the ambiguity refusal — +// a resolver that guesses the first match would sail through it — so the +// twin text is the point of this document. +const editTwinDoc = `{"version":1,"id":"obj1","type":"page","blocks":[` + + `{"id":"secPlanning1","type":"heading_1","text":"Planning"},` + + `{"id":"budgetPlan1","type":"paragraph","text":"Budget: TBD"},` + + `{"id":"secExec1","type":"heading_1","text":"Execution"},` + + `{"id":"budgetExec1","type":"paragraph","text":"Budget: TBD"}]}` + +// editChecklistDoc is §5.1's checkbox case ("check 'Draft timeline' under +// Planning"): two sections of checkbox items, every text distinct. FIVE +// blocks, and the assertions read the whole checked vector — a single-block +// fixture, or one that asserted only the target, could not catch a locator +// that toggled the wrong box. +const editChecklistDoc = `{"version":1,"id":"obj1","type":"page","blocks":[` + + `{"id":"secPlanning1","type":"heading_1","text":"Planning"},` + + `{"id":"taskDraft1","type":"checkbox","text":"Draft timeline"},` + + `{"id":"taskBudget1","type":"checkbox","text":"Budget review"},` + + `{"id":"secExec1","type":"heading_1","text":"Execution"},` + + `{"id":"taskShip1","type":"checkbox","text":"Ship the release"}]}` + +// editTableCellChildDoc holds a table whose only cell is the F10 array form: +// a toggle cell block (no id — derived) with one minted-id DESCENDANT. Cell +// descendants render as flat blocks, carry ids and are in the relabel pool — +// the id population the docLocalIds comment used to deny existed. +const editTableCellChildDoc = `{"version":1,"id":"obj1","type":"page","blocks":[` + + `{"id":"tblOne1","type":"table",` + + `"columns":[{"id":"colA"}],` + + `"rows":[{"id":"rowA","cells":[[{"type":"toggle","text":"cell"},` + + `{"indent":1,"id":"0000000000000000000dddd1","type":"paragraph","text":"inside"}]]}]}]}` + +// editMintedDoc mirrors the base document with editor-shaped (24-hex) block +// ids — the documents whose default read serves compact labels. +const editMintedDoc = `{"version":1,"id":"obj1","type":"page","blocks":[` + + `{"id":"0000000000000000000aaaa1","type":"heading_1","text":"Section"},` + + `{"id":"0000000000000000000bbbb1","type":"paragraph","text":"parent"}]}` + +// editCollectionDoc is a collection with one member. +const editCollectionDoc = `{"version":1,"id":"obj1","type":"collection","properties":{"name":"List"},"items":["memberA"]}` + +// editLayoutDoc holds a row/column layout — the V3 containment case a payload +// fragment cannot see on its own (review B′1). +const editLayoutDoc = `{"version":1,"id":"obj1","type":"page","blocks":[` + + `{"id":"rowOne1","type":"row"},` + + `{"indent":1,"id":"colOne1","type":"column"},` + + `{"indent":2,"id":"inCol1","type":"paragraph","text":"in column"}]}` + +// editManyBlocksDoc builds a document of n filler paragraphs after the base +// heading/parent/child/sibling four — the pre-existing-large-document shape +// of the M7 render-work bound. +func editManyBlocksDoc(n int) string { + var sb strings.Builder + sb.WriteString(`{"version":1,"id":"obj1","type":"page","properties":{"name":"Doc"},"blocks":[` + + `{"id":"blockHeading1","type":"heading_1","text":"Section"},` + + `{"id":"blockParent1","type":"paragraph","text":"parent"},` + + `{"indent":1,"id":"blockChild1","type":"paragraph","text":"child"},` + + `{"id":"blockSibling2","type":"paragraph","text":"the Q3 report and Q3 plan"}`) + for i := 0; i < n; i++ { + fmt.Fprintf(&sb, `,{"id":"filler%06d","type":"paragraph","text":"filler %d"}`, i, i) + } + sb.WriteString(`]}`) + return sb.String() +} + +// editRead builds a live read from an AnyBlock document. +func editRead(t *testing.T, doc string) apicore.ObjectRead { + t.Helper() + sbType, snapshot, err := anyblockjson.Unmarshal([]byte(doc), anyblockjson.Options{}) + require.NoError(t, err) + return apicore.ObjectRead{SbType: sbType, Snapshot: snapshot, Heads: []string{"headA"}} +} + +// blocksRefusedProduction mirrors the adapter's checkRestriction output for +// the Blocks axis (core/api/objectmutateadapter.go): a bare error chain +// wrapping restriction.ErrRestricted, NOT a ready-made *v2model.Error. The +// earlier fixture fed a v2model.Error here, which kept the refusal tests +// green while production fell through to a 500 (surface review M2a) — the +// refusal test must eat what the adapter actually cooks. +func blocksRefusedProduction() error { + return fmt.Errorf("%w: this object's blocks cannot be edited through the API", + fmt.Errorf("%w: %s", restriction.ErrRestricted, model.Restrictions_Blocks.String())) +} + +// expectMutate wires the mutator mock the way the adapter behaves: apply +// runs against a state built from read's snapshot and, on success, that +// mutated state is captured for assertions and newHeads reported. +func (fx *v2Fixture) expectMutate(read apicore.ObjectRead, newHeads ...string) **state.State { + return fx.expectMutateState(read, nil, newHeads...) +} + +// expectMutateHeader is expectMutate over a state that carries the structural +// header — the header wrapper and its title block, which every real page gets +// from template.InitTemplate at first open and which SPEC §7 keeps out of the +// served document. A document built from an AnyBlock snapshot alone has none, +// so nothing else in this file can see a placement rule that depends on it. +func (fx *v2Fixture) expectMutateHeader(read apicore.ObjectRead, newHeads ...string) **state.State { + return fx.expectMutateState(read, func(st *state.State) { + template.InitTemplate(st, template.WithTitle) + }, newHeads...) +} + +func (fx *v2Fixture) expectMutateState(read apicore.ObjectRead, prepare func(*state.State), newHeads ...string) **state.State { + var captured *state.State + // PatchObject reads the object and checks preconditions BEFORE prewarming + // create-missing refs and taking the lock (review A′1), so every PATCH + // test needs the read wired. + fx.readerMock.EXPECT().ReadObject(mock.Anything, testSpaceId, "obj1").Return(read, nil).Maybe() + fx.mutatorMock.EXPECT().MutateObject(mock.Anything, testSpaceId, "obj1", mock.Anything, mock.Anything). + RunAndReturn(func(ctx context.Context, spaceId, objectId string, needs apicore.EditNeeds, apply func(apicore.ObjectEdit) error) ([]string, error) { + st, err := state.NewDocFromSnapshot(objectId, &pb.ChangeSnapshot{Data: read.Snapshot}) + if err != nil { + return nil, err + } + if prepare != nil { + prepare(st) + } + if err := apply(apicore.ObjectEdit{SbType: read.SbType, Heads: read.Heads, State: st}); err != nil { + return nil, err + } + captured = st + return newHeads, nil + }) + return &captured +} + +// snapshotDoc marshals a captured snapshot back to its document form. +func snapshotDoc(t *testing.T, snapshot *model.SmartBlockSnapshotBase) map[string]any { + t.Helper() + require.NotNil(t, snapshot) + body, err := anyblockjson.Marshal(model.SmartBlockType_Page, snapshot, anyblockjson.Options{}) + require.NoError(t, err) + var doc map[string]any + require.NoError(t, json.Unmarshal(body, &doc)) + return doc +} + +// stateDoc marshals a captured edit state back to its document form. +func stateDoc(t *testing.T, st *state.State) map[string]any { + t.Helper() + require.NotNil(t, st) + return snapshotDoc(t, snapshotFromState(st)) +} + +// docBlocks extracts the blocks array of a marshaled document. +func docBlocks(doc map[string]any) []map[string]any { + raw, _ := doc["blocks"].([]any) + out := make([]map[string]any, 0, len(raw)) + for _, b := range raw { + out = append(out, b.(map[string]any)) + } + return out +} + +func blockTexts(blocks []map[string]any) []string { + out := make([]string, len(blocks)) + for i, b := range blocks { + out[i], _ = b["text"].(string) + } + return out +} + +// blockChecked reads the whole document's checkbox state (the exporter omits +// a false `checked`, so absent reads as false). Asserting the VECTOR is what +// makes a wrong-block match visible. +func blockChecked(blocks []map[string]any) []bool { + out := make([]bool, len(blocks)) + for i, b := range blocks { + out[i], _ = b["checked"].(bool) + } + return out +} + +func patchBody(ops ...string) []byte { + return []byte(`{"ops":[` + joinStrings(ops, ",") + `]}`) +} + +func joinStrings(parts []string, sep string) string { + out := "" + for i, p := range parts { + if i > 0 { + out += sep + } + out += p + } + return out +} + +func TestPatchObject(t *testing.T) { + ctx := context.Background() + + t.Run("update_block merges fields, suffix-addressed", func(t *testing.T) { + // given + fx := newV2Fixture(t) + captured := fx.expectMutate(editRead(t, editBaseDoc), "headB") + want := v2model.DiffStats{BlocksChanged: 1} + + // when + result, err := fx.PatchObject(ctx, testSpaceId, "obj1", + patchBody(`{"op":"update_block","id":"Child1","set":{"text":"edited child"}}`), "", false, true) + + // then + require.NoError(t, err) + assert.Equal(t, ComputeEtag([]string{"headB"}), result.Etag) + assert.Equal(t, want, result.DiffStats) + assert.Empty(t, result.CreatedBlocks) + blocks := docBlocks(stateDoc(t, *captured)) + assert.Equal(t, []string{"Section", "parent", "edited child", "the Q3 report and Q3 plan"}, blockTexts(blocks)) + assert.Equal(t, "blockChild1", blocks[2]["id"], "the block id survives the merge") + }) + + t.Run("update_block rejects indent and id", func(t *testing.T) { + fx := newV2Fixture(t) + fx.expectMutate(editRead(t, editBaseDoc)) + + _, err := fx.PatchObject(ctx, testSpaceId, "obj1", + patchBody(`{"op":"update_block","id":"blockChild1","set":{"indent":2}}`), "", false, true) + + apiErr := v2Err(t, err) + assert.Equal(t, http.StatusBadRequest, apiErr.Status) + assert.Contains(t, apiErr.Issues[0].Message, "use move_block") + assert.Equal(t, "ops[0].set.indent", apiErr.Issues[0].Path) + }) + + t.Run("insert_blocks after anchor with nested payload mints ids", func(t *testing.T) { + // given + fx := newV2Fixture(t) + captured := fx.expectMutate(editRead(t, editBaseDoc), "headB") + + // when + result, err := fx.PatchObject(ctx, testSpaceId, "obj1", + patchBody(`{"op":"insert_blocks","after":"blockHeading1","blocks":[{"type":"checkbox","text":"todo"},{"indent":1,"type":"paragraph","text":"note"}]}`), "", false, true) + + // then + require.NoError(t, err) + assert.Equal(t, v2model.DiffStats{BlocksAdded: 2}, result.DiffStats) + require.Len(t, result.CreatedBlocks, 2) + assert.Len(t, result.CreatedBlocks["ops[0].blocks[0]"], 24, "minted id is editor-shaped") + assert.Len(t, result.CreatedBlocks["ops[0].blocks[1]"], 24) + blocks := docBlocks(stateDoc(t, *captured)) + assert.Equal(t, []string{"Section", "todo", "note", "parent", "child", "the Q3 report and Q3 plan"}, blockTexts(blocks)) + assert.Equal(t, float64(1), blocks[2]["indent"], "payload indent is relative to the anchor level") + }) + + t.Run("an insert_blocks payload id is refused as not part of the op", func(t *testing.T) { + // reproduced before payload id resolution: a compact label ("bbbb1") + // copied from a default read into an insert_blocks payload was stored + // as the literal block id, and matchBlockRef resolves exact matches + // FIRST — so the adopted label captured the reference and the next + // replace_text on "bbbb1" edited the copy while the original block + // silently lost its label. Resolution closed that (payloadids.go), + // which left the slot with NO working value at all — so the field is + // gone from the op's schema (§8.30) and the refusal says so rather + // than reporting a duplicate the caller could not have foreseen. + fx := newV2Fixture(t) + fx.expectMutate(editRead(t, editMintedDoc), "headB") + + _, err := fx.PatchObject(ctx, testSpaceId, "obj1", + patchBody(`{"op":"insert_blocks","blocks":[{"id":"bbbb1","type":"paragraph","text":"copy"}]}`), "", false, true) + + apiErr := v2Err(t, err) + assert.Equal(t, http.StatusBadRequest, apiErr.Status) + require.NotEmpty(t, apiErr.Issues) + assert.Equal(t, "ops[0].blocks[0].id", apiErr.Issues[0].Path) + assert.Contains(t, apiErr.Issues[0].Message, `"bbbb1"`) + assert.Contains(t, apiErr.Issues[0].Message, "not part of this op") + assert.Contains(t, apiErr.Issues[0].Hint, "created_blocks", "the mint escape hatch is named") + }) + + t.Run("insert_blocks inside position first lands at the child level", func(t *testing.T) { + fx := newV2Fixture(t) + captured := fx.expectMutate(editRead(t, editBaseDoc), "headB") + + result, err := fx.PatchObject(ctx, testSpaceId, "obj1", + patchBody(`{"op":"insert_blocks","inside":"blockParent1","position":"first","blocks":[{"type":"paragraph","text":"first child"}]}`), "", false, true) + + require.NoError(t, err) + assert.Equal(t, v2model.DiffStats{BlocksAdded: 1}, result.DiffStats) + blocks := docBlocks(stateDoc(t, *captured)) + assert.Equal(t, []string{"Section", "parent", "first child", "child", "the Q3 report and Q3 plan"}, blockTexts(blocks)) + assert.Equal(t, float64(1), blocks[2]["indent"], "inside: payload indent 0 = the container's child level") + }) + + t.Run("insert_blocks with more than one target is ambiguous", func(t *testing.T) { + fx := newV2Fixture(t) + fx.expectMutate(editRead(t, editBaseDoc)) + + _, err := fx.PatchObject(ctx, testSpaceId, "obj1", + patchBody(`{"op":"insert_blocks","after":"blockHeading1","inside":"blockParent1","blocks":[{"type":"paragraph","text":"x"}]}`), "", false, true) + + apiErr := v2Err(t, err) + assert.Equal(t, v2model.CodeAmbiguousInput, apiErr.Code) + assert.Contains(t, apiErr.Message, "at most one of after, before, inside") + }) + + t.Run("insert_blocks with no anchor appends at the document end", func(t *testing.T) { + // given + fx := newV2Fixture(t) + captured := fx.expectMutate(editRead(t, editBaseDoc), "headB") + + // when: no after/before/inside — root-append, with a nested payload + result, err := fx.PatchObject(ctx, testSpaceId, "obj1", + patchBody(`{"op":"insert_blocks","blocks":[{"type":"paragraph","text":"appended"},{"indent":1,"type":"paragraph","text":"nested"}]}`), "", false, true) + + // then + require.NoError(t, err) + assert.Equal(t, v2model.DiffStats{BlocksAdded: 2}, result.DiffStats) + blocks := docBlocks(stateDoc(t, *captured)) + assert.Equal(t, []string{"Section", "parent", "child", "the Q3 report and Q3 plan", "appended", "nested"}, blockTexts(blocks)) + _, hasIndent := blocks[4]["indent"] + assert.False(t, hasIndent, "root-append lands at document level 0") + assert.Equal(t, float64(1), blocks[5]["indent"], "payload indent stays relative to the insertion level") + }) + + t.Run("insert_blocks with no anchor gives an empty object its first content", func(t *testing.T) { + // given: zero blocks — nothing is addressable, so anchored targeting + // cannot work and PUT used to be the only way in (the corruption vector) + fx := newV2Fixture(t) + captured := fx.expectMutate(editRead(t, editEmptyDoc), "headB") + + // when + result, err := fx.PatchObject(ctx, testSpaceId, "obj1", + patchBody(`{"op":"insert_blocks","blocks":[{"type":"heading_1","text":"First"},{"type":"paragraph","text":"body"}]}`), "", false, true) + + // then + require.NoError(t, err) + assert.Equal(t, v2model.DiffStats{BlocksAdded: 2}, result.DiffStats) + blocks := docBlocks(stateDoc(t, *captured)) + assert.Equal(t, []string{"First", "body"}, blockTexts(blocks)) + }) + + // §8.32: `position` with no targeting field was a guaranteed 400, and it + // is the shape gemma4:e2b produced on 20 payloads — 10 of 10 in each of + // the two cases that reach for it (add a section at the end, copy this + // block as new content). The published description ("with inside only") + // reads as "last is the default, so naming it is harmless". It now names + // an end of the DOCUMENT, which is both the obvious intent and the only + // expression of "insert at the beginning" that needs no prior read. + t.Run("insert_blocks position last with no target appends at the document end", func(t *testing.T) { + // given — the exact payload shape the probe recorded + fx := newV2Fixture(t) + captured := fx.expectMutate(editRead(t, editBaseDoc), "headB") + + // when + result, err := fx.PatchObject(ctx, testSpaceId, "obj1", + patchBody(`{"op":"insert_blocks","markdown":"## Risks","position":"last"}`), "", false, true) + + // then + require.NoError(t, err) + assert.Equal(t, v2model.DiffStats{BlocksAdded: 1}, result.DiffStats) + blocks := docBlocks(stateDoc(t, *captured)) + assert.Equal(t, []string{"Section", "parent", "child", "the Q3 report and Q3 plan", "Risks"}, blockTexts(blocks)) + }) + + t.Run("insert_blocks position first with no target inserts at the document start", func(t *testing.T) { + // given + fx := newV2Fixture(t) + captured := fx.expectMutate(editRead(t, editBaseDoc), "headB") + + // when: the direction that used to require reading the document first + // just to learn the id of the block to sit before + result, err := fx.PatchObject(ctx, testSpaceId, "obj1", + patchBody(`{"op":"insert_blocks","position":"first","blocks":[{"type":"heading_2","text":"Summary"},{"indent":1,"type":"paragraph","text":"note"}]}`), "", false, true) + + // then + require.NoError(t, err) + assert.Equal(t, v2model.DiffStats{BlocksAdded: 2}, result.DiffStats) + blocks := docBlocks(stateDoc(t, *captured)) + assert.Equal(t, []string{"Summary", "note", "Section", "parent", "child", "the Q3 report and Q3 plan"}, blockTexts(blocks)) + _, hasIndent := blocks[0]["indent"] + assert.False(t, hasIndent, "root-first lands at document level 0") + assert.Equal(t, float64(1), blocks[1]["indent"], "payload indent stays relative to the insertion level") + }) + + t.Run("insert_blocks position first keeps the structural header above it", func(t *testing.T) { + // given: a real page's state root holds the header (title, description + // — SPEC §7 keeps it OUT of the served document) as its FIRST child, so + // prepending at the state root would land the block above the title + fx := newV2Fixture(t) + captured := fx.expectMutateHeader(editRead(t, editBaseDoc), "headB") + + // when + _, err := fx.PatchObject(ctx, testSpaceId, "obj1", + patchBody(`{"op":"insert_blocks","position":"first","blocks":[{"type":"paragraph","text":"top"}]}`), "", false, true) + + // then + require.NoError(t, err) + st := *captured + require.NotNil(t, st) + assert.Equal(t, template.HeaderLayoutId, st.Pick(st.RootId()).Model().ChildrenIds[0], + "the header stays the first child of the state root") + assert.Equal(t, []string{"top", "Section", "parent", "child", "the Q3 report and Q3 plan"}, + blockTexts(docBlocks(stateDoc(t, st))), "and the block is first in the document") + }) + + t.Run("insert_blocks position first on an empty object is the same slot as last", func(t *testing.T) { + fx := newV2Fixture(t) + captured := fx.expectMutate(editRead(t, editEmptyDoc), "headB") + + result, err := fx.PatchObject(ctx, testSpaceId, "obj1", + patchBody(`{"op":"insert_blocks","position":"first","blocks":[{"type":"paragraph","text":"only"}]}`), "", false, true) + + require.NoError(t, err) + assert.Equal(t, v2model.DiffStats{BlocksAdded: 1}, result.DiffStats) + assert.Equal(t, []string{"only"}, blockTexts(docBlocks(stateDoc(t, *captured)))) + }) + + t.Run("insert_blocks position with no target still rejects a value outside the enum", func(t *testing.T) { + fx := newV2Fixture(t) + fx.expectMutate(editRead(t, editBaseDoc)) + + _, err := fx.PatchObject(ctx, testSpaceId, "obj1", + patchBody(`{"op":"insert_blocks","position":"middle","blocks":[{"type":"paragraph","text":"x"}]}`), "", false, true) + + apiErr := v2Err(t, err) + assert.Equal(t, "invalid position", apiErr.Message) + assert.Equal(t, "ops[0].position", apiErr.Issues[0].Path) + assert.Contains(t, apiErr.Issues[0].Hint, "first, last") + }) + + t.Run("insert_blocks position alongside after is still refused", func(t *testing.T) { + // the anchor already names the slot — this one is unchanged + fx := newV2Fixture(t) + fx.expectMutate(editRead(t, editBaseDoc)) + + _, err := fx.PatchObject(ctx, testSpaceId, "obj1", + patchBody(`{"op":"insert_blocks","after":"blockHeading1","position":"first","blocks":[{"type":"paragraph","text":"x"}]}`), "", false, true) + + apiErr := v2Err(t, err) + assert.Contains(t, apiErr.Message, "position only applies to inside") + assert.Equal(t, "ops[0].position", apiErr.Issues[0].Path) + }) + + t.Run("move_block position first with no target moves the block to the document start", func(t *testing.T) { + fx := newV2Fixture(t) + captured := fx.expectMutate(editRead(t, editBaseDoc), "headB") + + result, err := fx.PatchObject(ctx, testSpaceId, "obj1", + patchBody(`{"op":"move_block","id":"blockParent1","position":"first"}`), "", false, true) + + require.NoError(t, err) + // every reordered block counts as moved (the documented diff rule) + assert.Equal(t, v2model.DiffStats{BlocksMoved: 3}, result.DiffStats) + blocks := docBlocks(stateDoc(t, *captured)) + assert.Equal(t, []string{"parent", "child", "Section", "the Q3 report and Q3 plan"}, blockTexts(blocks)) + assert.Equal(t, float64(1), blocks[1]["indent"], "the subtree rides along") + }) + + t.Run("move_block position first on the block already first keeps the order", func(t *testing.T) { + // the moved subtree cannot anchor itself (InsertTo refuses its own + // target) — the anchor search skips it, so the op succeeds where it + // would otherwise have appended the block to the END + fx := newV2Fixture(t) + captured := fx.expectMutate(editRead(t, editBaseDoc), "headB") + + _, err := fx.PatchObject(ctx, testSpaceId, "obj1", + patchBody(`{"op":"move_block","id":"blockHeading1","position":"first"}`), "", false, true) + + require.NoError(t, err) + assert.Equal(t, []string{"Section", "parent", "child", "the Q3 report and Q3 plan"}, + blockTexts(docBlocks(stateDoc(t, *captured)))) + }) + + t.Run("move_block position first when the moved subtree is the whole document", func(t *testing.T) { + fx := newV2Fixture(t) + captured := fx.expectMutate(editRead(t, editSoleParentDoc), "headB") + + _, err := fx.PatchObject(ctx, testSpaceId, "obj1", + patchBody(`{"op":"move_block","id":"blockParent1","position":"first"}`), "", false, true) + + require.NoError(t, err) + assert.Equal(t, []string{"parent", "child"}, blockTexts(docBlocks(stateDoc(t, *captured)))) + }) + + t.Run("insert_blocks markdown payload is parsed into blocks (the authoring channel)", func(t *testing.T) { + // given + fx := newV2Fixture(t) + captured := fx.expectMutate(editRead(t, editBaseDoc), "headB") + + // when: markdown instead of blocks — same targeting, same pipeline + result, err := fx.PatchObject(ctx, testSpaceId, "obj1", + patchBody(`{"op":"insert_blocks","after":"blockHeading1","markdown":"- [ ] todo\n - sub item"}`), "", false, true) + + // then + require.NoError(t, err) + assert.Equal(t, v2model.DiffStats{BlocksAdded: 2}, result.DiffStats) + require.Len(t, result.CreatedBlocks, 2) + assert.Len(t, result.CreatedBlocks["ops[0].markdown[0]"], 24, "created ids are keyed by parsed position under markdown[j]") + assert.Len(t, result.CreatedBlocks["ops[0].markdown[1]"], 24) + blocks := docBlocks(stateDoc(t, *captured)) + assert.Equal(t, []string{"Section", "todo", "sub item", "parent", "child", "the Q3 report and Q3 plan"}, blockTexts(blocks)) + assert.Equal(t, "checkbox", blocks[1]["type"], "markdown checkbox syntax maps to a checkbox block") + assert.Equal(t, float64(1), blocks[2]["indent"], "markdown indentation nests") + }) + + t.Run("insert_blocks markdown root-append works on an empty object", func(t *testing.T) { + fx := newV2Fixture(t) + captured := fx.expectMutate(editRead(t, editEmptyDoc), "headB") + + result, err := fx.PatchObject(ctx, testSpaceId, "obj1", + patchBody(`{"op":"insert_blocks","markdown":"# First\n\nbody"}`), "", false, true) + + require.NoError(t, err) + assert.Equal(t, v2model.DiffStats{BlocksAdded: 2}, result.DiffStats) + blocks := docBlocks(stateDoc(t, *captured)) + assert.Equal(t, []string{"First", "body"}, blockTexts(blocks)) + assert.Equal(t, "heading_1", blocks[0]["type"]) + }) + + t.Run("insert_blocks with both blocks and markdown is ambiguous", func(t *testing.T) { + fx := newV2Fixture(t) + fx.expectMutate(editRead(t, editBaseDoc)) + + _, err := fx.PatchObject(ctx, testSpaceId, "obj1", + patchBody(`{"op":"insert_blocks","after":"blockHeading1","blocks":[{"type":"paragraph","text":"x"}],"markdown":"y"}`), "", false, true) + + apiErr := v2Err(t, err) + assert.Equal(t, v2model.CodeAmbiguousInput, apiErr.Code) + assert.Equal(t, "provide blocks or markdown, not both", apiErr.Message) + }) + + t.Run("insert_blocks with neither blocks nor markdown is rejected", func(t *testing.T) { + fx := newV2Fixture(t) + fx.expectMutate(editRead(t, editBaseDoc)) + + _, err := fx.PatchObject(ctx, testSpaceId, "obj1", + patchBody(`{"op":"insert_blocks","after":"blockHeading1"}`), "", false, true) + + apiErr := v2Err(t, err) + assert.Equal(t, "insert_blocks needs a payload", apiErr.Message) + assert.Contains(t, apiErr.Issues[0].Message, "markdown (parsed server-side)") + }) + + t.Run("insert_blocks blank markdown is rejected with a path", func(t *testing.T) { + fx := newV2Fixture(t) + fx.expectMutate(editRead(t, editBaseDoc)) + + _, err := fx.PatchObject(ctx, testSpaceId, "obj1", + patchBody(`{"op":"insert_blocks","after":"blockHeading1","markdown":" \n\n"}`), "", false, true) + + apiErr := v2Err(t, err) + assert.Equal(t, "markdown produced no blocks", apiErr.Message) + assert.Equal(t, "ops[0].markdown", apiErr.Issues[0].Path) + }) + + t.Run("insert_blocks markdown over the block cap is rejected with the limit", func(t *testing.T) { + // the markdown channel is byte-bounded, but 3 bytes encode one block — + // the parsed run must share the blocks channel's 256 cap + fx := newV2Fixture(t) + fx.expectMutate(editRead(t, editBaseDoc)) + + md := strings.Repeat(`- x\n`, v2MaxMarkdownBlocksPerOp+1) + _, err := fx.PatchObject(ctx, testSpaceId, "obj1", + patchBody(fmt.Sprintf(`{"op":"insert_blocks","after":"blockHeading1","markdown":"%s"}`, md)), "", false, true) + + apiErr := v2Err(t, err) + assert.Equal(t, "markdown produced too many blocks", apiErr.Message) + require.Len(t, apiErr.Issues, 1) + assert.Equal(t, "ops[0].markdown", apiErr.Issues[0].Path) + assert.Contains(t, apiErr.Issues[0].Message, "256") + assert.Contains(t, apiErr.Issues[0].Message, "split the content") + }) + + t.Run("move_block with no anchor moves the subtree to the end", func(t *testing.T) { + fx := newV2Fixture(t) + captured := fx.expectMutate(editRead(t, editBaseDoc), "headB") + + result, err := fx.PatchObject(ctx, testSpaceId, "obj1", + patchBody(`{"op":"move_block","id":"blockParent1"}`), "", false, true) + + require.NoError(t, err) + // both reordered siblings count as moved (the documented diff rule) + assert.Equal(t, v2model.DiffStats{BlocksMoved: 2}, result.DiffStats) + blocks := docBlocks(stateDoc(t, *captured)) + assert.Equal(t, []string{"Section", "the Q3 report and Q3 plan", "parent", "child"}, blockTexts(blocks)) + assert.Equal(t, float64(1), blocks[3]["indent"], "the subtree rides along") + }) + + t.Run("insert_blocks inside a leaf block is rejected", func(t *testing.T) { + fx := newV2Fixture(t) + fx.expectMutate(editRead(t, editTableDoc)) + + _, err := fx.PatchObject(ctx, testSpaceId, "obj1", + patchBody(`{"op":"insert_blocks","inside":"tblOne1","blocks":[{"type":"paragraph","text":"x"}]}`), "", false, true) + + apiErr := v2Err(t, err) + assert.Contains(t, apiErr.Message, `"table" blocks cannot have children`) + }) + + t.Run("payload monotonicity violation names both indents", func(t *testing.T) { + fx := newV2Fixture(t) + fx.expectMutate(editRead(t, editBaseDoc)) + + _, err := fx.PatchObject(ctx, testSpaceId, "obj1", + patchBody(`{"op":"replace_subtree","id":"blockParent1","blocks":[{"type":"paragraph","text":"a"},{"indent":2,"type":"paragraph","text":"b"}]}`), "", false, true) + + apiErr := v2Err(t, err) + assert.Contains(t, apiErr.Message, "monotonic") + require.Len(t, apiErr.Issues, 1) + assert.Equal(t, "ops[0].blocks[1].indent", apiErr.Issues[0].Path) + assert.Contains(t, apiErr.Issues[0].Message, "indent 2 follows indent 0") + }) + + t.Run("replace_subtree swaps block and descendants", func(t *testing.T) { + fx := newV2Fixture(t) + captured := fx.expectMutate(editRead(t, editBaseDoc), "headB") + + result, err := fx.PatchObject(ctx, testSpaceId, "obj1", + patchBody(`{"op":"replace_subtree","id":"blockParent1","blocks":[{"type":"bulleted_list_item","text":"a"},{"indent":1,"type":"paragraph","text":"b"}]}`), "", false, true) + + require.NoError(t, err) + assert.Equal(t, v2model.DiffStats{BlocksAdded: 2, BlocksRemoved: 2}, result.DiffStats) + blocks := docBlocks(stateDoc(t, *captured)) + assert.Equal(t, []string{"Section", "a", "b", "the Q3 report and Q3 plan"}, blockTexts(blocks)) + }) + + t.Run("update_block retypes a block keeping id, position and descendants", func(t *testing.T) { + // migrated from replaceBlock (folded into update_block, v0.3.5) + fx := newV2Fixture(t) + captured := fx.expectMutate(editRead(t, editBaseDoc), "headB") + + result, err := fx.PatchObject(ctx, testSpaceId, "obj1", + patchBody(`{"op":"update_block","id":"blockParent1","set":{"type":"quote","text":"new **text**"}}`), "", false, true) + + require.NoError(t, err) + assert.Equal(t, v2model.DiffStats{BlocksChanged: 1}, result.DiffStats) + blocks := docBlocks(stateDoc(t, *captured)) + assert.Equal(t, "quote", blocks[1]["type"]) + assert.Equal(t, "blockParent1", blocks[1]["id"]) + assert.Equal(t, "new **text**", blocks[1]["text"]) + assert.Equal(t, "child", blocks[2]["text"], "descendants are kept") + assert.Equal(t, float64(1), blocks[2]["indent"]) + }) + + t.Run("update_block null clears a field, unnamed fields stay", func(t *testing.T) { + // the merge-with-null-clears semantics that made replaceBlock redundant + fx := newV2Fixture(t) + captured := fx.expectMutate(editRead(t, editBaseDoc), "headB") + + _, err := fx.PatchObject(ctx, testSpaceId, "obj1", + patchBody(`{"op":"update_block","id":"blockSibling2","set":{"text":null}}`), "", false, true) + + require.NoError(t, err) + blocks := docBlocks(stateDoc(t, *captured)) + text, _ := blocks[3]["text"].(string) + assert.Empty(t, text, "explicit null clears the text") + assert.Equal(t, "paragraph", blocks[3]["type"], "unnamed fields survive the merge") + }) + + t.Run("update_block to a leaf type with descendants names the count", func(t *testing.T) { + fx := newV2Fixture(t) + fx.expectMutate(editRead(t, editBaseDoc)) + + _, err := fx.PatchObject(ctx, testSpaceId, "obj1", + patchBody(`{"op":"update_block","id":"blockParent1","set":{"type":"divider"}}`), "", false, true) + + apiErr := v2Err(t, err) + assert.Contains(t, apiErr.Message, `cannot change block "blockParent1" to leaf type "divider"`) + assert.Contains(t, apiErr.Message, "1 descendant block") + }) + + t.Run("replaceBlock is gone and the hint names update_block", func(t *testing.T) { + // folded into update_block pre-release (v0.3.5) — the unknown-op error + // must steer an agent that learned the old vocabulary + fx := newV2Fixture(t) + fx.expectMutate(editRead(t, editBaseDoc)) + + _, err := fx.PatchObject(ctx, testSpaceId, "obj1", + patchBody(`{"op":"replaceBlock","id":"blockParent1","block":{"type":"quote"}}`), "", false, true) + + apiErr := v2Err(t, err) + assert.Contains(t, apiErr.Message, `unknown op "replaceBlock"`) + require.NotEmpty(t, apiErr.Issues) + assert.Contains(t, apiErr.Issues[0].Hint, "replaceBlock was removed — use update_block {id, set}") + assert.NotContains(t, apiErr.Issues[0].Hint, "allowed ops: set_properties, update_block, replaceBlock", + "the op list no longer carries replaceBlock") + }) + + t.Run("move_block inside moves the subtree and reindents", func(t *testing.T) { + fx := newV2Fixture(t) + captured := fx.expectMutate(editRead(t, editBaseDoc), "headB") + + result, err := fx.PatchObject(ctx, testSpaceId, "obj1", + patchBody(`{"op":"move_block","id":"blockSibling2","inside":"blockParent1","position":"last"}`), "", false, true) + + require.NoError(t, err) + assert.Equal(t, v2model.DiffStats{BlocksMoved: 1}, result.DiffStats) + blocks := docBlocks(stateDoc(t, *captured)) + assert.Equal(t, []string{"Section", "parent", "child", "the Q3 report and Q3 plan"}, blockTexts(blocks)) + assert.Equal(t, float64(1), blocks[3]["indent"], "moved under the parent") + }) + + t.Run("move_block into its own subtree is a cycle", func(t *testing.T) { + fx := newV2Fixture(t) + fx.expectMutate(editRead(t, editBaseDoc)) + + _, err := fx.PatchObject(ctx, testSpaceId, "obj1", + patchBody(`{"op":"move_block","id":"blockParent1","inside":"blockChild1"}`), "", false, true) + + apiErr := v2Err(t, err) + assert.Contains(t, apiErr.Message, "inside its own subtree") + }) + + t.Run("delete_block without recursive names the descendant count", func(t *testing.T) { + fx := newV2Fixture(t) + fx.expectMutate(editRead(t, editBaseDoc)) + + _, err := fx.PatchObject(ctx, testSpaceId, "obj1", + patchBody(`{"op":"delete_block","id":"blockParent1"}`), "", false, true) + + apiErr := v2Err(t, err) + assert.Contains(t, apiErr.Message, `block "blockParent1" has 1 descendant block`) + assert.Contains(t, apiErr.Message, `"recursive": true`) + }) + + t.Run("delete_block recursive removes the subtree", func(t *testing.T) { + fx := newV2Fixture(t) + captured := fx.expectMutate(editRead(t, editBaseDoc), "headB") + + result, err := fx.PatchObject(ctx, testSpaceId, "obj1", + patchBody(`{"op":"delete_block","id":"blockParent1","recursive":true}`), "", false, true) + + require.NoError(t, err) + assert.Equal(t, v2model.DiffStats{BlocksRemoved: 2}, result.DiffStats) + assert.Equal(t, []string{"Section", "the Q3 report and Q3 plan"}, blockTexts(docBlocks(stateDoc(t, *captured)))) + }) + + t.Run("replace_text no match steers to exact copy", func(t *testing.T) { + fx := newV2Fixture(t) + fx.expectMutate(editRead(t, editBaseDoc)) + + _, err := fx.PatchObject(ctx, testSpaceId, "obj1", + patchBody(`{"op":"replace_text","id":"blockSibling2","find":"Q5","replace":"Q6"}`), "", false, true) + + apiErr := v2Err(t, err) + assert.Contains(t, apiErr.Message, `no match found for "Q5" in block "blockSibling2"`) + }) + + t.Run("replace_text multiple matches asks for more context", func(t *testing.T) { + fx := newV2Fixture(t) + fx.expectMutate(editRead(t, editBaseDoc)) + + _, err := fx.PatchObject(ctx, testSpaceId, "obj1", + patchBody(`{"op":"replace_text","id":"blockSibling2","find":"Q3","replace":"Q4"}`), "", false, true) + + apiErr := v2Err(t, err) + assert.Contains(t, apiErr.Message, "found 2 matches") + assert.Contains(t, apiErr.Message, "provide more context") + assert.Contains(t, apiErr.Message, `"replace_all": true`) + }) + + t.Run("replace_text unique match replaces once", func(t *testing.T) { + fx := newV2Fixture(t) + captured := fx.expectMutate(editRead(t, editBaseDoc), "headB") + + result, err := fx.PatchObject(ctx, testSpaceId, "obj1", + patchBody(`{"op":"replace_text","id":"blockSibling2","find":"Q3 report","replace":"Q4 report"}`), "", false, true) + + require.NoError(t, err) + assert.Equal(t, v2model.DiffStats{BlocksChanged: 1}, result.DiffStats) + blocks := docBlocks(stateDoc(t, *captured)) + assert.Equal(t, "the Q4 report and Q3 plan", blocks[3]["text"]) + }) + + t.Run("replace_text replace_all replaces every match", func(t *testing.T) { + fx := newV2Fixture(t) + captured := fx.expectMutate(editRead(t, editBaseDoc), "headB") + + _, err := fx.PatchObject(ctx, testSpaceId, "obj1", + patchBody(`{"op":"replace_text","id":"blockSibling2","find":"Q3","replace":"Q4","replace_all":true}`), "", false, true) + + require.NoError(t, err) + blocks := docBlocks(stateDoc(t, *captured)) + assert.Equal(t, "the Q4 report and Q4 plan", blocks[3]["text"]) + }) + + // ---- find-as-locator (Wave 2.1a, §8.43): id omitted, find locates ---- + + t.Run("locator: omitted id resolves the one block containing find", func(t *testing.T) { + // the fixture has FOUR blocks and the snippet lives in the LAST one — + // a resolver that guessed the first block (or any fixed index) would + // either error or edit the wrong text, and the full-texts assertion + // would show it + fx := newV2Fixture(t) + captured := fx.expectMutate(editRead(t, editBaseDoc), "headB") + want := v2model.DiffStats{BlocksChanged: 1} + + result, err := fx.PatchObject(ctx, testSpaceId, "obj1", + patchBody(`{"op":"replace_text","find":"Q3 report","replace":"Q4 report"}`), "", false, true) + + require.NoError(t, err) + assert.Equal(t, want, result.DiffStats) + assert.Equal(t, []string{"Section", "parent", "child", "the Q4 report and Q3 plan"}, + blockTexts(docBlocks(stateDoc(t, *captured))), "the edit lands on the located block and nowhere else") + }) + + t.Run("locator: zero matches is a 404 steering to the outline read", func(t *testing.T) { + fx := newV2Fixture(t) + fx.expectMutate(editRead(t, editBaseDoc)) + + _, err := fx.PatchObject(ctx, testSpaceId, "obj1", + patchBody(`{"op":"replace_text","find":"Q9","replace":"Q4"}`), "", false, true) + + apiErr := v2Err(t, err) + assert.Equal(t, http.StatusNotFound, apiErr.Status) + assert.Equal(t, v2model.CodeNotFound, apiErr.Code) + assert.Contains(t, apiErr.Message, `no block contains "Q9"`) + assert.Contains(t, apiErr.Message, "copy the find text exactly, including inline markup") + assert.Contains(t, apiErr.Message, "markdown source", "the snippet may have missed only because of markup") + require.Len(t, apiErr.Issues, 1) + assert.Equal(t, "ops[0].find", apiErr.Issues[0].Path) + assert.Contains(t, apiErr.Issues[0].Hint, "?outline=true") + }) + + t.Run("locator: several matching blocks refuse and list the candidates", func(t *testing.T) { + // twin fixture: two blocks carry IDENTICAL text, so a guessed first + // match would succeed here — the test demands the refusal instead, + // with both blocks named for the retry + fx := newV2Fixture(t) + captured := fx.expectMutate(editRead(t, editTwinDoc)) + + _, err := fx.PatchObject(ctx, testSpaceId, "obj1", + patchBody(`{"op":"replace_text","find":"Budget: TBD","replace":"Budget: $40k"}`), "", false, true) + + apiErr := v2Err(t, err) + assert.Equal(t, http.StatusBadRequest, apiErr.Status) + assert.Equal(t, v2model.CodeAmbiguousInput, apiErr.Code) + assert.Contains(t, apiErr.Message, `"Budget: TBD" appears in 2 blocks`) + assert.Contains(t, apiErr.Message, "retry with id naming one of") + assert.Contains(t, apiErr.Message, "block budgetPlan1 (paragraph)") + assert.Contains(t, apiErr.Message, "block budgetExec1 (paragraph)") + require.Len(t, apiErr.Issues, 1) + assert.Equal(t, "ops[0].find", apiErr.Issues[0].Path) + assert.Nil(t, *captured, "a refusal never writes") + }) + + t.Run("locator: several occurrences within the ONE matching block get the more-context refusal, not ambiguity", func(t *testing.T) { + // "Q3" appears twice in blockSibling2 and nowhere else — a fixture + // where the snippet appears once per block could not tell this class + // (within-block multiplicity, replace_all's territory) apart from the + // several-blocks ambiguity above + fx := newV2Fixture(t) + fx.expectMutate(editRead(t, editBaseDoc)) + + _, err := fx.PatchObject(ctx, testSpaceId, "obj1", + patchBody(`{"op":"replace_text","find":"Q3","replace":"Q4"}`), "", false, true) + + apiErr := v2Err(t, err) + assert.Equal(t, v2model.CodeValidationFailed, apiErr.Code, "within-block multiplicity is not ambiguous_input") + assert.Contains(t, apiErr.Message, `found 2 matches for "Q3" in block "blockSibling2"`, + "the refusal names the RESOLVED block — a valid retry value") + assert.Contains(t, apiErr.Message, "provide more context") + assert.Contains(t, apiErr.Message, `"replace_all": true`) + }) + + t.Run("locator: replace_all resolves the block and replaces within it", func(t *testing.T) { + fx := newV2Fixture(t) + captured := fx.expectMutate(editRead(t, editBaseDoc), "headB") + + _, err := fx.PatchObject(ctx, testSpaceId, "obj1", + patchBody(`{"op":"replace_text","find":"Q3","replace":"Q4","replace_all":true}`), "", false, true) + + require.NoError(t, err) + blocks := docBlocks(stateDoc(t, *captured)) + assert.Equal(t, "the Q4 report and Q4 plan", blocks[3]["text"]) + }) + + t.Run("locator: replace_all never widens the locator across blocks", func(t *testing.T) { + // replace_all licenses every occurrence WITHIN the one matched block + // (§5.3); on the twin fixture the locator still refuses + fx := newV2Fixture(t) + captured := fx.expectMutate(editRead(t, editTwinDoc)) + + _, err := fx.PatchObject(ctx, testSpaceId, "obj1", + patchBody(`{"op":"replace_text","find":"Budget: TBD","replace":"Budget: $40k","replace_all":true}`), "", false, true) + + apiErr := v2Err(t, err) + assert.Equal(t, v2model.CodeAmbiguousInput, apiErr.Code) + assert.Nil(t, *captured, "a refusal never writes") + }) + + t.Run("locator mid-batch: op i resolves against op i-1's edits (in-place view path)", func(t *testing.T) { + // op 0 (itself a locator op, so the view is maintained in place — M7) + // INTRODUCES the only occurrence of "needle"; op 1 locates by it. A + // single-op test cannot catch mid-batch staleness: against the + // pre-batch document op 1 has zero matches and the batch would refuse. + fx := newV2Fixture(t) + captured := fx.expectMutate(editRead(t, editBaseDoc), "headB") + + _, err := fx.PatchObject(ctx, testSpaceId, "obj1", patchBody( + `{"op":"replace_text","find":"child","replace":"a needle appears"}`, + `{"op":"replace_text","find":"needle","replace":"pin"}`, + ), "", false, true) + + require.NoError(t, err) + assert.Equal(t, []string{"Section", "parent", "a pin appears", "the Q3 report and Q3 plan"}, + blockTexts(docBlocks(stateDoc(t, *captured)))) + }) + + t.Run("locator mid-batch: an earlier op's edit makes a later locator ambiguous — refuse, never the stale unique match", func(t *testing.T) { + // op 0 (a view-REBUILDING op, the other freshness path) writes "child" + // into a second block. Against the pre-batch document op 1's find is + // unique — so a resolver reading a stale view would silently edit + // blockChild1; the fresh view demands the ambiguity refusal naming + // both blocks. This is the silent-wrong-match failure the design + // exists to prevent, pinned in the direction that hurts. + fx := newV2Fixture(t) + captured := fx.expectMutate(editRead(t, editBaseDoc)) + + _, err := fx.PatchObject(ctx, testSpaceId, "obj1", patchBody( + `{"op":"update_block","id":"blockParent1","set":{"text":"child of mine"}}`, + `{"op":"replace_text","find":"child","replace":"kid"}`, + ), "", false, true) + + apiErr := v2Err(t, err) + assert.Equal(t, v2model.CodeAmbiguousInput, apiErr.Code) + assert.Contains(t, apiErr.Message, "appears in 2 blocks") + assert.Contains(t, apiErr.Message, "blockParent1") + assert.Contains(t, apiErr.Message, "blockChild1") + require.Len(t, apiErr.Issues, 1) + assert.Equal(t, "ops[1].find", apiErr.Issues[0].Path, "the failing op is the one addressed") + assert.Nil(t, *captured, "the whole batch refuses — nothing is committed") + }) + + t.Run("locator: a dry run resolves identically and commits nothing (C9)", func(t *testing.T) { + // no mutator expectation is wired at all — the dry run must never + // reach it; resolution runs at apply time on the private state + fx := newV2Fixture(t) + fx.readerMock.EXPECT().ReadObject(mock.Anything, testSpaceId, "obj1"). + Return(editRead(t, editBaseDoc), nil) + + result, err := fx.PatchObject(ctx, testSpaceId, "obj1", + patchBody(`{"op":"replace_text","find":"Q3 report","replace":"Q4 report"}`), "", true, true) + + require.NoError(t, err) + assert.True(t, result.DryRun) + assert.Equal(t, v2model.DiffStats{BlocksChanged: 1}, result.DiffStats) + }) + + // ---- match-as-locator (Wave 2.1b, §8.45): the id alternative on + // update_block and delete_block ---- + + t.Run("match: update_block toggles the one checkbox the text names", func(t *testing.T) { + // §5.1's own case. The fixture holds THREE checkboxes in two + // sections: a resolver that guessed a fixed index would check the + // wrong box, and the whole checked vector says which one moved + fx := newV2Fixture(t) + captured := fx.expectMutate(editRead(t, editChecklistDoc), "headB") + want := v2model.DiffStats{BlocksChanged: 1} + + result, err := fx.PatchObject(ctx, testSpaceId, "obj1", + patchBody(`{"op":"update_block","match":"Draft timeline","set":{"checked":true}}`), "", false, true) + + require.NoError(t, err) + assert.Equal(t, want, result.DiffStats) + blocks := docBlocks(stateDoc(t, *captured)) + assert.Equal(t, []bool{false, true, false, false, false}, blockChecked(blocks)) + assert.Equal(t, []string{"Planning", "Draft timeline", "Budget review", "Execution", "Ship the release"}, + blockTexts(blocks), "merge semantics: the located block keeps its text") + assert.Equal(t, "taskDraft1", blocks[1]["id"], "and its id") + }) + + t.Run("match: several matching blocks refuse and list the candidates", func(t *testing.T) { + // twin fixture: two blocks carry IDENTICAL text, so a resolver that + // took the first match would sail through — the refusal is the test + fx := newV2Fixture(t) + captured := fx.expectMutate(editRead(t, editTwinDoc)) + + _, err := fx.PatchObject(ctx, testSpaceId, "obj1", + patchBody(`{"op":"update_block","match":"Budget: TBD","set":{"text":"Budget: $40k"}}`), "", false, true) + + apiErr := v2Err(t, err) + assert.Equal(t, http.StatusBadRequest, apiErr.Status) + assert.Equal(t, v2model.CodeAmbiguousInput, apiErr.Code) + assert.Contains(t, apiErr.Message, `"Budget: TBD" appears in 2 blocks`) + assert.Contains(t, apiErr.Message, "block budgetPlan1 (paragraph)") + assert.Contains(t, apiErr.Message, "block budgetExec1 (paragraph)") + require.Len(t, apiErr.Issues, 1) + assert.Equal(t, "ops[0].match", apiErr.Issues[0].Path) + assert.Contains(t, apiErr.Issues[0].Hint, "add surrounding text to match", + "the repair names the caller's own field") + assert.Nil(t, *captured, "a refusal never writes") + }) + + t.Run("match: zero matches is a 404 steering to the outline read", func(t *testing.T) { + fx := newV2Fixture(t) + fx.expectMutate(editRead(t, editChecklistDoc)) + + _, err := fx.PatchObject(ctx, testSpaceId, "obj1", + patchBody(`{"op":"update_block","match":"Draft agenda","set":{"checked":true}}`), "", false, true) + + apiErr := v2Err(t, err) + assert.Equal(t, http.StatusNotFound, apiErr.Status) + assert.Contains(t, apiErr.Message, `no block contains "Draft agenda"`) + assert.Contains(t, apiErr.Message, "copy the match text exactly") + require.Len(t, apiErr.Issues, 1) + assert.Equal(t, "ops[0].match", apiErr.Issues[0].Path) + assert.Contains(t, apiErr.Issues[0].Hint, "?outline=true") + }) + + t.Run("match: repeats within the one matched block are not a refusal", func(t *testing.T) { + // "Q3" occurs twice in blockSibling2 and nowhere else. For + // replace_text that is the more-context refusal (it has to splice ONE + // occurrence); update_block addresses the BLOCK, which the text + // identifies perfectly well — a fixture where the snippet appeared + // once per block could not tell the two classes apart + fx := newV2Fixture(t) + captured := fx.expectMutate(editRead(t, editBaseDoc), "headB") + + result, err := fx.PatchObject(ctx, testSpaceId, "obj1", + patchBody(`{"op":"update_block","match":"Q3","set":{"color":"red"}}`), "", false, true) + + require.NoError(t, err) + assert.Equal(t, v2model.DiffStats{BlocksChanged: 1}, result.DiffStats) + blocks := docBlocks(stateDoc(t, *captured)) + assert.Equal(t, "red", blocks[3]["color"]) + assert.Equal(t, "the Q3 report and Q3 plan", blocks[3]["text"], "the text is untouched") + }) + + t.Run("match: id and match together are refused, never ranked", func(t *testing.T) { + // silent precedence is the failure shape this surface removes: with + // one winning, the other field is inert and the caller cannot tell + // which block was addressed + fx := newV2Fixture(t) + captured := fx.expectMutate(editRead(t, editChecklistDoc)) + + _, err := fx.PatchObject(ctx, testSpaceId, "obj1", + patchBody(`{"op":"update_block","id":"taskShip1","match":"Draft timeline","set":{"checked":true}}`), "", false, true) + + apiErr := v2Err(t, err) + assert.Equal(t, http.StatusBadRequest, apiErr.Status) + assert.Equal(t, v2model.CodeAmbiguousInput, apiErr.Code) + assert.Contains(t, apiErr.Message, "id or match, not both") + assert.Contains(t, apiErr.Message, "update_block") + require.Len(t, apiErr.Issues, 1) + assert.Equal(t, "ops[0]", apiErr.Issues[0].Path) + assert.Nil(t, *captured, "neither channel wins — nothing is written") + }) + + t.Run("match: neither id nor match names both channels", func(t *testing.T) { + // before 2.1b an id-less update_block resolved the empty string and + // reported `block "" not found` — a 404 naming nothing + fx := newV2Fixture(t) + fx.expectMutate(editRead(t, editChecklistDoc)) + + _, err := fx.PatchObject(ctx, testSpaceId, "obj1", + patchBody(`{"op":"update_block","set":{"checked":true}}`), "", false, true) + + apiErr := v2Err(t, err) + assert.Equal(t, v2model.CodeValidationFailed, apiErr.Code) + assert.Contains(t, apiErr.Message, "update_block needs a block to address") + assert.NotContains(t, apiErr.Message, `block "" not found`) + require.Len(t, apiErr.Issues, 1) + assert.Contains(t, apiErr.Issues[0].Message, "give id") + assert.Contains(t, apiErr.Issues[0].Message, "or match") + }) + + t.Run("match: an op may match the very text it rewrites", func(t *testing.T) { + // resolution runs before the merge — the only coherent order, and + // what makes a match-then-rename expressible at all + fx := newV2Fixture(t) + captured := fx.expectMutate(editRead(t, editBaseDoc), "headB") + + _, err := fx.PatchObject(ctx, testSpaceId, "obj1", + patchBody(`{"op":"update_block","match":"parent","set":{"text":"renamed parent"}}`), "", false, true) + + require.NoError(t, err) + assert.Equal(t, []string{"Section", "renamed parent", "child", "the Q3 report and Q3 plan"}, + blockTexts(docBlocks(stateDoc(t, *captured)))) + }) + + t.Run("match mid-batch: op i matches the text op i-1 WROTE", func(t *testing.T) { + // op 0 renames a block; op 1 matches the NEW text. Against the + // pre-batch document op 1 has zero matches, so a stale view 404s the + // batch — a single-op test cannot see this at all + fx := newV2Fixture(t) + captured := fx.expectMutate(editRead(t, editBaseDoc), "headB") + + _, err := fx.PatchObject(ctx, testSpaceId, "obj1", patchBody( + `{"op":"update_block","match":"child","set":{"text":"renamed leaf"}}`, + `{"op":"update_block","match":"renamed leaf","set":{"color":"red"}}`, + ), "", false, true) + + require.NoError(t, err) + blocks := docBlocks(stateDoc(t, *captured)) + assert.Equal(t, []string{"Section", "parent", "renamed leaf", "the Q3 report and Q3 plan"}, blockTexts(blocks)) + assert.Equal(t, "red", blocks[2]["color"], "both ops landed on the same block") + }) + + t.Run("match mid-batch: the text op i-1 OVERWROTE stops resolving", func(t *testing.T) { + // the same freshness, in the direction that must fail: op 0 rewrote + // "child" away, so op 1's match names text no block carries any more. + // A stale view would resolve it and edit a block whose content the + // batch already replaced + fx := newV2Fixture(t) + captured := fx.expectMutate(editRead(t, editBaseDoc)) + + _, err := fx.PatchObject(ctx, testSpaceId, "obj1", patchBody( + `{"op":"update_block","match":"child","set":{"text":"renamed leaf"}}`, + `{"op":"update_block","match":"child","set":{"color":"red"}}`, + ), "", false, true) + + apiErr := v2Err(t, err) + assert.Equal(t, v2model.CodeNotFound, apiErr.Code) + assert.Contains(t, apiErr.Message, `no block contains "child"`) + require.Len(t, apiErr.Issues, 1) + assert.Equal(t, "ops[1].match", apiErr.Issues[0].Path, "the failing op is the one addressed") + assert.Nil(t, *captured, "the whole batch refuses — op 0 is not committed either") + }) + + t.Run("match: delete_block removes the one block the text names", func(t *testing.T) { + // four blocks, and the remaining texts are asserted whole — deleting + // the wrong one is visible either way + fx := newV2Fixture(t) + captured := fx.expectMutate(editRead(t, editBaseDoc), "headB") + + result, err := fx.PatchObject(ctx, testSpaceId, "obj1", + patchBody(`{"op":"delete_block","match":"child"}`), "", false, true) + + require.NoError(t, err) + assert.Equal(t, v2model.DiffStats{BlocksRemoved: 1}, result.DiffStats) + assert.Equal(t, []string{"Section", "parent", "the Q3 report and Q3 plan"}, + blockTexts(docBlocks(stateDoc(t, *captured)))) + }) + + t.Run("match: a located parent still demands recursive, naming the RESOLVED id", func(t *testing.T) { + // the caller never sent an id, so the guard cannot name one back — + // `block ""` would be a retry value that resolves to nothing. The + // resolved full id always resolves + fx := newV2Fixture(t) + captured := fx.expectMutate(editRead(t, editBaseDoc)) + + _, err := fx.PatchObject(ctx, testSpaceId, "obj1", + patchBody(`{"op":"delete_block","match":"parent"}`), "", false, true) + + apiErr := v2Err(t, err) + assert.Contains(t, apiErr.Message, `block "blockParent1" has 1 descendant block`) + assert.Contains(t, apiErr.Message, `"recursive": true`) + assert.NotContains(t, apiErr.Message, `block ""`) + assert.Nil(t, *captured, "the guard refuses before anything is unlinked") + }) + + t.Run("match: a located parent with recursive deletes the whole subtree", func(t *testing.T) { + fx := newV2Fixture(t) + captured := fx.expectMutate(editRead(t, editBaseDoc), "headB") + + result, err := fx.PatchObject(ctx, testSpaceId, "obj1", + patchBody(`{"op":"delete_block","match":"parent","recursive":true}`), "", false, true) + + require.NoError(t, err) + assert.Equal(t, v2model.DiffStats{BlocksRemoved: 2}, result.DiffStats, + "the receipt counts the subtree, exactly as it does for an id") + assert.Equal(t, []string{"Section", "the Q3 report and Q3 plan"}, + blockTexts(docBlocks(stateDoc(t, *captured)))) + }) + + t.Run("match: an ambiguous delete refuses, and its candidate list is a usable retry", func(t *testing.T) { + // the destructive half of the one-match rule: on the twin fixture a + // guess would delete a coin-flip block. The candidates must not just + // be printed — the second PATCH replays one of them verbatim and must + // land on that exact block + fx := newV2Fixture(t) + captured := fx.expectMutate(editRead(t, editTwinDoc)) + + _, err := fx.PatchObject(ctx, testSpaceId, "obj1", + patchBody(`{"op":"delete_block","match":"Budget: TBD"}`), "", false, true) + + apiErr := v2Err(t, err) + assert.Equal(t, v2model.CodeAmbiguousInput, apiErr.Code) + assert.Contains(t, apiErr.Message, "retry with id naming one of") + assert.Contains(t, apiErr.Message, "block budgetExec1 (paragraph)") + assert.Nil(t, *captured, "nothing is deleted while the address is ambiguous") + + // when: the caller retries with a listed candidate + retry := newV2Fixture(t) + retried := retry.expectMutate(editRead(t, editTwinDoc), "headB") + + result, err := retry.PatchObject(ctx, testSpaceId, "obj1", + patchBody(`{"op":"delete_block","id":"budgetExec1"}`), "", false, true) + + require.NoError(t, err) + assert.Equal(t, v2model.DiffStats{BlocksRemoved: 1}, result.DiffStats) + assert.Equal(t, []string{"Planning", "Budget: TBD", "Execution"}, + blockTexts(docBlocks(stateDoc(t, *retried))), "the listed candidate deleted the block it named") + }) + + t.Run("match mid-batch: an earlier op makes a DELETE locator ambiguous — refuse, never the stale unique match", func(t *testing.T) { + // the failure this slice exists to prevent, at its worst: against the + // pre-batch document "child" is unique, so a stale view would delete + // blockChild1 silently. Op 0 writes the same word into a second block + // — the fresh view must refuse and name both + fx := newV2Fixture(t) + captured := fx.expectMutate(editRead(t, editBaseDoc)) + + _, err := fx.PatchObject(ctx, testSpaceId, "obj1", patchBody( + `{"op":"update_block","id":"blockParent1","set":{"text":"child of mine"}}`, + `{"op":"delete_block","match":"child"}`, + ), "", false, true) + + apiErr := v2Err(t, err) + assert.Equal(t, v2model.CodeAmbiguousInput, apiErr.Code) + assert.Contains(t, apiErr.Message, "appears in 2 blocks") + assert.Contains(t, apiErr.Message, "blockParent1") + assert.Contains(t, apiErr.Message, "blockChild1") + require.Len(t, apiErr.Issues, 1) + assert.Equal(t, "ops[1].match", apiErr.Issues[0].Path) + assert.Nil(t, *captured, "the whole batch refuses — nothing is deleted") + }) + + t.Run("match: delete_block refuses id and match together before deleting anything", func(t *testing.T) { + fx := newV2Fixture(t) + captured := fx.expectMutate(editRead(t, editBaseDoc)) + + _, err := fx.PatchObject(ctx, testSpaceId, "obj1", + patchBody(`{"op":"delete_block","id":"blockChild1","match":"Section"}`), "", false, true) + + apiErr := v2Err(t, err) + assert.Equal(t, v2model.CodeAmbiguousInput, apiErr.Code) + assert.Contains(t, apiErr.Message, "id or match, not both") + assert.Contains(t, apiErr.Message, "delete_block") + assert.Nil(t, *captured) + }) + + t.Run("match: delete_block with neither channel names both", func(t *testing.T) { + fx := newV2Fixture(t) + fx.expectMutate(editRead(t, editBaseDoc)) + + _, err := fx.PatchObject(ctx, testSpaceId, "obj1", + patchBody(`{"op":"delete_block","recursive":true}`), "", false, true) + + apiErr := v2Err(t, err) + assert.Equal(t, v2model.CodeValidationFailed, apiErr.Code) + assert.Contains(t, apiErr.Message, "delete_block needs a block to address") + }) + + t.Run("match: a dry run resolves identically and commits nothing (C9)", func(t *testing.T) { + // no mutator expectation is wired at all — the dry run must never + // reach it; resolution runs at apply time on the private state + fx := newV2Fixture(t) + fx.readerMock.EXPECT().ReadObject(mock.Anything, testSpaceId, "obj1"). + Return(editRead(t, editBaseDoc), nil) + + result, err := fx.PatchObject(ctx, testSpaceId, "obj1", + patchBody(`{"op":"delete_block","match":"parent","recursive":true}`), "", true, true) + + require.NoError(t, err) + assert.True(t, result.DryRun) + assert.Equal(t, v2model.DiffStats{BlocksRemoved: 2}, result.DiffStats) + }) + + t.Run("set_cell writes one cell", func(t *testing.T) { + fx := newV2Fixture(t) + captured := fx.expectMutate(editRead(t, editTableDoc), "headB") + + result, err := fx.PatchObject(ctx, testSpaceId, "obj1", + patchBody(`{"op":"set_cell","table_id":"tblOne1","row":"rowB","col":"colB","value":"done"}`), "", false, true) + + require.NoError(t, err) + assert.Equal(t, v2model.DiffStats{BlocksChanged: 1}, result.DiffStats) + blocks := docBlocks(stateDoc(t, *captured)) + rows := blocks[0]["rows"].([]any) + cells := rows[1].(map[string]any)["cells"].([]any) + assert.Equal(t, []any{"Export", "done"}, cells) + }) + + t.Run("set_cell keeps the table's wrapper block ids (A′3)", func(t *testing.T) { + // the format does not carry the column/row layout wrappers, so the + // importer mints fresh ids for them. Reusing the live ids keeps a cell + // edit a cell edit — otherwise every table op replaces both wrappers + // and re-parents every row and column, and concurrent edits on two + // devices merge into a table with duplicated rows/columns. + fx := newV2Fixture(t) + read := editRead(t, editTableDoc) + var liveWrappers []string + for _, b := range read.Snapshot.Blocks { + if b.Id == "tblOne1" { + liveWrappers = append([]string(nil), b.ChildrenIds...) + } + } + require.Len(t, liveWrappers, 2, "table has column and row wrappers") + captured := fx.expectMutate(read, "headB") + + _, err := fx.PatchObject(ctx, testSpaceId, "obj1", + patchBody(`{"op":"set_cell","table_id":"tblOne1","row":"rowB","col":"colB","value":"done"}`), "", false, true) + + require.NoError(t, err) + table := (*captured).Pick("tblOne1") + require.NotNil(t, table) + assert.Equal(t, liveWrappers, table.Model().ChildrenIds, + "the wrapper ids must survive a cell edit") + for _, id := range liveWrappers { + assert.NotNil(t, (*captured).Pick(id), "wrapper %s must still exist", id) + } + }) + + t.Run("set_cell unknown row lists the rows", func(t *testing.T) { + fx := newV2Fixture(t) + fx.expectMutate(editRead(t, editTableDoc)) + + _, err := fx.PatchObject(ctx, testSpaceId, "obj1", + patchBody(`{"op":"set_cell","table_id":"tblOne1","row":"rowZ","col":"colB","value":"x"}`), "", false, true) + + apiErr := v2Err(t, err) + assert.Equal(t, http.StatusNotFound, apiErr.Status) + assert.Contains(t, apiErr.Message, `row "rowZ" not found in table "tblOne1"`) + assert.Contains(t, apiErr.Message, "rowH, rowB") + }) + + t.Run("set_cell with an invalid cell block fails post-op validation", func(t *testing.T) { + fx := newV2Fixture(t) + fx.expectMutate(editRead(t, editTableDoc)) + + // cells never carry ids (SPEC §6.1) — the R5 net catches it + _, err := fx.PatchObject(ctx, testSpaceId, "obj1", + patchBody(`{"op":"set_cell","table_id":"tblOne1","row":"rowB","col":"colB","value":{"id":"x1","type":"paragraph","text":"y"}}`), "", false, true) + + apiErr := v2Err(t, err) + assert.Equal(t, "the ops would produce an invalid document — no op was applied", apiErr.Message) + require.NotEmpty(t, apiErr.Issues) + }) + + t.Run("set_properties writes presence and unsets", func(t *testing.T) { + fx := newV2Fixture(t) + captured := fx.expectMutate(editRead(t, editBaseDoc), "headB") + + result, err := fx.PatchObject(ctx, testSpaceId, "obj1", + patchBody(`{"op":"set_properties","set":{"name":"Renamed","done":true},"unset":["description"]}`), "", false, true) + + require.NoError(t, err) + assert.Equal(t, v2model.DiffStats{PropertiesChanged: 3}, result.DiffStats) + doc := stateDoc(t, *captured) + props := doc["properties"].(map[string]any) + assert.Equal(t, "Renamed", props["name"]) + assert.Equal(t, true, props["done"]) + _, hasDescription := props["description"] + assert.False(t, hasDescription) + }) + + t.Run("a rejected precondition creates no options (A′1)", func(t *testing.T) { + // create-missing resolution must run only after the request is known + // to be legitimate: prewarming first meant a stale If-Match (412), a + // missing object (404) or a restricted object (403) still permanently + // created every option the batch named. + fx := newV2Fixture(t) + fx.addSelectProperty(t) + fx.readerMock.EXPECT().ReadObject(mock.Anything, testSpaceId, "obj1"). + Return(editRead(t, editBaseDoc), nil) + // no ObjectCreateRelationOption expectation and no mutator expectation: + // reaching either fails the test + + _, err := fx.PatchObject(ctx, testSpaceId, "obj1", + patchBody(`{"op":"set_properties","set":{"severity":["BrandNewOption"]}}`), `"deadbeef"`, false, true) + + apiErr := v2Err(t, err) + assert.Equal(t, http.StatusConflict, apiErr.Status) + assert.Equal(t, v2model.CodeEtagMismatch, apiErr.Code) + }) + + // M5 (surface review): create-missing is irreversible and was unbounded. + // The two halves below catch different requests — the bound stops a batch + // that WOULD succeed, the ordering stops one that cannot. + t.Run("M5: a failing op creates nothing, even though an earlier op named new options", func(t *testing.T) { + // the reproducer: op 1 names an option, op 2 is a 404. Before M5 the + // batch failed AND the option existed, permanently, with no delete + // surface to undo it. + fx := newV2Fixture(t) + fx.addSelectProperty(t) + fx.readerMock.EXPECT().ReadObject(mock.Anything, testSpaceId, "obj1"). + Return(editRead(t, editBaseDoc), nil) + // no ObjectCreateRelationOption expectation: creating anything fails + // the test. No mutator expectation either — the batch must not commit. + + _, err := fx.PatchObject(ctx, testSpaceId, "obj1", + patchBody(`{"op":"set_properties","set":{"severity":["BrandNewOption"]}}`, + `{"op":"update_block","id":"doesNotExist","set":{"text":"hi"}}`), "", false, true) + + require.Error(t, err) + apiErr := v2Err(t, err) + assert.Equal(t, http.StatusNotFound, apiErr.Status, "the batch fails on the bad block ref") + }) + + t.Run("M5: the set/unset conflict creates nothing", func(t *testing.T) { + // the second reproducer: prewarm's skip list covered a key claimed by + // both `add` and `set` but never read `unset`, so this created the + // option and then 400ed. Validating the batch first covers the whole + // family instead of one more special case. + fx := newV2Fixture(t) + fx.addSelectProperty(t) + fx.readerMock.EXPECT().ReadObject(mock.Anything, testSpaceId, "obj1"). + Return(editRead(t, editBaseDoc), nil) + + _, err := fx.PatchObject(ctx, testSpaceId, "obj1", + patchBody(`{"op":"set_properties","set":{"severity":["BrandNewOption"]},"unset":["severity"]}`), "", false, true) + + require.Error(t, err) + }) + + t.Run("M5: a batch over the option cap is refused before any create", func(t *testing.T) { + // this one WOULD apply cleanly — only the bound stops it. At the body + // cap the same shape reaches ~10^6 permanent objects. + fx := newV2Fixture(t) + fx.addSelectProperty(t) + fx.readerMock.EXPECT().ReadObject(mock.Anything, testSpaceId, "obj1"). + Return(editRead(t, editBaseDoc), nil).Maybe() + + names := make([]string, 0, v2MaxCreatedOptionsPerPatch+1) + for i := 0; i <= v2MaxCreatedOptionsPerPatch; i++ { + names = append(names, fmt.Sprintf(`"Hallucinated-%d"`, i)) + } + _, err := fx.PatchObject(ctx, testSpaceId, "obj1", + patchBody(fmt.Sprintf(`{"op":"set_properties","set":{"severity":[%s]}}`, + strings.Join(names, ","))), "", false, true) + + apiErr := v2Err(t, err) + assert.Equal(t, http.StatusBadRequest, apiErr.Status) + require.NotEmpty(t, apiErr.Issues) + assert.Contains(t, apiErr.Issues[0].Message, "would create") + assert.Contains(t, apiErr.Issues[0].Message, "severity") + assert.Contains(t, apiErr.Issues[0].Hint, "permanent") + }) + + t.Run("M5: a batch at the cap still applies", func(t *testing.T) { + // the bound must not break legitimate bulk tagging + fx := newV2Fixture(t) + fx.addSelectProperty(t) + read := editRead(t, editBaseDoc) + fx.expectMutate(read, "headB") + fx.mwMock.EXPECT().ObjectCreateRelationOption(mock.Anything, mock.Anything). + RunAndReturn(func(_ context.Context, req *pb.RpcObjectCreateRelationOptionRequest) *pb.RpcObjectCreateRelationOptionResponse { + name := req.Details.GetFields()[bundle.RelationKeyName.String()].GetStringValue() + return &pb.RpcObjectCreateRelationOptionResponse{ + ObjectId: "opt-" + name, + Error: &pb.RpcObjectCreateRelationOptionResponseError{Code: pb.RpcObjectCreateRelationOptionResponseError_NULL}, + } + }).Times(v2MaxCreatedOptionsPerPatch) + + names := make([]string, 0, v2MaxCreatedOptionsPerPatch) + for i := 0; i < v2MaxCreatedOptionsPerPatch; i++ { + names = append(names, fmt.Sprintf(`"Bulk-%d"`, i)) + } + result, err := fx.PatchObject(ctx, testSpaceId, "obj1", + patchBody(fmt.Sprintf(`{"op":"set_properties","set":{"severity":[%s]}}`, + strings.Join(names, ","))), "", false, true) + + require.NoError(t, err) + require.NotNil(t, result.Created) + assert.Len(t, result.Created.Options, v2MaxCreatedOptionsPerPatch) + }) + + t.Run("dry run reports a created option once (C′2)", func(t *testing.T) { + // prewarm and the op itself both resolve the same name; dry_run must + // preview exactly what the real run reports + fx := newV2Fixture(t) + fx.addSelectProperty(t) + fx.readerMock.EXPECT().ReadObject(mock.Anything, testSpaceId, "obj1"). + Return(editRead(t, editBaseDoc), nil) + + result, err := fx.PatchObject(ctx, testSpaceId, "obj1", + patchBody(`{"op":"set_properties","set":{"severity":["BrandNewOption"]}}`), "", true, true) + + require.NoError(t, err) + require.NotNil(t, result.Created) + assert.Len(t, result.Created.Options, 1, "the option is previewed once, not once per resolution") + assert.Equal(t, "BrandNewOption", result.Created.Options[0].Name) + }) + + // M1 (surface review): the gate is per-op. Sets and collections carry + // Restrictions_Blocks but NOT Restrictions_Details, so a blanket + // per-request check made renaming a set — and every add_items, the only v2 + // route into an existing collection — permanently refuse. + t.Run("M1: a blocks-restricted object still accepts a property edit", func(t *testing.T) { + fx := newV2Fixture(t) + read := editRead(t, editBaseDoc) + read.BlocksRefused = blocksRefusedProduction() + captured := fx.expectMutate(read, "headB") + + _, err := fx.PatchObject(ctx, testSpaceId, "obj1", + patchBody(`{"op":"set_properties","set":{"name":"Renamed"}}`), "", false, true) + + // before M1 this returned the blocks refusal, so a set could never be + // renamed through v2 even though nothing restricted its details + require.NoError(t, err) + require.NotNil(t, *captured, "the mutator must be reached") + assert.Equal(t, "Renamed", (*captured).Details().GetString(bundle.RelationKeyName)) + }) + + t.Run("M1: the batch's needs carry only the axes its ops touch", func(t *testing.T) { + fx := newV2Fixture(t) + read := editRead(t, editBaseDoc) + fx.readerMock.EXPECT().ReadObject(mock.Anything, testSpaceId, "obj1").Return(read, nil).Maybe() + + var got apicore.EditNeeds + fx.mutatorMock.EXPECT().MutateObject(mock.Anything, testSpaceId, "obj1", mock.Anything, mock.Anything). + RunAndReturn(func(ctx context.Context, spaceId, objectId string, needs apicore.EditNeeds, apply func(apicore.ObjectEdit) error) ([]string, error) { + got = needs + st, err := state.NewDocFromSnapshot(objectId, &pb.ChangeSnapshot{Data: read.Snapshot}) + if err != nil { + return nil, err + } + if err := apply(apicore.ObjectEdit{SbType: read.SbType, Heads: read.Heads, State: st}); err != nil { + return nil, err + } + return []string{"headB"}, nil + }) + + _, err := fx.PatchObject(ctx, testSpaceId, "obj1", + patchBody(`{"op":"set_properties","set":{"name":"Renamed"}}`), "", false, true) + + require.NoError(t, err) + assert.Equal(t, apicore.EditNeeds{Details: true}, got, + "a property-only batch must not demand the Blocks axis") + }) + + t.Run("M1: a blocks-restricted object still refuses a block op, naming it", func(t *testing.T) { + fx := newV2Fixture(t) + read := editRead(t, editBaseDoc) + read.BlocksRefused = blocksRefusedProduction() + fx.readerMock.EXPECT().ReadObject(mock.Anything, testSpaceId, "obj1").Return(read, nil) + // no MutateObject expectation: reaching the mutator would fail the test + + _, err := fx.PatchObject(ctx, testSpaceId, "obj1", + patchBody(`{"op":"set_properties","set":{"name":"Renamed"}}`, + `{"op":"delete_block","id":"blockChild1"}`), "", false, true) + + // M2a: the refusal is PERMANENT, so it must be the C6 403 — not the + // bare error RespondError turns into a retryable 500 + apiErr := v2Err(t, err) + assert.Equal(t, http.StatusForbidden, apiErr.Status) + assert.Equal(t, v2model.CodeForbidden, apiErr.Code) + assert.Contains(t, apiErr.Message, "cannot be edited") + assert.Contains(t, apiErr.Message, "/ops/1", "the refusal must address the offending op, not the request") + }) + + t.Run("M2a: a restriction refusal from the in-lock re-check is a 403, not a read-shaped 500", func(t *testing.T) { + // the adapter re-checks restrictions under the lock (and Apply checks + // per-block restrictions) — a refusal surfacing from MutateObject must + // classify like the pre-lock gate's, not fall through mapReadError + fx := newV2Fixture(t) + fx.readerMock.EXPECT().ReadObject(mock.Anything, testSpaceId, "obj1"). + Return(editRead(t, editBaseDoc), nil) + fx.mutatorMock.EXPECT().MutateObject(mock.Anything, testSpaceId, "obj1", mock.Anything, mock.Anything). + Return(nil, blocksRefusedProduction()) + + _, err := fx.PatchObject(ctx, testSpaceId, "obj1", + patchBody(`{"op":"delete_block","id":"blockChild1"}`), "", false, true) + + apiErr := v2Err(t, err) + assert.Equal(t, http.StatusForbidden, apiErr.Status) + assert.Equal(t, v2model.CodeForbidden, apiErr.Code) + assert.NotContains(t, apiErr.Message, "read object", "a refused write must not be dressed as a failed read") + }) + + t.Run("a restricted object is refused on the dry run too (C′3)", func(t *testing.T) { + // the restriction verdict rides the read, so dry_run cannot report a + // success the real edit would refuse + fx := newV2Fixture(t) + read := editRead(t, editBaseDoc) + read.BlocksRefused = blocksRefusedProduction() + fx.readerMock.EXPECT().ReadObject(mock.Anything, testSpaceId, "obj1").Return(read, nil) + + _, err := fx.PatchObject(ctx, testSpaceId, "obj1", + patchBody(`{"op":"delete_block","id":"blockChild1"}`), "", true, true) + + apiErr := v2Err(t, err) + assert.Equal(t, http.StatusForbidden, apiErr.Status, "the dry run reaches the same 403 the real edit would") + assert.Contains(t, apiErr.Message, "cannot be edited") + }) + + t.Run("V3 row→column containment is enforced on the spliced result (B′1)", func(t *testing.T) { + // a paragraph inside a row is legal as an isolated fragment — only the + // whole document shows the violation, which is why the post-op validate + // has to run + fx := newV2Fixture(t) + fx.expectMutate(editRead(t, editLayoutDoc)) + + _, err := fx.PatchObject(ctx, testSpaceId, "obj1", + patchBody(`{"op":"insert_blocks","inside":"rowOne1","blocks":[{"type":"paragraph","text":"x"}]}`), "", false, true) + + apiErr := v2Err(t, err) + assert.Equal(t, http.StatusBadRequest, apiErr.Status) + assert.Equal(t, v2InvalidDocMessage, apiErr.Message) + }) + + t.Run("a legal edit inside a column still passes (no false rejection)", func(t *testing.T) { + fx := newV2Fixture(t) + captured := fx.expectMutate(editRead(t, editLayoutDoc), "headB") + + _, err := fx.PatchObject(ctx, testSpaceId, "obj1", + patchBody(`{"op":"insert_blocks","inside":"colOne1","blocks":[{"type":"paragraph","text":"added"}]}`), "", false, true) + + require.NoError(t, err) + assert.NotNil(t, *captured) + }) + + t.Run("an over-deep insert is refused, not silently clamped (B′2)", func(t *testing.T) { + // fragment validation is run-RELATIVE, so a deep run passes on its own; + // spliced in it can push the document past the format's depth bound. + // The exporter clamps rather than failing when a warning sink is + // installed, which used to corrupt the view for later ops in the same + // batch (delete_block then saw no descendants and dropped a subtree) and + // leave the object permanently un-PATCHable. + fx := newV2Fixture(t) + fx.expectMutate(editRead(t, editBaseDoc)) + + run := make([]string, 0, 34) + for i := 0; i < 34; i++ { + indent := "" + if i > 0 { + indent = fmt.Sprintf(`"indent":%d,`, i) + } + run = append(run, fmt.Sprintf(`{%s"type":"toggle","text":"d%d"}`, indent, i)) + } + body := patchBody(fmt.Sprintf( + `{"op":"insert_blocks","after":"blockSibling2","blocks":[%s]}`, strings.Join(run, ","))) + + _, err := fx.PatchObject(ctx, testSpaceId, "obj1", body, "", false, true) + + apiErr := v2Err(t, err) + assert.Equal(t, http.StatusBadRequest, apiErr.Status) + require.NotEmpty(t, apiErr.Issues) + }) + + t.Run("a blocks run beyond the advertised per-op cap is refused (M7)", func(t *testing.T) { + // the op schemas advertise maxItems 256 on the blocks channel; before + // M7 nothing enforced it, so one op could inflate the document by + // 24,000 blocks that every later op re-rendered under the object lock + fx := newV2Fixture(t) + fx.expectMutate(editRead(t, editBaseDoc)) + run := make([]string, v2MaxBlocksPerOp+1) + for i := range run { + run[i] = `{"type":"paragraph","text":"x"}` + } + + _, err := fx.PatchObject(ctx, testSpaceId, "obj1", + patchBody(fmt.Sprintf(`{"op":"insert_blocks","after":"blockHeading1","blocks":[%s]}`, strings.Join(run, ","))), "", false, true) + + apiErr := v2Err(t, err) + assert.Equal(t, http.StatusBadRequest, apiErr.Status) + assert.Equal(t, "too many blocks in one op", apiErr.Message) + require.Len(t, apiErr.Issues, 1) + assert.Equal(t, "ops[0].blocks", apiErr.Issues[0].Path) + assert.Contains(t, apiErr.Issues[0].Message, "256") + }) + + t.Run("replace_subtree shares the per-op blocks cap (M7)", func(t *testing.T) { + fx := newV2Fixture(t) + fx.expectMutate(editRead(t, editBaseDoc)) + run := make([]string, v2MaxBlocksPerOp+1) + for i := range run { + run[i] = `{"type":"paragraph","text":"x"}` + } + + _, err := fx.PatchObject(ctx, testSpaceId, "obj1", + patchBody(fmt.Sprintf(`{"op":"replace_subtree","id":"blockParent1","blocks":[%s]}`, strings.Join(run, ","))), "", false, true) + + apiErr := v2Err(t, err) + assert.Equal(t, "too many blocks in one op", apiErr.Message) + require.Len(t, apiErr.Issues, 1) + assert.Equal(t, "ops[0].blocks", apiErr.Issues[0].Path) + }) + + t.Run("a batch whose re-render product exceeds the work bound is refused whole (M7)", func(t *testing.T) { + // the 512-op cap bounds one factor of the O(ops × document) product; + // this bounds the product itself: 512 structural ops on a 2,500-block + // document is ~1.28M block-renders of lock-held marshal work — over + // v2MaxPatchRenderWork — and is refused after the begin() marshal, + // before any op applies + fx := newV2Fixture(t) + fx.expectMutate(editRead(t, editManyBlocksDoc(2500))) + ops := make([]string, v2MaxOpsPerPatch) + for i := range ops { + ops[i] = `{"op":"move_block","id":"blockParent1"}` + } + + _, err := fx.PatchObject(ctx, testSpaceId, "obj1", patchBody(ops...), "", false, true) + + apiErr := v2Err(t, err) + assert.Equal(t, http.StatusBadRequest, apiErr.Status) + assert.Equal(t, "this PATCH is too much re-rendering work for one atomic batch", apiErr.Message) + require.Len(t, apiErr.Issues, 1) + assert.Equal(t, "/ops", apiErr.Issues[0].Path) + assert.Contains(t, apiErr.Issues[0].Message, "block-renders") + assert.Contains(t, apiErr.Issues[0].Hint, "split the edit") + }) + + t.Run("a replace_text batch is exempt from the work bound and applies on a large document (M7)", func(t *testing.T) { + // replace_text keeps the view valid in place (v2OpRebuildsView false), + // so a full 512-op text-edit batch on a 2,500-block document costs two + // document renders, not 512 — and must NOT trip the render-work bound + // the move_block batch above trips + fx := newV2Fixture(t) + captured := fx.expectMutate(editRead(t, editManyBlocksDoc(2500)), "headB") + ops := make([]string, v2MaxOpsPerPatch) + for i := range ops { + ops[i] = `{"op":"replace_text","id":"blockSibling2","find":"report","replace":"report"}` + } + + _, err := fx.PatchObject(ctx, testSpaceId, "obj1", patchBody(ops...), "", false, true) + + require.NoError(t, err) + require.NotNil(t, *captured) + }) + + t.Run("sequential replace_text matches against the canonical inline form (M7)", func(t *testing.T) { + // op 1 splices "**re****port**" — raw adjacent bolds whose canonical + // rendering is "**report**". The view op 2 addresses must carry the + // canonical form (what a re-marshal emits and what the agent would + // read back), whether the view was rebuilt or maintained in place. + fx := newV2Fixture(t) + captured := fx.expectMutate(editRead(t, editBaseDoc), "headB") + + result, err := fx.PatchObject(ctx, testSpaceId, "obj1", patchBody( + `{"op":"replace_text","id":"blockSibling2","find":"report","replace":"**re****port**"}`, + `{"op":"replace_text","id":"blockSibling2","find":"the Q3 **report**","replace":"the Q3 **REPORT**"}`, + ), "", false, true) + + require.NoError(t, err) + assert.Equal(t, v2model.DiffStats{BlocksChanged: 1}, result.DiffStats) + blocks := docBlocks(stateDoc(t, *captured)) + assert.Equal(t, "the Q3 **REPORT** and Q3 plan", blocks[3]["text"]) + }) + + t.Run("an op after replace_text sees the replaced text (M7)", func(t *testing.T) { + // update_block merges on the view block — if the in-place text update + // were wrong or stale, the merge would resurrect the pre-replace text + fx := newV2Fixture(t) + captured := fx.expectMutate(editRead(t, editBaseDoc), "headB") + + _, err := fx.PatchObject(ctx, testSpaceId, "obj1", patchBody( + `{"op":"replace_text","id":"blockChild1","find":"child","replace":"kid"}`, + `{"op":"update_block","id":"blockChild1","set":{"color":"red"}}`, + ), "", false, true) + + require.NoError(t, err) + blocks := docBlocks(stateDoc(t, *captured)) + assert.Equal(t, "kid", blocks[2]["text"], "the merge must ride on the replaced text") + assert.Equal(t, "red", blocks[2]["color"]) + }) + + t.Run("a modest batch on a large document stays under the work bound (M7)", func(t *testing.T) { + // the bound must catch the abusive product, not ordinary edits to big + // documents: 8 ops × 2,500 blocks is well inside the budget + fx := newV2Fixture(t) + captured := fx.expectMutate(editRead(t, editManyBlocksDoc(2500)), "headB") + ops := make([]string, 8) + for i := range ops { + ops[i] = `{"op":"replace_text","id":"blockSibling2","find":"report","replace":"report"}` + } + + _, err := fx.PatchObject(ctx, testSpaceId, "obj1", patchBody(ops...), "", false, true) + + require.NoError(t, err) + require.NotNil(t, *captured) + }) + + t.Run("a batch beyond the op cap is refused (A′2)", func(t *testing.T) { + // every op re-renders the view under the object lock, so the batch is + // bounded; the cap is checked before any read or lock + fx := newV2Fixture(t) + ops := make([]string, v2MaxOpsPerPatch+1) + for i := range ops { + ops[i] = `{"op":"set_properties","set":{"name":"x"}}` + } + + _, err := fx.PatchObject(ctx, testSpaceId, "obj1", patchBody(ops...), "", false, true) + + apiErr := v2Err(t, err) + assert.Equal(t, http.StatusBadRequest, apiErr.Status) + assert.Equal(t, "/ops", apiErr.Issues[0].Path) + assert.Contains(t, apiErr.Issues[0].Message, "exceeds the 512-op limit") + }) + + t.Run("set_properties rejects output-only and unknown keys path-addressed", func(t *testing.T) { + fx := newV2Fixture(t) + fx.expectMutate(editRead(t, editBaseDoc)) + + _, err := fx.PatchObject(ctx, testSpaceId, "obj1", + patchBody(`{"op":"set_properties","set":{"resolvedLayout":"todo","totallyUnknown":1}}`), "", false, true) + + apiErr := v2Err(t, err) + require.Len(t, apiErr.Issues, 2) + assert.Equal(t, "ops[0].set.resolvedLayout", apiErr.Issues[0].Path) + assert.Contains(t, apiErr.Issues[0].Message, "output-only") + assert.Equal(t, "ops[0].set.totallyUnknown", apiErr.Issues[1].Path) + assert.Contains(t, apiErr.Issues[1].Message, "unknown property key") + }) + + // A2: minting an option is a WRITE the caller did not ask for in the body + // — the value names a label, not a create — so it needs explicit consent. + // Default OFF and loud: an unmatched select value is far more often a + // typo or a hallucinated label than a deliberate new option, and a minted + // one joins the property's vocabulary for every object and every member + // of the space. + t.Run("an unmatched option name is refused without ?create_missing_options", func(t *testing.T) { + // given: the read is wired (PatchObject reads before prewarming), but + // there is no ObjectCreateRelationOption and no MutateObject + // expectation — reaching either fails the test, which is the point + fx := newV2Fixture(t) + fx.addSelectProperty(t) + fx.readerMock.EXPECT().ReadObject(mock.Anything, testSpaceId, "obj1"). + Return(editRead(t, editBaseDoc), nil).Maybe() + + // when + _, err := fx.PatchObject(ctx, testSpaceId, "obj1", + patchBody(`{"op":"set_properties","set":{"severity":["Critical"]}}`), "", false, false) + + // then: the refusal names the property, the value, and both ways on + apiErr := v2ErrWithIssue(t, err) + assert.Equal(t, v2model.CodeValidationFailed, apiErr.Code) + require.NotEmpty(t, apiErr.Issues) + assert.Contains(t, apiErr.Issues[0].Message, `"Critical"`) + assert.Contains(t, apiErr.Issues[0].Message, `"severity"`) + assert.Contains(t, apiErr.Issues[0].Hint, "/options") + assert.Contains(t, apiErr.Issues[0].Hint, "create_missing_options=true") + }) + + t.Run("an EXISTING option name needs no consent", func(t *testing.T) { + // the gate is about minting, not about using: a name the property + // already holds resolves on the default path, or the flag would be a + // tax on every ordinary write + fx := newV2Fixture(t) + fx.addSelectProperty(t) // "severity" already holds "High" + fx.expectMutate(editRead(t, editBaseDoc), "headB") + + result, err := fx.PatchObject(ctx, testSpaceId, "obj1", + patchBody(`{"op":"set_properties","set":{"severity":["High"]}}`), "", false, false) + + require.NoError(t, err) + assert.Nil(t, result.Created, "nothing was minted") + }) + + t.Run("set_properties creates missing select options and reports them", func(t *testing.T) { + // given + fx := newV2Fixture(t) + fx.addSelectProperty(t) + fx.mwMock.EXPECT().ObjectCreateRelationOption(mock.Anything, mock.Anything).Return(&pb.RpcObjectCreateRelationOptionResponse{ + ObjectId: "opt-critical", + Error: &pb.RpcObjectCreateRelationOptionResponseError{Code: pb.RpcObjectCreateRelationOptionResponseError_NULL}, + }) + fx.expectMutate(editRead(t, editBaseDoc), "headB") + want := &v2model.SideEffects{Options: []v2model.CreatedOption{{Property: "severity", Name: "Critical"}}} + + // when + result, err := fx.PatchObject(ctx, testSpaceId, "obj1", + patchBody(`{"op":"set_properties","set":{"severity":["Critical"]}}`), "", false, true) + + // then + require.NoError(t, err) + assert.Equal(t, want, result.Created) + }) + + t.Run("set_properties add on a select that already has a value is refused", func(t *testing.T) { + // select holds ONE value; appending would leave a two-valued + // single-select the UI renders arbitrarily. No create expectation: + // the guard must fire before the option is minted. + fx := newV2Fixture(t) + fx.addSelectProperty(t) + read := editRead(t, editBaseDoc) + read.Snapshot.Details.Fields["severity"] = pbtypes.StringList([]string{"opt-high"}) + fx.expectMutate(read) + + _, err := fx.PatchObject(ctx, testSpaceId, "obj1", + patchBody(`{"op":"set_properties","add":{"severity":["High"]}}`), "", false, true) + + apiErr := v2Err(t, err) + assert.Equal(t, http.StatusBadRequest, apiErr.Status) + assert.Equal(t, "ops[0].add.severity", apiErr.Issues[0].Path) + assert.Contains(t, apiErr.Issues[0].Message, "single value") + assert.Contains(t, apiErr.Issues[0].Hint, "use set") + }) + + t.Run("set_properties add on an EMPTY select is allowed", func(t *testing.T) { + // the guard is about overflowing an occupied single slot, not about + // forbidding add on selects outright + fx := newV2Fixture(t) + fx.addSelectProperty(t) + captured := fx.expectMutate(editRead(t, editBaseDoc), "headB") + + _, err := fx.PatchObject(ctx, testSpaceId, "obj1", + patchBody(`{"op":"set_properties","add":{"severity":["High"]}}`), "", false, true) + + require.NoError(t, err) + props := stateDoc(t, *captured)["properties"].(map[string]any) + assert.Equal(t, []any{"opt-high"}, props["severity"], + "stateDoc marshals without an option resolver, so the stored id shows") + }) + + t.Run("set_properties add appends to a multiSelect without duplicating", func(t *testing.T) { + // given + fx := newV2Fixture(t) + fx.addTagProperty(t) + captured := fx.expectMutate(editRead(t, editBaseDoc), "headB") + + // when: op 0 adds Urgent (twice — dedupe within one op), op 1 adds + // Urgent again plus Later — the existing entry is never duplicated + _, err := fx.PatchObject(ctx, testSpaceId, "obj1", + patchBody( + `{"op":"set_properties","add":{"tags":["Urgent","Urgent"]}}`, + `{"op":"set_properties","add":{"tags":["Urgent","Later"]}}`), "", false, true) + + // then + require.NoError(t, err) + got := (*captured).CombinedDetails().Get(domain.RelationKey("tags")).StringList() + assert.Equal(t, []string{"opt-urgent", "opt-later"}, got) + }) + + t.Run("set_properties remove deletes matching entries and no-ops on absent ones", func(t *testing.T) { + // given + fx := newV2Fixture(t) + fx.addTagProperty(t) + captured := fx.expectMutate(editRead(t, editBaseDoc), "headB") + + // when: seed both tags, then remove one plus a name that resolves to + // nothing — removal must never create the option it names + _, err := fx.PatchObject(ctx, testSpaceId, "obj1", + patchBody( + `{"op":"set_properties","add":{"tags":["Urgent","Later"]}}`, + `{"op":"set_properties","remove":{"tags":["Urgent","Nonexistent"]}}`), "", false, true) + + // then: no ObjectCreateRelationOption expectation is wired — a create + // RPC for "Nonexistent" would fail the test + require.NoError(t, err) + got := (*captured).CombinedDetails().Get(domain.RelationKey("tags")).StringList() + assert.Equal(t, []string{"opt-later"}, got) + }) + + t.Run("set_properties remove on an absent key stays absent", func(t *testing.T) { + fx := newV2Fixture(t) + fx.addTagProperty(t) + captured := fx.expectMutate(editRead(t, editBaseDoc), "headB") + + _, err := fx.PatchObject(ctx, testSpaceId, "obj1", + patchBody(`{"op":"set_properties","remove":{"tags":["Urgent"]}}`), "", false, true) + + require.NoError(t, err) + assert.False(t, (*captured).CombinedDetails().Has(domain.RelationKey("tags")), + "remove never creates presence") + }) + + t.Run("set_properties add on a scalar format names the format", func(t *testing.T) { + fx := newV2Fixture(t) + fx.expectMutate(editRead(t, editBaseDoc)) + + _, err := fx.PatchObject(ctx, testSpaceId, "obj1", + patchBody(`{"op":"set_properties","add":{"done":[true]}}`), "", false, true) + + apiErr := v2Err(t, err) + require.Len(t, apiErr.Issues, 1) + assert.Equal(t, "ops[0].add.done", apiErr.Issues[0].Path) + assert.Equal(t, `"done" has format "checkbox" — add only applies to list-shaped formats (select, multi_select, objects, files); use set`, apiErr.Issues[0].Message) + }) + + t.Run("set_properties rejects a key in more than one of set/unset/add/remove", func(t *testing.T) { + fx := newV2Fixture(t) + fx.addTagProperty(t) + fx.expectMutate(editRead(t, editBaseDoc)) + + _, err := fx.PatchObject(ctx, testSpaceId, "obj1", + patchBody(`{"op":"set_properties","set":{"tags":["Urgent"]},"add":{"tags":["Later"]}}`), "", false, true) + + apiErr := v2Err(t, err) + require.Len(t, apiErr.Issues, 1) + assert.Equal(t, "ops[0].add.tags", apiErr.Issues[0].Path) + assert.Equal(t, `"tags" appears in both set and add — pick one`, apiErr.Issues[0].Message) + }) + + t.Run("set_properties add validates keys like set", func(t *testing.T) { + fx := newV2Fixture(t) + fx.expectMutate(editRead(t, editBaseDoc)) + + _, err := fx.PatchObject(ctx, testSpaceId, "obj1", + patchBody(`{"op":"set_properties","add":{"resolvedLayout":["todo"],"totallyUnknown":["x"]}}`), "", false, true) + + apiErr := v2Err(t, err) + require.Len(t, apiErr.Issues, 2) + assert.Equal(t, "ops[0].add.resolvedLayout", apiErr.Issues[0].Path) + assert.Contains(t, apiErr.Issues[0].Message, "output-only") + assert.Equal(t, "ops[0].add.totallyUnknown", apiErr.Issues[1].Path) + assert.Contains(t, apiErr.Issues[1].Message, "unknown property key") + }) + + t.Run("set_properties add takes an array, not a scalar", func(t *testing.T) { + fx := newV2Fixture(t) + fx.addTagProperty(t) + fx.expectMutate(editRead(t, editBaseDoc)) + + _, err := fx.PatchObject(ctx, testSpaceId, "obj1", + patchBody(`{"op":"set_properties","add":{"tags":"Urgent"}}`), "", false, true) + + apiErr := v2Err(t, err) + require.Len(t, apiErr.Issues, 1) + assert.Equal(t, "ops[0].add.tags", apiErr.Issues[0].Path) + assert.Contains(t, apiErr.Issues[0].Message, "add takes an array of entries") + }) + + t.Run("set_properties add creates missing option names and reports them", func(t *testing.T) { + // the same create-missing resolution as set, including the pre-lock + // prewarm (which scans add too) + fx := newV2Fixture(t) + fx.addSelectProperty(t) + fx.mwMock.EXPECT().ObjectCreateRelationOption(mock.Anything, mock.Anything).Return(&pb.RpcObjectCreateRelationOptionResponse{ + ObjectId: "opt-critical", + Error: &pb.RpcObjectCreateRelationOptionResponseError{Code: pb.RpcObjectCreateRelationOptionResponseError_NULL}, + }).Once() + captured := fx.expectMutate(editRead(t, editBaseDoc), "headB") + want := &v2model.SideEffects{Options: []v2model.CreatedOption{{Property: "severity", Name: "Critical"}}} + + result, err := fx.PatchObject(ctx, testSpaceId, "obj1", + patchBody(`{"op":"set_properties","add":{"severity":["Critical"]}}`), "", false, true) + + require.NoError(t, err) + assert.Equal(t, want, result.Created, "created once — prewarm and the op share the resolver cache") + got := (*captured).CombinedDetails().Get(domain.RelationKey("severity")).StringList() + assert.Equal(t, []string{"opt-critical"}, got) + }) + + t.Run("add_items and remove_items edit the collection membership", func(t *testing.T) { + fx := newV2Fixture(t) + captured := fx.expectMutate(editRead(t, editCollectionDoc), "headB") + + _, err := fx.PatchObject(ctx, testSpaceId, "obj1", + patchBody(`{"op":"add_items","items":["memberB","memberA"]}`, `{"op":"remove_items","items":["memberA"]}`), "", false, true) + + require.NoError(t, err) + doc := stateDoc(t, *captured) + assert.Equal(t, []any{"memberB"}, doc["items"]) + }) + + t.Run("add_items on a non-collection is rejected", func(t *testing.T) { + fx := newV2Fixture(t) + fx.expectMutate(editRead(t, editBaseDoc)) + + _, err := fx.PatchObject(ctx, testSpaceId, "obj1", + patchBody(`{"op":"add_items","items":["memberB"]}`), "", false, true) + + apiErr := v2Err(t, err) + assert.Contains(t, apiErr.Message, `add_items requires a collection — this object's type is "page"`) + }) + + t.Run("ambiguous suffix reference steers to the full id", func(t *testing.T) { + fx := newV2Fixture(t) + fx.expectMutate(editRead(t, editBaseDoc)) + + // "1" is a suffix of blockHeading1, blockParent1 and blockChild1 + _, err := fx.PatchObject(ctx, testSpaceId, "obj1", + patchBody(`{"op":"delete_block","id":"1"}`), "", false, true) + + apiErr := v2Err(t, err) + assert.Equal(t, v2model.CodeAmbiguousInput, apiErr.Code) + assert.Contains(t, apiErr.Message, "matches more than one block") + }) + + t.Run("missing block steers to the outline, C6-shaped", func(t *testing.T) { + fx := newV2Fixture(t) + fx.expectMutate(editRead(t, editBaseDoc)) + + _, err := fx.PatchObject(ctx, testSpaceId, "obj1", + patchBody(`{"op":"delete_block","id":"nowhere"}`), "", false, true) + + apiErr := v2Err(t, err) + assert.Equal(t, http.StatusNotFound, apiErr.Status) + assert.Contains(t, apiErr.Message, `"nowhere"`) + require.NotEmpty(t, apiErr.Issues, "the repair loop rides a C6 issue, not the message prose") + assert.Equal(t, "ops[0].id", apiErr.Issues[0].Path) + assert.Contains(t, apiErr.Issues[0].Hint, "?outline=true") + }) + + t.Run("unknown op lists the op set", func(t *testing.T) { + fx := newV2Fixture(t) + fx.expectMutate(editRead(t, editBaseDoc)) + + _, err := fx.PatchObject(ctx, testSpaceId, "obj1", + patchBody(`{"op":"frobnicate"}`), "", false, true) + + apiErr := v2Err(t, err) + assert.Contains(t, apiErr.Message, `unknown op "frobnicate"`) + assert.Contains(t, apiErr.Issues[0].Hint, "replace_text") + }) + + t.Run("stale If-Match is a 409 with the current etag, before the lock", func(t *testing.T) { + // A′1: the precondition is checked on a plain read BEFORE prewarming + // create-missing refs and before taking the object lock — so a stale + // If-Match never reaches the mutator and can never mint options. + fx := newV2Fixture(t) + fx.readerMock.EXPECT().ReadObject(mock.Anything, testSpaceId, "obj1"). + Return(editRead(t, editBaseDoc), nil) + // deliberately NO mutator expectation: reaching it would fail the test + + _, err := fx.PatchObject(ctx, testSpaceId, "obj1", + patchBody(`{"op":"delete_block","id":"blockChild1"}`), `"deadbeef"`, false, true) + + apiErr := v2Err(t, err) + assert.Equal(t, http.StatusConflict, apiErr.Status) + assert.Equal(t, v2model.CodeEtagMismatch, apiErr.Code) + assert.Contains(t, apiErr.Message, ComputeEtag([]string{"headA"})) + }) + + t.Run("matching If-Match passes", func(t *testing.T) { + fx := newV2Fixture(t) + fx.expectMutate(editRead(t, editBaseDoc), "headB") + + _, err := fx.PatchObject(ctx, testSpaceId, "obj1", + patchBody(`{"op":"delete_block","id":"blockChild1"}`), QuoteEtag(ComputeEtag([]string{"headA"})), false, true) + + require.NoError(t, err) + }) + + t.Run("dry run computes the outcome without the mutator", func(t *testing.T) { + // given: no MutateObject expectation — a call would fail the test + fx := newV2Fixture(t) + fx.readerMock.EXPECT().ReadObject(mock.Anything, testSpaceId, "obj1").Return(editRead(t, editBaseDoc), nil) + + // when + result, err := fx.PatchObject(ctx, testSpaceId, "obj1", + patchBody(`{"op":"delete_block","id":"blockParent1","recursive":true}`), "", true, true) + + // then + require.NoError(t, err) + assert.True(t, result.DryRun) + assert.Empty(t, result.Etag) + assert.Equal(t, v2model.DiffStats{BlocksRemoved: 2}, result.DiffStats) + }) + + t.Run("empty ops list is rejected", func(t *testing.T) { + fx := newV2Fixture(t) + + _, err := fx.PatchObject(ctx, testSpaceId, "obj1", []byte(`{"ops":[]}`), "", false, true) + + apiErr := v2Err(t, err) + assert.Contains(t, apiErr.Message, "ops must not be empty") + }) + + t.Run("atomicity: a failing later op applies nothing", func(t *testing.T) { + // given: the mutator returns whatever build produced — a nil snapshot + // (never captured) proves the first op never landed + fx := newV2Fixture(t) + captured := fx.expectMutate(editRead(t, editBaseDoc)) + + // when: op 0 is fine, op 1 addresses a missing block + _, err := fx.PatchObject(ctx, testSpaceId, "obj1", + patchBody(`{"op":"delete_block","id":"blockChild1"}`, `{"op":"delete_block","id":"nowhere"}`), "", false, true) + + // then + require.Error(t, err) + assert.Nil(t, *captured) + }) +} + +// jsonReplace swaps the first occurrence of a literal substring in a JSON +// document (test helper). +func jsonReplace(t *testing.T, doc, from, to string) []byte { + t.Helper() + out := strings.Replace(doc, from, to, 1) + require.NotEqual(t, doc, out, "replacement must apply") + return []byte(out) +} + +// TestApplierRenderCounts pins the M7 bounded-work property in the unit the +// render-work bound is denominated in: whole-document renders (marshalDoc +// calls). A wall-clock assertion would be flaky in CI; a render count is not. +func TestApplierRenderCounts(t *testing.T) { + ctx := context.Background() + + // newApplier builds a raw applier over a private state — the same + // construction applyPatchOps performs. + newApplier := func(t *testing.T, fx *v2Fixture, doc string) *v2StateApplier { + t.Helper() + edit, err := editFromRead("obj1", editRead(t, doc)) + require.NoError(t, err) + resolvers := fx.newCreatingResolvers(ctx, testSpaceId, false, true) + return newV2StateApplier(fx.Service, testSpaceId, "obj1", edit.SbType, edit.State, resolvers) + } + + t.Run("a replace_text batch renders the document exactly twice", func(t *testing.T) { + // begin + the final after-document — NOT once per op: replace_text + // maintains the view in place (M7), which is what turns a text-edit + // batch from O(ops × document) into O(document) under the object lock + fx := newV2Fixture(t) + applier := newApplier(t, fx, editBaseDoc) + _, err := applier.begin() + require.NoError(t, err) + + op := json.RawMessage(`{"op":"replace_text","id":"blockSibling2","find":"report","replace":"report"}`) + for i := 0; i < 50; i++ { + require.NoError(t, applier.apply(i, op)) + } + _, err = applier.currentDoc() + + require.NoError(t, err) + assert.Equal(t, 2, applier.marshalCount, + "50 replace_text ops must cost begin + final renders only") + }) + + t.Run("a locator batch renders the document exactly twice too", func(t *testing.T) { + // find-as-locator resolution reads the SAME live view the op already + // holds (§8.43) — it must add zero renders, or a 512-op locator batch + // would re-inherit the O(ops × document) product M7 removed + fx := newV2Fixture(t) + applier := newApplier(t, fx, editBaseDoc) + _, err := applier.begin() + require.NoError(t, err) + + op := json.RawMessage(`{"op":"replace_text","find":"report","replace":"report"}`) + for i := 0; i < 50; i++ { + require.NoError(t, applier.apply(i, op)) + } + _, err = applier.currentDoc() + + require.NoError(t, err) + assert.Equal(t, 2, applier.marshalCount, + "50 id-less replace_text ops must cost begin + final renders only") + }) + + t.Run("a match-addressed structural batch costs the same renders as an id-addressed one", func(t *testing.T) { + // `match` resolution reads the view the op already holds (§8.45), so + // it must add ZERO renders: two deleteBlocks cost begin + one rebuild + // + the final after-document either way. A locator that re-marshaled + // to resolve would double the count and re-inherit the O(ops × + // document) product M7 removed + fx := newV2Fixture(t) + byId := newApplier(t, fx, editBaseDoc) + _, err := byId.begin() + require.NoError(t, err) + require.NoError(t, byId.apply(0, json.RawMessage(`{"op":"delete_block","id":"blockChild1"}`))) + require.NoError(t, byId.apply(1, json.RawMessage(`{"op":"delete_block","id":"blockSibling2"}`))) + _, err = byId.currentDoc() + require.NoError(t, err) + + byMatch := newApplier(t, fx, editBaseDoc) + _, err = byMatch.begin() + require.NoError(t, err) + require.NoError(t, byMatch.apply(0, json.RawMessage(`{"op":"delete_block","match":"child"}`))) + require.NoError(t, byMatch.apply(1, json.RawMessage(`{"op":"delete_block","match":"Q3 report"}`))) + _, err = byMatch.currentDoc() + + require.NoError(t, err) + assert.Equal(t, 3, byId.marshalCount) + assert.Equal(t, byId.marshalCount, byMatch.marshalCount, "the locator adds no renders") + }) + + t.Run("structural ops still rebuild the view per op", func(t *testing.T) { + // the contrast pin: two moveBlocks cost begin + one rebuild (for the + // second op's view) + the final after-document — the render-work + // bound counts exactly these ops (v2OpRebuildsView) + fx := newV2Fixture(t) + applier := newApplier(t, fx, editBaseDoc) + _, err := applier.begin() + require.NoError(t, err) + + op := json.RawMessage(`{"op":"move_block","id":"blockParent1"}`) + for i := 0; i < 2; i++ { + require.NoError(t, applier.apply(i, op)) + } + _, err = applier.currentDoc() + + require.NoError(t, err) + assert.Equal(t, 3, applier.marshalCount) + }) +} + +// TestPatchExcludesSystemManagedObjects is F3: the canUpdateObject switch in +// checkEditPreconditions lost its only coverage when PUT went away. It must +// FAIL if the switch is deleted — the assertion is on the refusal itself, +// and the mutator carries no expectation, so a PATCH that got through would +// blow up on the mock too. +func TestPatchExcludesSystemManagedObjects(t *testing.T) { + ctx := context.Background() + for _, sbType := range []model.SmartBlockType{ + model.SmartBlockType_STRelation, + model.SmartBlockType_STRelationOption, + model.SmartBlockType_FileObject, + model.SmartBlockType_Participant, + } { + t.Run(sbType.String(), func(t *testing.T) { + fx := newV2Fixture(t) + read := editRead(t, editBaseDoc) + read.SbType = sbType + fx.readerMock.EXPECT().ReadObject(mock.Anything, testSpaceId, "obj1").Return(read, nil).Maybe() + + _, err := fx.PatchObject(ctx, testSpaceId, "obj1", + patchBody(`{"op":"update_block","id":"blockChild1","set":{"text":"edited"}}`), "", false, true) + + apiErr := v2Err(t, err) + assert.Equal(t, http.StatusBadRequest, apiErr.Status) + assert.Contains(t, apiErr.Message, "system-managed") + assert.Contains(t, apiErr.Message, sbType.String()) + }) + } +} diff --git a/core/api/v2/service/file.go b/core/api/v2/service/file.go new file mode 100644 index 0000000000..c90e98fa31 --- /dev/null +++ b/core/api/v2/service/file.go @@ -0,0 +1,98 @@ +package v2service + +// file.go implements POST /v2/spaces/{space_id}/files (APIV2.md §2 — +// load-bearing, R11): upload by multipart (staged to a local path by the +// handler) or by URL, returning the file object id that file/image blocks +// and iconImage values need. + +import ( + "context" + "fmt" + "net/http" + "strings" + + v2model "github.com/anyproto/anytype-heart/core/api/v2/model" + "github.com/anyproto/anytype-heart/core/domain" + "github.com/anyproto/anytype-heart/core/files/fileuploader" + "github.com/anyproto/anytype-heart/pb" + "github.com/anyproto/anytype-heart/pkg/lib/bundle" + "github.com/anyproto/anytype-heart/pkg/lib/pb/model" +) + +// UploadFile uploads one file into the space, from a staged local path +// (multipart upload) or a URL — exactly one must be set. +func (s *Service) UploadFile(ctx context.Context, spaceId, localPath, url string, dryRun bool) (*v2model.FileUploadResult, error) { + if err := s.ensureSpaceWrite(ctx, spaceId); err != nil { + return nil, err + } + if (localPath == "") == (url == "") { + return nil, v2model.ValidationFailed("provide a file or a url", + v2model.Issue{Message: "upload multipart/form-data with a file field, or JSON {\"url\": …}"}) + } + // the advertised url bound (M6, the file kind's maxLength) + if err := validateV2FieldLength("/url", url, maxV2UrlLength); err != nil { + return nil, err + } + if dryRun { + return &v2model.FileUploadResult{DryRun: true}, nil + } + + resp := s.mw.FileUpload(ctx, &pb.RpcFileUploadRequest{ + SpaceId: spaceId, + LocalPath: localPath, + Url: url, + Type: model.BlockContentFile_None, + Origin: model.ObjectOrigin_api, + }) + if resp.Error != nil && resp.Error.Code != pb.RpcFileUploadResponseError_NULL { + return nil, v2FileRpcError(fmt.Sprintf("upload file to space %s", spaceId), url, + int32(resp.Error.Code), int32(pb.RpcFileUploadResponseError_BAD_INPUT), resp.Error.Description) + } + details := domain.NewDetailsFromProto(resp.Details) + return &v2model.FileUploadResult{ + Id: resp.ObjectId, + Name: details.GetString(bundle.RelationKeyName), + MimeType: details.GetString(bundle.RelationKeyFileMimeType), + Size: details.GetInt64(bundle.RelationKeySizeInBytes), + }, nil +} + +// v2FileRpcError classifies a FileUpload RPC failure into the C6 shape. +// The middleware has exactly ONE error branch for the whole upload — always +// UNKNOWN_ERROR (core/file.go) — so, like chat and spaces, the +// classification rides the error description (surface review M2c); without +// it every failed upload was a retry-looping 500. The URL-mode arms are +// permanent refusals of the caller-supplied url → 400 with the /url path: +// - the uploader's non-2xx source refusal, matched through the +// fileuploader.ErrFailedToDownload sentinel itself so a producer +// rewording updates this matcher at compile time; +// - the net/http fetch failure (`Get "…": …` — url.Error's fixed +// rendering, pinned by TestV2UploadFile against the stdlib type; +// CleanupError masks the URL inside but keeps the shape) covering DNS, +// connection and TLS failures on the way to the source. +// +// Anything else — storage faults, local-path staging failures — stays a +// 500 carrying the description. +func v2FileRpcError(op, url string, code, badInputCode int32, description string) error { + if code == badInputCode { + return v2model.ValidationFailed(fmt.Sprintf("%s: invalid input", op), + v2model.Issue{Message: description}) + } + if url != "" { + switch { + case strings.Contains(description, fileuploader.ErrFailedToDownload.Error()): + return v2model.ValidationFailed(fmt.Sprintf("%s: the source URL did not yield the file", op), + v2model.Issue{Path: "/url", Message: description, + Hint: "the URL must answer 2xx with the file bytes — check it in a browser first"}) + case strings.Contains(description, `Get "`): + return v2model.ValidationFailed(fmt.Sprintf("%s: the source URL could not be fetched", op), + v2model.Issue{Path: "/url", Message: description, + Hint: "the host did not answer (DNS, connection or TLS failure) — check the URL"}) + } + } + msg := op + " failed" + if description != "" { + msg += ": " + description + } + return v2model.NewError(http.StatusInternalServerError, v2model.CodeInternalError, msg) +} diff --git a/core/api/v2/service/file_test.go b/core/api/v2/service/file_test.go new file mode 100644 index 0000000000..80bb5c6483 --- /dev/null +++ b/core/api/v2/service/file_test.go @@ -0,0 +1,172 @@ +package v2service + +import ( + "context" + "errors" + "net/http" + "net/url" + "strings" + "testing" + + "github.com/gogo/protobuf/types" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + + v2model "github.com/anyproto/anytype-heart/core/api/v2/model" + "github.com/anyproto/anytype-heart/core/files/fileuploader" + "github.com/anyproto/anytype-heart/pb" + "github.com/anyproto/anytype-heart/pkg/lib/bundle" + "github.com/anyproto/anytype-heart/util/pbtypes" +) + +func TestV2UploadFile(t *testing.T) { + t.Run("url upload returns the file object id", func(t *testing.T) { + // given + fx := newV2Fixture(t) + fx.mwMock.EXPECT().FileUpload(mock.Anything, mock.MatchedBy(func(req *pb.RpcFileUploadRequest) bool { + return req.SpaceId == testSpaceId && req.Url == "https://example.org/a.pdf" && req.LocalPath == "" + })).Return(&pb.RpcFileUploadResponse{ + ObjectId: "file1", + Details: &types.Struct{Fields: map[string]*types.Value{ + bundle.RelationKeyName.String(): pbtypes.String("a.pdf"), + bundle.RelationKeyFileMimeType.String(): pbtypes.String("application/pdf"), + bundle.RelationKeySizeInBytes.String(): pbtypes.Int64(123), + }}, + Error: &pb.RpcFileUploadResponseError{Code: pb.RpcFileUploadResponseError_NULL}, + }) + want := &v2model.FileUploadResult{Id: "file1", Name: "a.pdf", MimeType: "application/pdf", Size: 123} + + // when + got, err := fx.UploadFile(context.Background(), testSpaceId, "", "https://example.org/a.pdf", false) + + // then + require.NoError(t, err) + assert.Equal(t, want, got) + }) + + t.Run("neither path nor url is a 400", func(t *testing.T) { + // given + fx := newV2Fixture(t) + + // when + _, err := fx.UploadFile(context.Background(), testSpaceId, "", "", false) + + // then + apiErr := v2Err(t, err) + assert.Equal(t, v2model.CodeValidationFailed, apiErr.Code) + }) + + t.Run("M6: the advertised url bound is enforced", func(t *testing.T) { + // given: no FileUpload expectation — reaching the RPC fails the test + fx := newV2Fixture(t) + + // when + _, err := fx.UploadFile(context.Background(), testSpaceId, "", + "https://example.org/"+strings.Repeat("x", maxV2UrlLength), false) + + // then + apiErr := v2Err(t, err) + assert.Equal(t, v2model.CodeValidationFailed, apiErr.Code) + require.NotEmpty(t, apiErr.Issues) + assert.Equal(t, "/url", apiErr.Issues[0].Path) + }) + + t.Run("dry run uploads nothing", func(t *testing.T) { + // given: no FileUpload expectation + fx := newV2Fixture(t) + + // when + got, err := fx.UploadFile(context.Background(), testSpaceId, "", "https://example.org/a.pdf", true) + + // then + require.NoError(t, err) + assert.True(t, got.DryRun) + assert.Empty(t, got.Id) + }) + + t.Run("an unclassified upload failure is a 500 carrying op and description", func(t *testing.T) { + // given + fx := newV2Fixture(t) + fx.mwMock.EXPECT().FileUpload(mock.Anything, mock.Anything).Return(&pb.RpcFileUploadResponse{ + Error: &pb.RpcFileUploadResponseError{Code: pb.RpcFileUploadResponseError_UNKNOWN_ERROR, Description: "boom"}, + }) + + // when + _, err := fx.UploadFile(context.Background(), testSpaceId, "", "https://example.org/a.pdf", false) + + // then + apiErr := v2Err(t, err) + assert.Equal(t, http.StatusInternalServerError, apiErr.Status) + assert.Equal(t, v2model.CodeInternalError, apiErr.Code) + assert.Contains(t, apiErr.Message, "upload file") + assert.Contains(t, apiErr.Message, "boom") + }) + + t.Run("a non-2xx source URL is a 400 on /url, not a retry-looping 500", func(t *testing.T) { + // given: the description the uploader really produces — built from + // the fileuploader sentinel so a producer rewording cannot leave + // this test green against a dead string (surface review M2c) + fx := newV2Fixture(t) + fx.mwMock.EXPECT().FileUpload(mock.Anything, mock.Anything).Return(&pb.RpcFileUploadResponse{ + Error: &pb.RpcFileUploadResponseError{ + Code: pb.RpcFileUploadResponseError_UNKNOWN_ERROR, + Description: fileuploader.ErrFailedToDownload.Error() + ", status: 404", + }, + }) + + // when + _, err := fx.UploadFile(context.Background(), testSpaceId, "", "https://example.org/gone.pdf", false) + + // then + apiErr := v2Err(t, err) + assert.Equal(t, http.StatusBadRequest, apiErr.Status) + assert.Equal(t, v2model.CodeValidationFailed, apiErr.Code) + require.NotEmpty(t, apiErr.Issues) + assert.Equal(t, "/url", apiErr.Issues[0].Path) + }) + + t.Run("an unreachable host is a 400 on /url", func(t *testing.T) { + // given: the description shape net/http produces (url.Error's + // rendering, with the URL masked the way anyerror.CleanupError does + // at the RPC boundary) — built from the stdlib type so a format + // change in Go would surface here, not in production + desc := (&url.Error{Op: "Get", URL: "", Err: errors.New("dial tcp: connection refused")}).Error() + fx := newV2Fixture(t) + fx.mwMock.EXPECT().FileUpload(mock.Anything, mock.Anything).Return(&pb.RpcFileUploadResponse{ + Error: &pb.RpcFileUploadResponseError{ + Code: pb.RpcFileUploadResponseError_UNKNOWN_ERROR, + Description: desc, + }, + }) + + // when + _, err := fx.UploadFile(context.Background(), testSpaceId, "", "https://nope.invalid/a.pdf", false) + + // then + apiErr := v2Err(t, err) + assert.Equal(t, http.StatusBadRequest, apiErr.Status) + require.NotEmpty(t, apiErr.Issues) + assert.Equal(t, "/url", apiErr.Issues[0].Path) + }) + + t.Run("a local-path failure stays a 500 — the url arms are url-mode only", func(t *testing.T) { + // given: a multipart upload (localPath mode) whose description + // happens to contain a Get-shaped fragment must not be blamed on a + // /url the caller never sent + fx := newV2Fixture(t) + fx.mwMock.EXPECT().FileUpload(mock.Anything, mock.Anything).Return(&pb.RpcFileUploadResponse{ + Error: &pb.RpcFileUploadResponseError{ + Code: pb.RpcFileUploadResponseError_UNKNOWN_ERROR, + Description: `cannot read file: Get "x"`, + }, + }) + + // when + _, err := fx.UploadFile(context.Background(), testSpaceId, "/tmp/staged/upload.bin", "", false) + + // then + apiErr := v2Err(t, err) + assert.Equal(t, http.StatusInternalServerError, apiErr.Status) + }) +} diff --git a/core/api/v2/service/forgery_test.go b/core/api/v2/service/forgery_test.go new file mode 100644 index 0000000000..cb4df7afc0 --- /dev/null +++ b/core/api/v2/service/forgery_test.go @@ -0,0 +1,85 @@ +package v2service + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + v2model "github.com/anyproto/anytype-heart/core/api/v2/model" +) + +// The document body is an input channel like any other (review cause 1): +// identity-bearing details must never ride a properties map into the create +// RPC. A forged uniqueKey reaches getUniqueKeyOrGenerate verbatim and +// DERIVES the bundled type's object id — strategy (b)'s silent merge, +// reachable under (a) through a channel the union check never inspects. + +func TestV2TypeDocumentForgery(t *testing.T) { + t.Run("a forged uniqueKey is rejected, path-addressed", func(t *testing.T) { + // given: no create expectation — reaching the RPC fails the test + fx := newV2Fixture(t) + + // when + _, err := fx.CreateType(context.Background(), testSpaceId, + []byte(`{"kind":"object_type","properties":{"name":"Forged","uniqueKey":"ot-page"},"type_settings":{"api_key":"forged"}}`), false, true) + + // then + apiErr := v2ErrWithIssue(t, err) + assert.Equal(t, v2model.CodeValidationFailed, apiErr.Code) + require.NotEmpty(t, apiErr.Issues) + assert.Equal(t, "/properties/uniqueKey", apiErr.Issues[0].Path) + }) + + t.Run("relationKey, isReadonly and restrictions are rejected too", func(t *testing.T) { + fx := newV2Fixture(t) + for _, forged := range []string{"relationKey", "isReadonly", "restrictions"} { + _, err := fx.CreateType(context.Background(), testSpaceId, + []byte(`{"kind":"object_type","type_settings":{"api_key":"forged2"},"properties":{"name":"Forged","`+forged+`":"x"}}`), false, true) + apiErr := v2ErrWithIssue(t, err) + require.NotEmpty(t, apiErr.Issues, forged) + assert.Equal(t, "/properties/"+forged, apiErr.Issues[0].Path) + } + }) + + t.Run("a document-supplied apiObjectKey is refused, never trusted", func(t *testing.T) { + // the slug is derived from the api_key/name and union-checked; a + // document-supplied value would bypass that check ("object_type" + // would shadow the bundled type slug). + // + // §2a closed this one layer earlier and harder than the API's own + // drop did: apiObjectKey is a type_settings member now, so the + // format REFUSES it in `properties` instead of the create silently + // dropping it. The invariant is unchanged — a forged slug never + // reaches the mint — so this asserts the refusal rather than the + // value that used to survive it. No create expectation: reaching the + // RPC at all fails the test. + for _, body := range []string{ + `{"kind":"object_type","properties":{"name":"Clean","apiObjectKey":"object_type"},"type_settings":{"api_key":"cleantype"}}`, + // the sharp case: no api_key, and a name that slugs to nothing, + // so the forged value would have been the ONLY apiObjectKey + `{"kind":"object_type","properties":{"name":"☕","apiObjectKey":"object_type"}}`, + } { + fx := newV2Fixture(t) + _, err := fx.CreateType(context.Background(), testSpaceId, []byte(body), false, true) + + apiErr := v2ErrWithIssue(t, err) + require.NotEmpty(t, apiErr.Issues) + assert.Equal(t, "/properties/apiObjectKey", apiErr.Issues[0].Path) + assert.Contains(t, apiErr.Issues[0].Message, "type_settings") + } + }) + t.Run("an object document must not carry an envelope key", func(t *testing.T) { + // the same forgery through the second channel: doc.Key becomes + // snapshot.Key becomes uniqueKeyInternal becomes DeriveTreeObject + fx := newV2Fixture(t) + + _, err := fx.CreateObject(context.Background(), testSpaceId, + []byte(`{"version":1,"key":"page","blocks":[{"type":"paragraph","text":"x"}]}`), false, true) + + apiErr := v2ErrWithIssue(t, err) + require.NotEmpty(t, apiErr.Issues) + assert.Equal(t, "/key", apiErr.Issues[0].Path) + }) +} diff --git a/core/api/v2/service/grant_test.go b/core/api/v2/service/grant_test.go new file mode 100644 index 0000000000..7cd015a2a3 --- /dev/null +++ b/core/api/v2/service/grant_test.go @@ -0,0 +1,288 @@ +package v2service + +// grant_test.go pins the service-layer half of the space-grant enforcement: +// these tests call the service DIRECTLY with a grant-carrying context — no +// route middleware in front — so they prove the backstop (ensureSpace, +// GetSpace, CreateSpace) and the fan-out intersection (ListSpaces, +// spaceRefs/GlobalSearchObjects) deny on their own, defense in depth. + +import ( + "context" + "errors" + "net/http" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/anyproto/anytype-heart/core/api/util" + v2model "github.com/anyproto/anytype-heart/core/api/v2/model" + "github.com/anyproto/anytype-heart/core/domain" + "github.com/anyproto/anytype-heart/pkg/lib/bundle" + "github.com/anyproto/anytype-heart/pkg/lib/localstore/objectstore" + "github.com/anyproto/anytype-heart/pkg/lib/pb/model" +) + +// grantCtx builds a request context carrying a grant, the way the server's +// ensureAuthenticated does. +func grantCtx(perms string, spaces ...string) context.Context { + return util.CtxWithApiGrant(context.Background(), &util.ApiGrant{Spaces: spaces, Perms: perms}) +} + +func requireSpaceNotGranted(t *testing.T, err error) { + t.Helper() + var v2Err *v2model.Error + require.ErrorAs(t, err, &v2Err) + assert.Equal(t, http.StatusForbidden, v2Err.Status) + assert.Equal(t, v2model.CodeSpaceNotGranted, v2Err.Code) + assert.Contains(t, v2Err.Message, "granted:") +} + +func TestEnsureSpaceGrantBackstop(t *testing.T) { + t.Run("a non-granted space is denied even without the route middleware", func(t *testing.T) { + // given: the service reached directly, as if a future route forgot + // the gate — the ensureSpace backstop must fail closed on its own + fx := newV2Fixture(t) + + // when + _, _, _, err := fx.ListTypes(grantCtx(util.GrantPermsReadWrite, "someOtherSpace"), testSpaceId, 0, 25) + + // then + requireSpaceNotGranted(t, err) + }) + + t.Run("a granted space passes through ensureSpace", func(t *testing.T) { + fx := newV2Fixture(t) + _, _, _, err := fx.ListTypes(grantCtx(util.GrantPermsRead, testSpaceId), testSpaceId, 0, 25) + require.NoError(t, err) + }) + + t.Run("the tech space is denied by the backstop unless explicitly granted", func(t *testing.T) { + // ensureSpace admits the tech space as an ordinary space id AFTER + // the grant check — the ordering is what keeps a scoped key out of + // the tech space when the route middleware is bypassed + fx := newV2Fixture(t) + + _, _, _, err := fx.ListTypes(grantCtx(util.GrantPermsReadWrite, testSpaceId), objectstore.TestTechSpaceId, 0, 25) + requireSpaceNotGranted(t, err) + + _, _, _, err = fx.ListTypes(grantCtx(util.GrantPermsReadWrite, objectstore.TestTechSpaceId), objectstore.TestTechSpaceId, 0, 25) + require.NoError(t, err) + }) + + t.Run("an empty granted-space list denies every space", func(t *testing.T) { + // empty must be impossible at persist time; if ever encountered it + // must NEVER be read as unscoped + fx := newV2Fixture(t) + _, _, _, err := fx.ListTypes(grantCtx(util.GrantPermsReadWrite), testSpaceId, 0, 25) + requireSpaceNotGranted(t, err) + }) + + t.Run("a nil grant (legacy key) passes unchanged", func(t *testing.T) { + fx := newV2Fixture(t) + _, _, _, err := fx.ListTypes(context.Background(), testSpaceId, 0, 25) + require.NoError(t, err) + }) + + t.Run("GetSpace consults the grant directly (it bypasses ensureSpace)", func(t *testing.T) { + fx := newV2Fixture(t) + + _, err := fx.GetSpace(grantCtx(util.GrantPermsRead, "someOtherSpace"), testSpaceId) + requireSpaceNotGranted(t, err) + + space, err := fx.GetSpace(grantCtx(util.GrantPermsRead, testSpaceId), testSpaceId) + require.NoError(t, err) + assert.Equal(t, testSpaceId, space.Id) + }) + + t.Run("a read-only grant is refused by the verb backstop even without the route middleware", func(t *testing.T) { + // the write entry points consult ensureWriteGranted themselves + // (ensureSpaceWrite / ensureChatWrite / UpdateSpace's direct pair): + // a future write route that forgets the middleware still cannot + // mutate with a read-only key + fx := newV2Fixture(t) + ctx := grantCtx(util.GrantPermsRead, testSpaceId) + tests := []struct { + name string + call func() error + }{ + {"CreateObject", func() error { _, err := fx.CreateObject(ctx, testSpaceId, []byte(`{}`), false, true); return err }}, + {"PatchObject", func() error { + _, err := fx.PatchObject(ctx, testSpaceId, "obj1", []byte(`{}`), "", false, true) + return err + }}, + {"CreateType", func() error { _, err := fx.CreateType(ctx, testSpaceId, []byte(`{}`), false, true); return err }}, + {"DeleteProperty", func() error { _, err := fx.DeleteProperty(ctx, testSpaceId, "status", false); return err }}, + {"CreateSet", func() error { + _, err := fx.CreateSet(ctx, testSpaceId, v2model.CreateSetRequest{}, false, true) + return err + }}, + {"UploadFile", func() error { _, err := fx.UploadFile(ctx, testSpaceId, "", "", false); return err }}, + {"CreateChat", func() error { + _, err := fx.CreateChat(ctx, testSpaceId, v2model.CreateChatRequest{Name: "c"}, false) + return err + }}, + // the read-watermark advance is a WRITE, and the verb check runs + // BEFORE the chat lookup — no chat fixture needed + {"ReadChat", func() error { + _, err := fx.ReadChat(ctx, testSpaceId, "chat1", v2model.ChatReadRequest{}, false) + return err + }}, + {"UpdateSpace", func() error { + _, err := fx.UpdateSpace(ctx, testSpaceId, v2model.UpdateSpaceRequest{}, false) + return err + }}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := tt.call() + + var v2Err *v2model.Error + require.ErrorAs(t, err, &v2Err) + assert.Equal(t, http.StatusForbidden, v2Err.Status) + assert.Equal(t, v2model.CodeWriteNotGranted, v2Err.Code) + assert.Contains(t, v2Err.Message, "read-only") + }) + } + }) + + t.Run("space_not_granted wins over write_not_granted on the write backstop", func(t *testing.T) { + // route-gate precedence: the space check runs first, so a read-only + // key writing into a NON-granted space is told about the space + fx := newV2Fixture(t) + + _, err := fx.CreateObject(grantCtx(util.GrantPermsRead, "someOtherSpace"), testSpaceId, []byte(`{}`), false, true) + + requireSpaceNotGranted(t, err) + }) + + t.Run("a readwrite grant passes the verb backstop", func(t *testing.T) { + // the next failure is the ordinary validation error — the check fell + // through instead of short-circuiting readwrite keys + fx := newV2Fixture(t) + + _, err := fx.CreateChat(grantCtx(util.GrantPermsReadWrite, testSpaceId), + testSpaceId, v2model.CreateChatRequest{}, false) + + var v2Err *v2model.Error + require.ErrorAs(t, err, &v2Err) + assert.Equal(t, v2model.CodeValidationFailed, v2Err.Code) + }) + + t.Run("CreateSpace refuses every granted key at the service layer too", func(t *testing.T) { + // a key that can mint spaces it then owns is not meaningfully + // scoped — the route gate denies POST /v2/spaces, and this is the + // backstop for a path that skips it + fx := newV2Fixture(t) + + _, err := fx.CreateSpace(grantCtx(util.GrantPermsReadWrite, testSpaceId), + v2model.CreateSpaceRequest{Name: "New"}, false) + + var v2Err *v2model.Error + require.True(t, errors.As(err, &v2Err)) + assert.Equal(t, v2model.CodeSpaceNotGranted, v2Err.Code) + assert.Contains(t, v2Err.Message, "cannot create spaces") + }) +} + +func TestListSpacesGrantIntersection(t *testing.T) { + t.Run("three live spaces, grant covers one", func(t *testing.T) { + // given + fx := newV2FixtureBare(t) + for _, space := range []struct{ id, name string }{ + {"spaceA", "Work"}, {"spaceB", "Personal"}, {"spaceC", "Diary"}, + } { + fx.objectStore.AddObjects(t, objectstore.TestTechSpaceId, []objectstore.TestObject{{ + bundle.RelationKeyId: domain.String("spaceView_" + space.id), + bundle.RelationKeyResolvedLayout: domain.Int64(int64(model.ObjectType_spaceView)), + bundle.RelationKeyTargetSpaceId: domain.String(space.id), + bundle.RelationKeyName: domain.String(space.name), + }}) + } + want := []v2model.SpaceRow{{Id: "spaceA", Name: "Work"}} + + // when + rows, total, hasMore, err := fx.ListSpaces(grantCtx(util.GrantPermsRead, "spaceA"), 0, 25) + + // then: the grant intersects the space set — total counts only + // granted spaces, so even the COUNT of others is not disclosed + require.NoError(t, err) + assert.Equal(t, want, rows) + assert.Equal(t, 1, total) + assert.False(t, hasMore) + }) +} + +func TestGlobalSearchGrantIntersection(t *testing.T) { + // given: an object in each of two live spaces, grant covers space1 only + setup := func(t *testing.T) *v2Fixture { + fx := newV2Fixture(t) + fx.registerSpace(t, "space2") + for spaceId, objectId := range map[string]string{testSpaceId: "docA", "space2": "docB"} { + fx.objectStore.AddObjects(t, spaceId, []objectstore.TestObject{{ + bundle.RelationKeyId: domain.String(objectId), + bundle.RelationKeyName: domain.String("Doc in " + spaceId), + bundle.RelationKeyResolvedLayout: domain.Int64(int64(model.ObjectType_basic)), + bundle.RelationKeyLastModifiedDate: domain.Int64(1000), + }}) + } + return fx + } + + t.Run("results come only from granted spaces, totals included", func(t *testing.T) { + fx := setup(t) + + rows, total, hasMore, warnings, err := fx.GlobalSearchObjects( + grantCtx(util.GrantPermsRead, testSpaceId), v2model.SearchRequest{}, 0, 25) + + require.NoError(t, err) + assert.Equal(t, []string{"docA"}, rowIds(rows)) + assert.Equal(t, 1, total, "total must not count non-granted spaces") + assert.False(t, hasMore) + assert.Empty(t, warnings) + }) + + t.Run("a non-granted space never appears in warnings", func(t *testing.T) { + // the intersection happens on the INPUT set (spaceRefs), not on the + // output rows: the probe is a type that resolves ONLY in the granted + // space, the exact shape that makes an intersected-away space emit + // `space "space2" was skipped` if it enters the fan-out loop — which + // would disclose that the space exists. Warnings must be EMPTY, not + // merely free of the id: the skip warning names the space by name. + fx := setup(t) + fx.objectStore.AddObjects(t, testSpaceId, []objectstore.TestObject{ + { + bundle.RelationKeyId: domain.String("type-chore"), + bundle.RelationKeyName: domain.String("Chore"), + bundle.RelationKeyUniqueKey: domain.String("ot-chore"), + bundle.RelationKeyResolvedLayout: domain.Int64(int64(model.ObjectType_objectType)), + }, + { + bundle.RelationKeyId: domain.String("chore1"), + bundle.RelationKeyName: domain.String("A chore"), + bundle.RelationKeyType: domain.String("type-chore"), + bundle.RelationKeyResolvedLayout: domain.Int64(int64(model.ObjectType_basic)), + bundle.RelationKeyLastModifiedDate: domain.Int64(2000), + }, + }) + + rows, total, _, warnings, err := fx.GlobalSearchObjects( + grantCtx(util.GrantPermsRead, testSpaceId), v2model.SearchRequest{Type: "chore"}, 0, 25) + + require.NoError(t, err) + assert.Equal(t, []string{"chore1"}, rowIds(rows)) + assert.Equal(t, 1, total) + require.Empty(t, warnings, "a skip warning here could only name the non-granted space") + }) + + t.Run("a nil grant fans out over every space unchanged", func(t *testing.T) { + fx := setup(t) + + rows, total, _, _, err := fx.GlobalSearchObjects( + context.Background(), v2model.SearchRequest{}, 0, 25) + + require.NoError(t, err) + assert.ElementsMatch(t, []string{"docA", "docB"}, rowIds(rows)) + assert.Equal(t, 2, total) + }) +} diff --git a/core/api/v2/service/idshape.go b/core/api/v2/service/idshape.go new file mode 100644 index 0000000000..676b7c167b --- /dev/null +++ b/core/api/v2/service/idshape.go @@ -0,0 +1,66 @@ +package v2service + +// idshape.go carries `?ids=` — the ONE parameter that chooses between the +// compact serving vocabulary and the full spelling (C4) — from the route +// layer down to the services that decide how an id is SPELLED in a response. +// +// WHY it needs a carrier at all. `?ids=` selects a serving SHAPE, and a +// response has more than one axis of spelling: the object read spells block +// ids (short doc-local labels vs the machine-minted ones), and every +// space-serving surface spells space ids (§8.35's short reference vs the +// full `.` id). One parameter means one thing on all of +// them — "full spells everything in full" — so a caller asking for the +// export shape gets it everywhere in that response rather than on whichever +// axis the endpoint happens to own (§8.36). +// +// WHY the request context and not a parameter. servedSpaceRef is reached +// from GetSpace, CreateSpace, UpdateSpace, ListSpaces, the global-search +// fan-out and whoami — and several of those call each other, so threading a +// flag would mean touching every signature between the route and the +// spelling decision, and a surface added later would default to the wrong +// answer by omission. The ctx is the same carrier the §8.35 echo uses. + +import ( + "context" + "fmt" + + v2model "github.com/anyproto/anytype-heart/core/api/v2/model" +) + +// ParseIdsShape validates a raw `?ids=` value and reports whether the caller +// asked for the FULL spelling. +// +// This is the ONE definition of the parameter's legal values and of the 400 +// an unknown one earns. The route middleware (which carries the answer to +// the space surfaces) and the object read's own plan validation both go +// through it, so the two cannot come to disagree about what `ids=export` +// means — the second copy of a value list is how a surface starts accepting +// on one route what it refuses on another. +func ParseIdsShape(raw string) (full bool, err error) { + switch raw { + case "", V2IdsCompact: + return false, nil + case V2IdsFull: + return true, nil + default: + return false, v2model.ValidationFailed("invalid ids value", + v2model.Issue{Path: "ids", Message: fmt.Sprintf("unknown value %q", raw), Hint: "allowed: compact, full"}) + } +} + +// fullIdsKey carries the request's answer to `?ids=`. +type fullIdsKey struct{} + +// CtxWithFullIds records that this request asked for `?ids=full` — the +// export spelling, on every id the response carries. +func CtxWithFullIds(ctx context.Context) context.Context { + return context.WithValue(ctx, fullIdsKey{}, true) +} + +// fullIdsRequested reports whether this request asked for the full spelling. +// Absent — which is what every internal caller and every test that does not +// care carries — means compact, the default serving shape. +func fullIdsRequested(ctx context.Context) bool { + full, _ := ctx.Value(fullIdsKey{}).(bool) + return full +} diff --git a/core/api/v2/service/idshape_test.go b/core/api/v2/service/idshape_test.go new file mode 100644 index 0000000000..ea18597c5f --- /dev/null +++ b/core/api/v2/service/idshape_test.go @@ -0,0 +1,76 @@ +package v2service + +// idshape_test.go pins the ONE definition of `?ids=` (APIV2.md §8.36): its +// legal values, the 400 an unknown one earns, and the fact that the object +// read's own validation is that same definition rather than a second copy of +// it. + +import ( + "context" + "net/http" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + v2model "github.com/anyproto/anytype-heart/core/api/v2/model" +) + +func TestParseIdsShape(t *testing.T) { + t.Run("the legal values, and which one is the default", func(t *testing.T) { + for raw, want := range map[string]bool{"": false, V2IdsCompact: false, V2IdsFull: true} { + // when + full, err := ParseIdsShape(raw) + + // then + require.NoError(t, err, "ids=%q", raw) + assert.Equal(t, want, full, "ids=%q", raw) + } + }) + + t.Run("an unknown value is a 400 naming the allowed ones", func(t *testing.T) { + // given: the shapes a caller plausibly guesses + for _, raw := range []string{"export", "FULL", "true", "short"} { + // when + _, err := ParseIdsShape(raw) + + // then + var apiErr *v2model.Error + require.ErrorAs(t, err, &apiErr, "ids=%q must be refused", raw) + assert.Equal(t, http.StatusBadRequest, apiErr.Status) + require.Len(t, apiErr.Issues, 1) + assert.Equal(t, "ids", apiErr.Issues[0].Path) + assert.Contains(t, apiErr.Issues[0].Hint, "compact, full") + } + }) + + t.Run("the object read's plan validation IS this parse, not a second copy", func(t *testing.T) { + // given / when + compact, err := ObjectQuery{Ids: V2IdsCompact}.validate() + require.NoError(t, err) + full, err := ObjectQuery{Ids: V2IdsFull}.validate() + require.NoError(t, err) + _, unknownErr := ObjectQuery{Ids: "export"}.validate() + + // then: `full` is the export shape — full block ids, no relabeling + assert.True(t, compact.compactBlockLabels) + assert.False(t, full.compactBlockLabels) + + var apiErr *v2model.Error + require.ErrorAs(t, unknownErr, &apiErr) + assert.Equal(t, http.StatusBadRequest, apiErr.Status) + require.Len(t, apiErr.Issues, 1) + assert.Equal(t, "ids", apiErr.Issues[0].Path) + }) +} + +func TestFullIdsCtx(t *testing.T) { + t.Run("an untouched context means the compact default", func(t *testing.T) { + // given: what every internal caller and every test carries + assert.False(t, fullIdsRequested(context.Background())) + }) + + t.Run("CtxWithFullIds is what the route middleware records", func(t *testing.T) { + assert.True(t, fullIdsRequested(CtxWithFullIds(context.Background()))) + }) +} diff --git a/core/api/v2/service/keycanon.go b/core/api/v2/service/keycanon.go new file mode 100644 index 0000000000..d8e4c73157 --- /dev/null +++ b/core/api/v2/service/keycanon.go @@ -0,0 +1,269 @@ +package v2service + +// keycanon.go — input canonicalization for the QUERY channels (search +// fields/filters/sorts, list fields, set creation): the listings advertise +// served spellings (a BSON-keyed property answers to its slug), so every +// channel that takes a property key must accept that spelling and translate +// it to the STORED key the store binds (review cause 3 — before this, the +// API advertised exactly the key its query channels rejected). One primed +// snapshot per request (§7.5a-2); translation through the same §7.5a-5 +// chain every other channel walks, file aliases folded in front. + +import ( + "encoding/json" + "fmt" + + v2model "github.com/anyproto/anytype-heart/core/api/v2/model" + "github.com/anyproto/anytype-heart/core/domain" + "github.com/anyproto/anytype-heart/pkg/lib/bundle" +) + +// keyCanon is the per-request canonicalizer. +type keyCanon struct { + s *Service + entries []propertyEntry + aliases map[string]domain.RelationKey // chain-aware active file aliases +} + +func (s *Service) newKeyCanon(spaceId string) (*keyCanon, error) { + entries, err := s.liveProperties(spaceId) + if err != nil { + return nil, err + } + return &keyCanon{s: s, entries: entries, aliases: s.activeFieldAliasesIn(entries)}, nil +} + +// canon translates one concrete input to its stored spelling: an active +// file alias maps to its backing relation; otherwise the §7.5a-5 chain +// resolves (stored key, slug, bundled, fold). Ambiguity returns the +// candidates (the caller owes the loud 400); a miss passes through +// verbatim — membership validation owns that refusal. +func (k *keyCanon) canon(input string) (string, []string) { + if backing, ok := k.aliases[input]; ok { + return string(backing), nil + } + entry, ok, ambiguous := k.s.resolvePropertyInput(input, k.entries) + if len(ambiguous) > 0 { + return input, ambiguous + } + if ok && entry.Key != "" { + return entry.Key, nil + } + return input, nil +} + +// withServedSpellings widens a stored-key reference set with every +// spelling the chain resolves for it: the served spelling (the listing's — +// C2's one-vocabulary promise, kept across mint and listing) and, for +// bundled keys, the derived slug (`due_date` for `dueDate` — the routes +// and documents accept it, so the string-form filter validator must too; +// acceptance is wider than advertising). +func (k *keyCanon) withServedSpellings(stored []string) []string { + keyTaken, slugHolders := servedPropertyKeySets(k.entries) + bySlug := map[string]string{} + for _, entry := range k.entries { + if served := servedKey(entry.Key, entry.Slug, keyTaken, slugHolders); served != entry.Key { + bySlug[entry.Key] = served + } + } + out := append([]string{}, stored...) + for _, key := range stored { + if served, ok := bySlug[key]; ok { + out = append(out, served) + } + if bundle.HasRelation(domain.RelationKey(key)) { + if slug := bundle.ApiSlug(key); slug != key { + out = append(out, slug) + } + } + } + return sortedDistinct(out) +} + +// servedSpellings maps a stored-key list to its served spellings (for +// candidate lists and did-you-mean — never advertise a spelling the +// channels reject). +func (k *keyCanon) servedSpellings(stored []string) []string { + keyTaken, slugHolders := servedPropertyKeySets(k.entries) + byKey := map[string]string{} + for _, entry := range k.entries { + byKey[entry.Key] = servedKey(entry.Key, entry.Slug, keyTaken, slugHolders) + } + out := make([]string, 0, len(stored)) + for _, key := range stored { + if served, ok := byKey[key]; ok { + out = append(out, served) + continue + } + out = append(out, key) + } + return sortedDistinct(out) +} + +// ---- generic JSON rewriters for the §6.2 channels ---- +// +// The set-create request carries filters/sorts/views as raw JSON that lands +// in the set document verbatim — a served-slug property key there would +// bind a dataview filter to a spelling the store never matches, silently. +// The rewriters walk generic JSON (no partial-struct re-marshal, so no +// field is ever dropped) and canonicalize exactly the key slots +// collectViewPropertyKeys reads: filter nodes' property (recursive), sorts' +// property, views' groupBy/sorts/columns/filters. + +func (k *keyCanon) canonOrErr(input, path string) (string, error) { + canonical, ambiguous := k.canon(input) + if len(ambiguous) > 0 { + return "", ambiguousKeyError("property key", input, path, ambiguous) + } + return canonical, nil +} + +func (k *keyCanon) rewriteFilterNodes(nodes []any, path string) error { + for i, raw := range nodes { + node, ok := raw.(map[string]any) + if !ok { + continue + } + nodePath := fmt.Sprintf("%s/%d", path, i) + if prop, ok := node["property"].(string); ok && prop != "" { + canonical, err := k.canonOrErr(prop, nodePath+"/property") + if err != nil { + return err + } + node["property"] = canonical + } + if nested, ok := node["filters"].([]any); ok { + if err := k.rewriteFilterNodes(nested, nodePath+"/filters"); err != nil { + return err + } + } + } + return nil +} + +func (k *keyCanon) rewriteSorts(sorts []any, path string) error { + for i, raw := range sorts { + sort, ok := raw.(map[string]any) + if !ok { + continue + } + if prop, ok := sort["property"].(string); ok && prop != "" { + canonical, err := k.canonOrErr(prop, fmt.Sprintf("%s/%d/property", path, i)) + if err != nil { + return err + } + sort["property"] = canonical + } + } + return nil +} + +func (k *keyCanon) rewriteViews(views []any, path string) error { + for i, raw := range views { + view, ok := raw.(map[string]any) + if !ok { + continue + } + prefix := fmt.Sprintf("%s/%d", path, i) + if groupBy, ok := view["group_by"].(string); ok && groupBy != "" { + canonical, err := k.canonOrErr(groupBy, prefix+"/groupBy") + if err != nil { + return err + } + view["group_by"] = canonical + } + if sorts, ok := view["sorts"].([]any); ok { + if err := k.rewriteSorts(sorts, prefix+"/sorts"); err != nil { + return err + } + } + if columns, ok := view["columns"].([]any); ok { + for j, rawCol := range columns { + column, ok := rawCol.(map[string]any) + if !ok { + continue + } + if prop, ok := column["property"].(string); ok && prop != "" { + canonical, err := k.canonOrErr(prop, fmt.Sprintf("%s/columns/%d/property", prefix, j)) + if err != nil { + return err + } + column["property"] = canonical + } + } + } + if filters, ok := view["filters"].([]any); ok { + if err := k.rewriteFilterNodes(filters, prefix+"/filters"); err != nil { + return err + } + } + } + return nil +} + +// canonicalizeRawChannel decodes one raw §6.2 channel, rewrites its key +// slots, and re-encodes. kind selects the walker. +func (k *keyCanon) canonicalizeRawChannel(raw json.RawMessage, kind, path string) (json.RawMessage, error) { + if len(raw) == 0 { + return raw, nil + } + var decoded []any + if err := json.Unmarshal(raw, &decoded); err != nil { + return raw, nil // shape errors belong to the channel's own decoder + } + var err error + switch kind { + case "filters": + err = k.rewriteFilterNodes(decoded, path) + case "sorts": + err = k.rewriteSorts(decoded, path) + case "views": + err = k.rewriteViews(decoded, path) + } + if err != nil { + return nil, err + } + out, err := json.Marshal(decoded) + if err != nil { + return nil, fmt.Errorf("re-encode %s: %w", kind, err) + } + return out, nil +} + +// canonFormatName wraps a format-name resolver with canonicalization, so a +// served slug's format resolves during filter parsing/validation exactly as +// the stored spelling's does. +func canonFormatName(base func(string) (string, bool), kc *keyCanon) func(string) (string, bool) { + return func(key string) (string, bool) { + if name, ok := base(key); ok { + return name, ok + } + canonical, ambiguous := kc.canon(key) + if len(ambiguous) > 0 || canonical == key { + return "", false + } + return base(canonical) + } +} + +// ambiguousInputIssue converts chain candidates to a path-addressed issue +// (shared shape with ambiguousKeyError, for issue-list callers). +func ambiguousInputIssue(what, input, path string, candidates []string) v2model.Issue { + return v2model.Issue{Path: path, + Message: fmt.Sprintf("%s %q matches %s", what, input, joinAnd(candidates)), + Hint: "address the intended one by its exact key"} +} + +func joinAnd(parts []string) string { + switch len(parts) { + case 0: + return "" + case 1: + return parts[0] + } + out := parts[0] + for _, p := range parts[1:] { + out += " and " + p + } + return out +} diff --git a/core/api/v2/service/keys.go b/core/api/v2/service/keys.go new file mode 100644 index 0000000000..4a23de1ca8 --- /dev/null +++ b/core/api/v2/service/keys.go @@ -0,0 +1,951 @@ +package v2service + +// keys.go — the space's live type/property key surface (APIV2_ADDRESSING.md §7.5, +// §7.5a): one bounded details query per kind primes entries carrying the +// stored key, the stored api slug (apiObjectKey), name and format. +// +// "Live" is the §7.5-requirement-2 corpse policy: archived and deleted +// objects are excluded by the store's injected defaults, and *uninstalled* — +// the UI-delete flag, which nothing else in the query layer filters (the +// §2.3-6 defect: a UI-deleted property still resolved by key, still listed +// in GET /properties, and blocked a same-key create) — is excluded here +// explicitly. A corpse must neither list, nor resolve as an address, nor +// hold its slug against a same-key create. + +import ( + "context" + "encoding/json" + "fmt" + "sort" + "strings" + + v2model "github.com/anyproto/anytype-heart/core/api/v2/model" + "github.com/anyproto/anytype-heart/core/domain" + "github.com/anyproto/anytype-heart/pkg/lib/anyblockjson" + "github.com/anyproto/anytype-heart/pkg/lib/bundle" + "github.com/anyproto/anytype-heart/pkg/lib/database" + "github.com/anyproto/anytype-heart/pkg/lib/pb/model" +) + +// propertyEntry is one live relation's identity row. +type propertyEntry struct { + Id string + Key string // stored relation key (bundled word, legacy readable, or BSON) + Slug string // stored apiObjectKey; empty for pre-slug keys + Name string + Format model.RelationFormat + // Hidden entries stay addressable by their exact stored key but do NOT + // participate in the slug namespace (resolution, fold, collision, + // serving): a hidden holder is invisible and undeletable to the caller, + // so letting it block or ambiguate a visible holder's slug would make + // that slug permanently unusable through no visible cause. + Hidden bool +} + +// typeEntry is one live type object's identity row. +type typeEntry struct { + Id string + Key string // internal type key (uniqueKey's internal part) + Slug string + Name string + Hidden bool +} + +// livePropertyFilters are the corpse-policy filters for relation queries: +// layout, plus the explicit isUninstalled exclusion (isArchived/isDeleted +// ride the store's injected defaults). +func livePropertyFilters() []database.FilterRequest { + return []database.FilterRequest{ + { + RelationKey: bundle.RelationKeyResolvedLayout, + Condition: model.BlockContentDataviewFilter_Equal, + Value: domain.Int64(int64(model.ObjectType_relation)), + }, + { + RelationKey: bundle.RelationKeyIsUninstalled, + Condition: model.BlockContentDataviewFilter_NotEqual, + Value: domain.Bool(true), + }, + } +} + +// liveTypeFilters is livePropertyFilters for type objects. +func liveTypeFilters() []database.FilterRequest { + return []database.FilterRequest{ + { + RelationKey: bundle.RelationKeyResolvedLayout, + Condition: model.BlockContentDataviewFilter_Equal, + Value: domain.Int64(int64(model.ObjectType_objectType)), + }, + { + RelationKey: bundle.RelationKeyIsUninstalled, + Condition: model.BlockContentDataviewFilter_NotEqual, + Value: domain.Bool(true), + }, + } +} + +// liveProperties lists the space's live relations — one bounded query, the +// per-request resolver shape ADDRESSING §7.5a-2 prescribes (tens to low +// hundreds of rows; never one query per reference). A store error is +// returned, never swallowed: the collision check and the resolution chain +// are load-bearing, and an empty-looking namespace on a store hiccup would +// wave every collision through (fail closed, not open). +func (s *Service) liveProperties(spaceId string) ([]propertyEntry, error) { + records, err := s.store.SpaceIndex(spaceId).Query(database.Query{Filters: livePropertyFilters()}) + if err != nil { + return nil, fmt.Errorf("query live properties of space %s: %w", spaceId, err) + } + entries := make([]propertyEntry, 0, len(records)) + for _, record := range records { + key := record.Details.GetString(bundle.RelationKeyRelationKey) + if key == "" { + continue + } + entries = append(entries, propertyEntry{ + Id: record.Details.GetString(bundle.RelationKeyId), + Key: key, + Slug: record.Details.GetString(bundle.RelationKeyApiObjectKey), + Name: record.Details.GetString(bundle.RelationKeyName), + Format: model.RelationFormat(record.Details.GetInt64(bundle.RelationKeyRelationFormat)), + Hidden: record.Details.GetBool(bundle.RelationKeyIsHidden), + }) + } + return entries, nil +} + +// liveTypes lists the space's live type objects (error contract as above). +func (s *Service) liveTypes(spaceId string) ([]typeEntry, error) { + records, err := s.store.SpaceIndex(spaceId).Query(database.Query{Filters: liveTypeFilters()}) + if err != nil { + return nil, fmt.Errorf("query live types of space %s: %w", spaceId, err) + } + entries := make([]typeEntry, 0, len(records)) + for _, record := range records { + key, err := domain.GetTypeKeyFromRawUniqueKey(record.Details.GetString(bundle.RelationKeyUniqueKey)) + if err != nil { + continue + } + entries = append(entries, typeEntry{ + Id: record.Details.GetString(bundle.RelationKeyId), + Key: string(key), + Slug: record.Details.GetString(bundle.RelationKeyApiObjectKey), + Name: record.Details.GetString(bundle.RelationKeyName), + Hidden: record.Details.GetBool(bundle.RelationKeyIsHidden), + }) + } + return entries, nil +} + +// resolvePropertyInput implements the §7.5a-5 resolution chain for one +// inbound property term over a primed live set (entries are mandatory — +// the caller loads them and owns the load error, so a store hiccup fails +// closed, never open): (1) exact live stored key; (2) the space's live +// slug namespace; (3) the bundled vocabulary — exact key or derived slug +// (`due_date` names bundled dueDate); (4) the forgiving fold layer +// (§7.5a-3: lowercase, `_`/`-` stripped — `dueDate` for `due_date`). +// Hidden entries answer to their exact stored key only (step 1) and are +// invisible to the slug and fold steps — see propertyEntry.Hidden. +// Ambiguity at any step returns the candidate descriptions and never a +// guess (the git rule); a full miss returns ok=false and the caller's R9 +// machinery owns the refusal. A resolved bundled term that is not installed +// in the space has Id == "" — address routes require an installed entry, +// existence checks do not. +func (s *Service) resolvePropertyInput(input string, entries []propertyEntry) (propertyEntry, bool, []string) { + // 1: exact stored key (hidden included — the stored key is always an + // address) + for _, entry := range entries { + if entry.Key == input { + return entry, true, nil + } + } + // 2: exact live slug — two visible holders is the loud ambiguity + var slugMatches []propertyEntry + for _, entry := range entries { + if !entry.Hidden && entry.Slug != "" && entry.Slug == input { + slugMatches = append(slugMatches, entry) + } + } + if len(slugMatches) == 1 { + if shadowed, ok := shadowedBundledProperty(input, slugMatches[0].Key); ok { + return propertyEntry{}, false, append(describePropertyEntries(slugMatches), shadowed) + } + return slugMatches[0], true, nil + } + if len(slugMatches) > 1 { + return propertyEntry{}, false, describePropertyEntries(slugMatches) + } + // 3: the bundled vocabulary (exact key, then the derived table) + if rel, err := bundle.PickRelation(domain.RelationKey(input)); err == nil { + return propertyEntry{Key: input, Name: rel.Name, Format: rel.Format}, true, nil + } + if key, ok := bundle.RelationKeyByApiSlug(input); ok { + for _, entry := range entries { + if entry.Key == string(key) { + return entry, true, nil + } + } + rel := bundle.MustGetRelation(key) + return propertyEntry{Key: string(key), Name: rel.Name, Format: rel.Format}, true, nil + } + // 4: the fold layer — exact has failed everywhere, so a single folded + // candidate is the intended forgiveness and several are a loud 400 + fold := bundle.FoldApiKey(input) + var candidates []propertyEntry + seen := map[string]bool{} + for _, entry := range entries { + if entry.Hidden { + continue + } + if bundle.FoldApiKey(entry.Key) == fold || (entry.Slug != "" && bundle.FoldApiKey(entry.Slug) == fold) { + if !seen[entry.Key] { + seen[entry.Key] = true + candidates = append(candidates, entry) + } + } + } + for _, key := range bundle.RelationKeysByApiFold(input) { + if seen[string(key)] { + continue + } + seen[string(key)] = true + rel := bundle.MustGetRelation(key) + candidates = append(candidates, propertyEntry{Key: string(key), Name: rel.Name, Format: rel.Format}) + } + if len(candidates) == 1 { + return candidates[0], true, nil + } + if len(candidates) > 1 { + return propertyEntry{}, false, describePropertyEntries(candidates) + } + return propertyEntry{}, false, nil +} + +// resolveTypeInput is resolvePropertyInput for the type namespace. +func (s *Service) resolveTypeInput(input string, entries []typeEntry) (typeEntry, bool, []string) { + for _, entry := range entries { + if entry.Key == input { + return entry, true, nil + } + } + var slugMatches []typeEntry + for _, entry := range entries { + if !entry.Hidden && entry.Slug != "" && entry.Slug == input { + slugMatches = append(slugMatches, entry) + } + } + if len(slugMatches) == 1 { + if shadowed, ok := shadowedBundledType(input, slugMatches[0].Key); ok { + return typeEntry{}, false, append(describeTypeEntries(slugMatches), shadowed) + } + return slugMatches[0], true, nil + } + if len(slugMatches) > 1 { + return typeEntry{}, false, describeTypeEntries(slugMatches) + } + if t, err := bundle.GetType(domain.TypeKey(input)); err == nil { + return typeEntry{Key: input, Name: t.Name}, true, nil + } + if key, ok := bundle.TypeKeyByApiSlug(input); ok { + for _, entry := range entries { + if entry.Key == string(key) { + return entry, true, nil + } + } + t := bundle.MustGetType(key) + return typeEntry{Key: string(key), Name: t.Name}, true, nil + } + fold := bundle.FoldApiKey(input) + var candidates []typeEntry + seen := map[string]bool{} + for _, entry := range entries { + if entry.Hidden { + continue + } + if bundle.FoldApiKey(entry.Key) == fold || (entry.Slug != "" && bundle.FoldApiKey(entry.Slug) == fold) { + if !seen[entry.Key] { + seen[entry.Key] = true + candidates = append(candidates, entry) + } + } + } + for _, key := range bundle.TypeKeysByApiFold(input) { + if seen[string(key)] { + continue + } + seen[string(key)] = true + t := bundle.MustGetType(key) + candidates = append(candidates, typeEntry{Key: string(key), Name: t.Name}) + } + if len(candidates) == 1 { + return candidates[0], true, nil + } + if len(candidates) > 1 { + return typeEntry{}, false, describeTypeEntries(candidates) + } + return typeEntry{}, false, nil +} + +// shadowedBundledProperty reports whether a STORED slug that just matched at +// chain step 2 shadows a different key in the bundled vocabulary — the +// live-defect shape ADDRESSING §7.5a-6 names: a UI property named "Due Date" +// took `due_date`, which is bundled `dueDate`'s derived slug, and every +// `set_properties {"set": {"due_date": …}}` since has landed in that property +// instead of the bundled one. Silently. +// +// New shadows are unreachable through every write channel that stamps +// `apiObjectKey`: the heart-side mint runs the union check +// (objectcreator/apikey.go), v2's POST refuses a taken slug, and v1's rename +// channel — which never enters objectcreator and so was NOT covered by the +// mint hardening, only by a per-space cache with no row for an uninstalled +// bundled relation — now applies the bundled arm too (service/property.go's +// shadowsBundledRelationKey). A space that already holds a shadow still cannot +// be repaired without re-pointing a slug v1 has been serving as an address +// (ADDRESSING §8-OQ3 owns that decision). What CAN be fixed without touching +// stored data is the failure mode: chain step 2 no longer picks the squatter +// over the bundled property — the input is ambiguous, and ambiguity at any +// step is a loud 400 listing every holder (§7.5-req-1). Wrong-and-silent +// becomes refused-and-actionable. +// +// The check is exact, never folded: only a slug that the bundled table +// itself resolves to a DIFFERENT key shadows anything. A bundled relation +// carrying its own derived slug (dueDate/due_date) is not a shadow, and a +// stored KEY spelled like a bundled slug still wins at step 1 — that is the +// chain's documented precedence (§8.23's stored-key-shadow case), not this. +func shadowedBundledProperty(input, matchedKey string) (string, bool) { + key, ok := bundle.RelationKeyByApiSlug(input) + if !ok || string(key) == matchedKey { + return "", false + } + rel := bundle.MustGetRelation(key) + return fmt.Sprintf("the bundled %q (key %s)", rel.Name, key), true +} + +// shadowedBundledType is shadowedBundledProperty for the type namespace. +func shadowedBundledType(input, matchedKey string) (string, bool) { + key, ok := bundle.TypeKeyByApiSlug(input) + if !ok || string(key) == matchedKey { + return "", false + } + return fmt.Sprintf("the bundled %q (key %s)", bundle.MustGetType(key).Name, key), true +} + +// describePropertyEntries renders ambiguity candidates ACTIONABLY: the +// stored key is the one address that always resolves (twin slugs print +// identically, so the slug alone steers the caller back into the same +// 400 — the review's unactionable-floor finding). +func describePropertyEntries(entries []propertyEntry) []string { + out := make([]string, 0, len(entries)) + for _, entry := range entries { + out = append(out, fmt.Sprintf("%q (key %s, id %s)", entry.Name, entry.Key, entry.Id)) + } + return out +} + +func describeTypeEntries(entries []typeEntry) []string { + out := make([]string, 0, len(entries)) + for _, entry := range entries { + out = append(out, fmt.Sprintf("%q (key %s, id %s)", entry.Name, entry.Key, entry.Id)) + } + return out +} + +// ambiguousKeyError is the loud 400 the chain owes an input that several +// holders answer to — candidates listed, never a guess (C6/§7.5a-3). +func ambiguousKeyError(what, input, path string, candidates []string) error { + return v2model.AmbiguousInput( + fmt.Sprintf("%s %q is ambiguous", what, input), + v2model.Issue{Path: path, + Message: fmt.Sprintf("%q matches %s", input, strings.Join(candidates, " and ")), + Hint: "address the intended one by its exact key"}) +} + +// requireLiveProperty resolves a property-addressing route param: ambiguity +// is a 400, anything not installed live in the space is the keyed 404, and +// a store failure propagates (fail closed). +func (s *Service) requireLiveProperty(spaceId, input string) (propertyEntry, error) { + entries, err := s.liveProperties(spaceId) + if err != nil { + return propertyEntry{}, err + } + entry, ok, ambiguous := s.resolvePropertyInput(input, entries) + if len(ambiguous) > 0 { + return propertyEntry{}, ambiguousKeyError("property key", input, "/key", ambiguous) + } + if !ok || entry.Id == "" { + return propertyEntry{}, s.propertyNotFoundError(spaceId, input) + } + return entry, nil +} + +// requireLiveType is requireLiveProperty for type routes. +func (s *Service) requireLiveType(spaceId, input, path string) (typeEntry, error) { + entries, err := s.liveTypes(spaceId) + if err != nil { + return typeEntry{}, err + } + entry, ok, ambiguous := s.resolveTypeInput(input, entries) + if len(ambiguous) > 0 { + return typeEntry{}, ambiguousKeyError("type key", input, path, ambiguous) + } + if !ok || entry.Id == "" { + return typeEntry{}, s.typeNotFoundError(spaceId, input) + } + return entry, nil +} + +// slugHolder names the existing holder of a proposed api key — the material +// for the loud refusal the union collision check owes the caller. +type slugHolder struct { + Kind string // "bundled property", "property", "bundled type", "type" + Key string // the holder's public key (bundled slug or stored key/slug) + Name string +} + +// propertySlugConflict runs the §7.5a-6 union collision check for a +// property mint by asking the resolution chain itself: a slug is free iff +// NOTHING resolves for it — live stored keys, live slugs, bundled keys, +// bundled-derived slugs AND the fold layer (a `moodlevel` minted beside +// `mood_level` would make the folded spelling permanently ambiguous for +// every caller, so an occupied fold class refuses too). Corpses and hidden +// holders vacate the namespace (§8-OQ2 / propertyEntry.Hidden). The check +// ships WITH the mint it guards (§7.6-3). +func (s *Service) propertySlugConflict(slug string, entries []propertyEntry) (slugHolder, bool) { + entry, ok, ambiguous := s.resolvePropertyInput(slug, entries) + if len(ambiguous) > 0 { + return slugHolder{Kind: "properties", Key: slug, Name: strings.Join(ambiguous, " and ")}, true + } + if !ok { + return slugHolder{}, false + } + if entry.Id == "" { + return slugHolder{Kind: "bundled property", Key: bundle.ApiSlug(entry.Key), Name: entry.Name}, true + } + return slugHolder{Kind: "property", Key: entry.Key, Name: entry.Name}, true +} + +// typeSlugConflict is propertySlugConflict for the type namespace. +func (s *Service) typeSlugConflict(slug string, entries []typeEntry) (slugHolder, bool) { + entry, ok, ambiguous := s.resolveTypeInput(slug, entries) + if len(ambiguous) > 0 { + return slugHolder{Kind: "types", Key: slug, Name: strings.Join(ambiguous, " and ")}, true + } + if !ok { + return slugHolder{}, false + } + if entry.Id == "" { + return slugHolder{Kind: "bundled type", Key: bundle.ApiSlug(entry.Key), Name: entry.Name}, true + } + return slugHolder{Kind: "type", Key: entry.Key, Name: entry.Name}, true +} + +// There is exactly ONE authority for the wire spelling of a key: servedKeyOf +// below. The rival propertyEntry.publicKey/typeEntry.publicKey pair used to +// live here — "the stored slug when the stored key is a BSON, the stored key +// otherwise" — with NONE of servedKeyOf's three round-trip guards, and dead +// repo-wide. Methods never trip an unused-symbol check, so it would have sat +// here until the next listing picked it up and re-opened the class of defect +// this file exists to close. Deleted deliberately: if a listing needs a wire +// spelling, it calls servedKey/servedTypeKeyOf. Its only helper, +// isBsonLikeKey, went with it — the BSON-or-not distinction was the rival +// rule's whole basis, and servedKeyOf does not make it. + +// propertyDefinition adapts an entry to the anyblockjson definition shape. +func (e propertyEntry) propertyDefinition() anyblockjson.PropertyDefinition { + return anyblockjson.PropertyDefinition{ + Key: domain.RelationKey(e.Key), + Name: e.Name, + Format: e.Format, + } +} + +// sanitizeApiSlug constrains a DERIVED slug (from a display name or a +// document key — inputs no pattern ever checked) to the advertised key +// grammar ^[a-zA-Z0-9_]+$ and maxV2KeyLength: every disallowed rune +// becomes `_`, runs collapse, edges trim. Without this, "50% done", "C++" +// or "☕" (unidecode: "?") became identity-bearing apiObjectKey values the +// create returned as keys that no /properties/{key} route could accept. +// Empty result = no derivable slug; the caller falls back to the minted +// BSON as the only address. The transform itself lives in pkg/lib/bundle +// beside ApiSlug — the slug grammar is one thing, and the heart-side mint +// and the apiObjectKey backfill apply the same one. +func sanitizeApiSlug(raw string) string { + return bundle.SanitizeApiSlug(raw, maxV2KeyLength) +} + +// servedKeySets primes the two maps the served-spelling rule needs from one +// live set: every live stored key, and the stored keys HOLDING each slug +// (hidden holders don't participate in the slug namespace — a hidden twin +// must not downgrade the visible row's spelling). Holders, not a count: +// a bundled key's slug is DERIVED, so the round-trip test is "does anyone +// ELSE answer to this spelling", which a count of zero cannot express. +func servedPropertyKeySets(entries []propertyEntry) (keys map[string]bool, slugHolders map[string][]string) { + keys = make(map[string]bool, len(entries)) + slugHolders = map[string][]string{} + for _, entry := range entries { + keys[entry.Key] = true + if !entry.Hidden && entry.Slug != "" { + slugHolders[entry.Slug] = append(slugHolders[entry.Slug], entry.Key) + } + } + return keys, slugHolders +} + +func servedTypeKeySets(entries []typeEntry) (keys map[string]bool, slugHolders map[string][]string) { + keys = make(map[string]bool, len(entries)) + slugHolders = map[string][]string{} + for _, entry := range entries { + keys[entry.Key] = true + if !entry.Hidden && entry.Slug != "" { + slugHolders[entry.Slug] = append(slugHolders[entry.Slug], entry.Key) + } + } + return keys, slugHolders +} + +// servedKey is the wire spelling of one live entry's key under the §7.5a +// surface rule — **the slug, always**, from whichever authority owns it: +// +// - a BUNDLED key spells as its DERIVED slug (`dueDate` → `due_date`), +// because the table in code is that key's authority in every space and +// offline (§7.5a-1); no stored detail is consulted or needed; +// - every other key spells as its STORED slug (apiObjectKey), when it has +// one. Pre-backfill entities have none and keep the stored key — the +// honest degradation, not a second vocabulary. +// +// The round-trip guard is unchanged in spirit and sharper in fact: an +// address the API serves MUST resolve back to the row it labels. All THREE +// ways it can fail are checked, one per chain step — a live stored key wins +// the spelling at step 1; any OTHER live holder makes it ambiguous at step 2; +// and the bundled table resolving it to a different key is the §7.5a-6 shadow +// at step 3, which resolvePropertyInput refuses as ambiguous. The third was +// missing: with the bundled relation NOT installed, nothing in the space +// revealed the clash, so a listing advertised `due_date` for a squatter and +// the very next request to /properties/due_date 400'd. +func servedKey(storedKey, slug string, keyTaken map[string]bool, slugHolders map[string][]string) string { + return servedKeyOf(storedKey, slug, keyTaken, slugHolders, + bundle.HasRelation(domain.RelationKey(storedKey)), shadowedBundledProperty) +} + +// servedTypeKeyOf is servedKey for the type namespace (its bundled tests are +// the type tables, not the relation ones). +func servedTypeKeyOf(storedKey, slug string, keyTaken map[string]bool, slugHolders map[string][]string) string { + return servedKeyOf(storedKey, slug, keyTaken, slugHolders, + bundle.HasObjectTypeByKey(domain.TypeKey(storedKey)), shadowedBundledType) +} + +func servedKeyOf(storedKey, slug string, keyTaken map[string]bool, slugHolders map[string][]string, bundled bool, shadowed func(input, matchedKey string) (string, bool)) string { + candidate := slug + if bundled { + candidate = bundle.ApiSlug(storedKey) + } + if candidate == "" || candidate == storedKey { + return storedKey + } + if keyTaken[candidate] { + return storedKey // a live stored key wins the spelling at chain step 1 + } + for _, holder := range slugHolders[candidate] { + if holder != storedKey { + return storedKey // someone else answers to it — ambiguous, so honest + } + } + if _, isShadow := shadowed(candidate, storedKey); isShadow { + return storedKey // the bundled table answers to it — the input side refuses it + } + return candidate +} + +// corpseFlagged reports whether an index row carries any lifecycle-exit +// flag: isUninstalled (UI delete), isArchived (v2 DELETE — §8.41 made it +// refuse writes like an uninstall) or isDeleted (the re-derived local flag a +// prod corpse always carries). +func corpseFlagged(details *domain.Details) bool { + return details.GetBool(bundle.RelationKeyIsUninstalled) || + details.GetBool(bundle.RelationKeyIsArchived) || + details.GetBool(bundle.RelationKeyIsDeleted) +} + +// relationObjectHoldingKey returns the ID of the relation object holding the +// stored key — live, corpse or tombstoned — and is the one probe that +// deliberately sees EVERY store shape a relation row can have. +// +// A corpse has THREE store shapes, not two (§8.41). A UI delete sets +// isUninstalled, the same Apply stamps isDeleted (smartblock's +// detailsinject, since GO-1978), and BeforeDelete then TOMBSTONES the index +// row down to {id, spaceId, isDeleted} — no relationKey, no resolvedLayout — +// until the next space load re-indexes the surviving tree with full details +// and both flags. So: +// +// - the query below (both injected defaults suppressed via the no-op +// Condition None clauses) sees the full-detail shapes, flag-only and +// two-flag alike; +// - the tombstone, which no key-filtered query can return, is found by its +// ID instead: a derived object's id is a pure function of (space, kind, +// internal key) — ADDRESSING §2.4, verified for every relation creation +// path — so RelationIdByKey computes where the row MUST be and a point +// lookup answers whether it is there. The tree the tombstone stands for +// still exists; the row's absence of fields is a window, not a fact. +// +// When several rows hold one key (a live relation beside a corpse), the LIVE +// row wins — callers reach this probe after the live resolution chain +// misses, but that ordering is a convention of today's call sites, not a +// contract this probe may lean on. Ties break by id for determinism. +// +// The error return is load-bearing: PropertyId's mint decision consults this +// probe, and a probe that swallowed a store or derivation error would turn +// "could not look" into "not held" and mint a duplicate of a property that +// exists (§7.5a-2's fail-closed rule). +func (s *Service) relationObjectHoldingKey(ctx context.Context, spaceId, key string) (string, bool, error) { + records, err := s.store.SpaceIndex(spaceId).Query(database.Query{ + Filters: []database.FilterRequest{ + { + RelationKey: bundle.RelationKeyRelationKey, + Condition: model.BlockContentDataviewFilter_Equal, + Value: domain.String(key), + }, + { + RelationKey: bundle.RelationKeyResolvedLayout, + Condition: model.BlockContentDataviewFilter_Equal, + Value: domain.Int64(int64(model.ObjectType_relation)), + }, + { + RelationKey: bundle.RelationKeyIsArchived, + Condition: model.BlockContentDataviewFilter_None, + }, + { + RelationKey: bundle.RelationKeyIsDeleted, + Condition: model.BlockContentDataviewFilter_None, + }, + }, + }) + if err != nil { + return "", false, fmt.Errorf("query relation objects holding %q in space %s: %w", key, spaceId, err) + } + best := "" + bestLive := false + for _, record := range records { + id := record.Details.GetString(bundle.RelationKeyId) + if id == "" { + continue + } + live := !corpseFlagged(record.Details) + switch { + case best == "", + live && !bestLive, + live == bestLive && id < best: + best, bestLive = id, live + } + } + if best != "" { + return best, true, nil + } + // tombstone window: the row may exist with nothing but {id, isDeleted} + details, id, err := s.derivedRelationRow(ctx, spaceId, key) + if err != nil { + return "", false, err + } + if details != nil && details.GetString(bundle.RelationKeyId) != "" { + return id, true, nil + } + return "", false, nil +} + +// derivedRelationRow computes the id the relation object for `key` MUST have +// in this space (derived identity, ADDRESSING §2.4) and point-looks-up its +// index row, bypassing every injected default. A nil details return means +// "no row at all" — the relation was never installed here. Requires the +// creator port (id derivation runs in the space); a read-only service has +// none and reports no row, which the write paths that consult this never +// reach. +func (s *Service) derivedRelationRow(ctx context.Context, spaceId, key string) (*domain.Details, string, error) { + if s.creator == nil { + return nil, "", nil + } + id, err := s.creator.RelationIdByKey(ctx, spaceId, domain.RelationKey(key)) + if err != nil { + return nil, "", fmt.Errorf("derive relation id for %q in space %s: %w", key, spaceId, err) + } + details, err := s.store.SpaceIndex(spaceId).GetDetails(id) + if err != nil { + return nil, "", fmt.Errorf("read relation row %s in space %s: %w", id, spaceId, err) + } + return details, id, nil +} + +// propertyKeyHeldByAnyRelation reports whether ANY relation object holds the +// stored key — live, corpse or tombstoned. This is the create path's +// round-trip tolerance (§8.29); it is never an ADDRESS — nothing resolves a +// corpse key to a property object, and no listing advertises it. +// +// The tolerance is DELIBERATELY a bare existence probe. It cannot tell a +// pasted read body (the clone loop it was written for) from a fresh value +// authored onto the corpse key of a brand-new object: the API has no +// provenance signal, and the §8.41 review settled that none is worth +// building — a custom corpse's stored key is a BSON id that resolves +// nowhere, so the worst a fresh value can do is join the dormant freight the +// clone loop already carries. Rows with only isDeleted set (no +// isUninstalled/isArchived) pass too, and that is intended: whatever exotic +// path exited the relation, a document value under its stored key is the +// same inert freight. On a probe error the key reads as not held and create +// refuses — fail closed, never a silent mint of presence. +func (s *Service) propertyKeyHeldByAnyRelation(ctx context.Context, spaceId, key string) bool { + _, held, err := s.relationObjectHoldingKey(ctx, spaceId, key) + return err == nil && held +} + +// bundledRemovalSet is the set of BUNDLED relation keys this space has +// REMOVED: a relation object exists and carries isUninstalled (UI delete) or +// isArchived (v2 DELETE — §8.41: the API's own delete verb must not leave a +// property that 404s on its route yet accepts writes). One bounded query per +// request (§7.5a-2), never one per reference; both injected defaults are +// suppressed so every full-detail corpse shape is seen. +// +// The set is deliberately narrow. A bundled relation that was NEVER +// installed has no object at all and is absent here — install-on-write stays +// correct, and is the common case in a fresh space. Only removals land in +// this set, which is the whole distinction the refusal rests on: "not +// installed yet" and "you deleted it" look identical through +// bundle.HasRelation and could not be told apart without this probe. The +// TOMBSTONE shape is invisible to this query too (no relationKey field) — +// per-key consultation goes through bundledPropertyRemoved, which adds the +// derived-id probe for that window. +func (s *Service) bundledRemovalSet(spaceId string) (map[string]bool, error) { + records, err := s.store.SpaceIndex(spaceId).Query(database.Query{ + Filters: []database.FilterRequest{ + { + RelationKey: bundle.RelationKeyResolvedLayout, + Condition: model.BlockContentDataviewFilter_Equal, + Value: domain.Int64(int64(model.ObjectType_relation)), + }, + {RelationKey: bundle.RelationKeyIsArchived, Condition: model.BlockContentDataviewFilter_None}, + {RelationKey: bundle.RelationKeyIsDeleted, Condition: model.BlockContentDataviewFilter_None}, + }, + }) + if err != nil { + return nil, fmt.Errorf("query removed relations of space %s: %w", spaceId, err) + } + removed := map[string]bool{} + for _, record := range records { + if !corpseFlagged(record.Details) { + continue + } + key := record.Details.GetString(bundle.RelationKeyRelationKey) + // custom corpses are NOT in this set: their stored key is a BSON id + // no bundled table knows, they can never be reinstalled, and the + // §8.29 tolerance carries their in-document values instead + if key != "" && bundle.HasRelation(domain.RelationKey(key)) { + removed[key] = true + } + } + return removed, nil +} + +// bundledPropertyRemoved is the per-key removal verdict for a BUNDLED +// property key: the space explicitly removed it (bundledRemovalSet), or its +// relation object sits in the post-delete tombstone window — a row at the +// derived id carrying isDeleted and no relationKey, which no query-built set +// can contain. A live installed entry always outvotes; a missing row means +// never-installed and keeps install-on-write working. +func (s *Service) bundledPropertyRemoved(ctx context.Context, spaceId string, entries []propertyEntry, removed map[string]bool, key string) (bool, error) { + if propertyKeyRemovedIn(entries, removed, key) { + return true, nil + } + if !bundle.HasRelation(domain.RelationKey(key)) || propertyKeyInstalledIn(entries, key) { + return false, nil + } + details, _, err := s.derivedRelationRow(ctx, spaceId, key) + if err != nil { + return false, err + } + if details == nil || details.GetString(bundle.RelationKeyId) == "" { + return false, nil // no row: never installed + } + if _, hasKey := details.TryString(bundle.RelationKeyRelationKey); hasKey { + return false, nil // a full-detail row belongs to the query-built sets + } + return details.GetBool(bundle.RelationKeyIsDeleted), nil +} + +// bundledTypeRemovalSet is bundledRemovalSet for the TYPE namespace: bundled +// type keys whose type object exists and carries a removal flag. Same +// query discipline, same never-installed boundary, same tombstone blind spot +// (bundledTypeRemoved owns that window). +func (s *Service) bundledTypeRemovalSet(spaceId string) (map[string]bool, error) { + records, err := s.store.SpaceIndex(spaceId).Query(database.Query{ + Filters: []database.FilterRequest{ + { + RelationKey: bundle.RelationKeyResolvedLayout, + Condition: model.BlockContentDataviewFilter_Equal, + Value: domain.Int64(int64(model.ObjectType_objectType)), + }, + {RelationKey: bundle.RelationKeyIsArchived, Condition: model.BlockContentDataviewFilter_None}, + {RelationKey: bundle.RelationKeyIsDeleted, Condition: model.BlockContentDataviewFilter_None}, + }, + }) + if err != nil { + return nil, fmt.Errorf("query removed types of space %s: %w", spaceId, err) + } + removed := map[string]bool{} + for _, record := range records { + if !corpseFlagged(record.Details) { + continue + } + key, err := domain.GetTypeKeyFromRawUniqueKey(record.Details.GetString(bundle.RelationKeyUniqueKey)) + if err != nil { + continue + } + if bundle.HasObjectTypeByKey(key) { + removed[string(key)] = true + } + } + return removed, nil +} + +// typeKeyInstalledIn is propertyKeyInstalledIn for the type namespace. +func typeKeyInstalledIn(entries []typeEntry, key string) bool { + for _, entry := range entries { + if entry.Key == key { + return true + } + } + return false +} + +// bundledTypeRemoved is bundledPropertyRemoved for the type namespace. +func (s *Service) bundledTypeRemoved(ctx context.Context, spaceId string, entries []typeEntry, removed map[string]bool, key string) (bool, error) { + if removed[key] && !typeKeyInstalledIn(entries, key) { + return true, nil + } + if !bundle.HasObjectTypeByKey(domain.TypeKey(key)) || typeKeyInstalledIn(entries, key) { + return false, nil + } + if s.creator == nil { + return false, nil + } + id, err := s.creator.TypeIdByKey(ctx, spaceId, domain.TypeKey(key)) + if err != nil { + return false, fmt.Errorf("derive type id for %q in space %s: %w", key, spaceId, err) + } + details, err := s.store.SpaceIndex(spaceId).GetDetails(id) + if err != nil { + return false, fmt.Errorf("read type row %s in space %s: %w", id, spaceId, err) + } + if details.GetString(bundle.RelationKeyId) == "" { + return false, nil // no row: never installed + } + if _, hasKey := details.TryString(bundle.RelationKeyUniqueKey); hasKey { + return false, nil // a full-detail row belongs to the query-built set + } + return details.GetBool(bundle.RelationKeyIsDeleted), nil +} + +// canonicalizeDocumentKeys rewrites an inbound document's addressing terms +// to their canonical stored spellings BEFORE validation and import: the +// envelope's type/templateFor (slug → internal type key — the import path +// derives `ot-` URLs from them) and the properties-map keys (slug → +// stored relation key — they become detail keys verbatim). Terms already +// canonical, or resolving to nothing (the R9 validation owns that refusal), +// pass through verbatim so errors keep the caller's spelling. Ambiguity is +// a path-addressed 400; two spellings canonicalizing onto one key is too. +// +// The second return maps every REWRITTEN property key back to the spelling +// the caller sent (canonical → original), so validation that runs after the +// rewrite can address its refusals to the request as sent (§8.41-10). +func (s *Service) canonicalizeDocumentKeys(spaceId string, body []byte) ([]byte, map[string]string, error) { + spellings := map[string]string{} + fields, err := parseEnvelope(body) + if err != nil { + return body, spellings, nil // not an object — the document validator owns this + } + changed := false + + var typeEntries []typeEntry + for _, field := range []string{"type", "template_for"} { + raw, ok := fields[field] + if !ok { + continue + } + var term string + if err := json.Unmarshal(raw, &term); err != nil || term == "" { + continue + } + if typeEntries == nil { + if typeEntries, err = s.liveTypes(spaceId); err != nil { + return nil, nil, err + } + } + entry, ok, ambiguous := s.resolveTypeInput(term, typeEntries) + if len(ambiguous) > 0 { + return nil, nil, ambiguousKeyError("type key", term, "/"+field, ambiguous) + } + if ok && entry.Key != term { + if fields[field], err = rawJSON(entry.Key); err != nil { + return nil, nil, err + } + changed = true + } + } + + if raw, ok := fields["properties"]; ok { + var props map[string]json.RawMessage + if err := json.Unmarshal(raw, &props); err == nil && len(props) > 0 { + propEntries, err := s.liveProperties(spaceId) + if err != nil { + return nil, nil, err + } + renames := map[string]string{} + for _, key := range sortedKeys(props) { + entry, ok, ambiguous := s.resolvePropertyInput(key, propEntries) + if len(ambiguous) > 0 { + return nil, nil, ambiguousKeyError("property key", key, "/properties/"+key, ambiguous) + } + if ok && entry.Key != key { + renames[key] = entry.Key + } + } + if len(renames) > 0 { + rewritten := make(map[string]json.RawMessage, len(props)) + // deterministic order, so a duplicate-spelling refusal + // names the same path on every run + for _, key := range sortedKeys(props) { + canonical := key + if to, ok := renames[key]; ok { + canonical = to + spellings[canonical] = key + } + if _, dup := rewritten[canonical]; dup { + return nil, nil, v2model.ValidationFailed("duplicate property key", + v2model.Issue{Path: "/properties/" + key, + Message: fmt.Sprintf("%q and another spelling both address property %q — keep one", key, canonical)}) + } + rewritten[canonical] = props[key] + } + if fields["properties"], err = rawJSON(rewritten); err != nil { + return nil, nil, err + } + changed = true + } + } + } + + if !changed { + return body, spellings, nil + } + body, err = encodeEnvelope(fields) + return body, spellings, err +} + +// sortedDistinct returns the sorted distinct non-empty values. +func sortedDistinct(values []string) []string { + seen := make(map[string]bool, len(values)) + out := make([]string, 0, len(values)) + for _, v := range values { + if v != "" && !seen[v] { + seen[v] = true + out = append(out, v) + } + } + sort.Strings(out) + return out +} diff --git a/core/api/v2/service/keys_input_test.go b/core/api/v2/service/keys_input_test.go new file mode 100644 index 0000000000..2758fa92ff --- /dev/null +++ b/core/api/v2/service/keys_input_test.go @@ -0,0 +1,262 @@ +package v2service + +import ( + "context" + "fmt" + "net/http" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + + v2model "github.com/anyproto/anytype-heart/core/api/v2/model" + "github.com/anyproto/anytype-heart/core/domain" + "github.com/anyproto/anytype-heart/pb" + "github.com/anyproto/anytype-heart/pkg/lib/bundle" + "github.com/anyproto/anytype-heart/pkg/lib/localstore/objectstore" + "github.com/anyproto/anytype-heart/pkg/lib/pb/model" + "github.com/anyproto/anytype-heart/util/pbtypes" +) + +// The §7.5a-5 input chain: after the (a) mint rework a v2-created property +// or type has a BSON stored key and its slug is the only readable address — +// every place the API takes a key must resolve the slug (exact stored key +// first, live slug second, bundled table third, fold fourth; ambiguity is a +// loud 400, never a guess). + +const ( + slugPropKey = "6a7663db61fab21cd4b9e101" // stored key of the slug-addressed text property + slugSelectKey = "6a7663db61fab21cd4b9e102" // stored key of the slug-addressed select property + slugTypeKey = "6a7663db61fab21cd4b9e103" // internal key of the slug-addressed type +) + +func slugSpaceFixture(t *testing.T) *v2Fixture { + fx := newV2Fixture(t) + fx.addRelation(t, testSpaceId, objectstore.TestObject{ + bundle.RelationKeyId: domain.String("rel-manual"), + bundle.RelationKeyRelationKey: domain.String(slugPropKey), + bundle.RelationKeyApiObjectKey: domain.String("manual_property"), + bundle.RelationKeyName: domain.String("Manual property"), + }) + fx.addRelation(t, testSpaceId, objectstore.TestObject{ + bundle.RelationKeyId: domain.String("rel-mood"), + bundle.RelationKeyRelationKey: domain.String(slugSelectKey), + bundle.RelationKeyApiObjectKey: domain.String("mood_level"), + bundle.RelationKeyName: domain.String("Mood level"), + bundle.RelationKeyRelationFormat: domain.Int64(int64(model.RelationFormat_status)), + }) + fx.addType(t, testSpaceId, objectstore.TestObject{ + bundle.RelationKeyId: domain.String("type-meeting"), + bundle.RelationKeyUniqueKey: domain.String("ot-" + slugTypeKey), + bundle.RelationKeyApiObjectKey: domain.String("meeting_note"), + bundle.RelationKeyName: domain.String("Meeting note"), + }) + return fx +} + +func TestV2SlugAddressedRoutes(t *testing.T) { + t.Run("PATCH properties by slug lands on the BSON-keyed relation", func(t *testing.T) { + // given + fx := slugSpaceFixture(t) + fx.mwMock.EXPECT().ObjectSetDetails(mock.Anything, mock.MatchedBy(func(req *pb.RpcObjectSetDetailsRequest) bool { + return req.ContextId == "rel-manual" + })).Return(&pb.RpcObjectSetDetailsResponse{Error: &pb.RpcObjectSetDetailsResponseError{Code: pb.RpcObjectSetDetailsResponseError_NULL}}) + name := "Renamed" + + // when + result, err := fx.UpdateProperty(context.Background(), testSpaceId, "manual_property", + v2model.UpdatePropertyRequest{Name: &name}, false) + + // then + require.NoError(t, err) + assert.Equal(t, "rel-manual", result.Id) + }) + + t.Run("options listing by slug lists the stored-key-bound options", func(t *testing.T) { + // given: options bind to the STORED key, so the slug must resolve to + // it before the store lookup + fx := slugSpaceFixture(t) + fx.objectStore.AddObjects(t, testSpaceId, []objectstore.TestObject{{ + bundle.RelationKeyId: domain.String("opt-happy"), + bundle.RelationKeyRelationKey: domain.String(slugSelectKey), + bundle.RelationKeyName: domain.String("Happy"), + bundle.RelationKeyResolvedLayout: domain.Int64(int64(model.ObjectType_relationOption)), + }}) + + // when + rows, _, _, err := fx.ListPropertyOptions(context.Background(), testSpaceId, "mood_level", "", 0, 25) + + // then + require.NoError(t, err) + require.Len(t, rows, 1) + assert.Equal(t, "Happy", rows[0].Name) + }) + + t.Run("DELETE types by slug archives the BSON-keyed type", func(t *testing.T) { + fx := slugSpaceFixture(t) + fx.mwMock.EXPECT().ObjectSetIsArchived(mock.Anything, &pb.RpcObjectSetIsArchivedRequest{ + ContextId: "type-meeting", IsArchived: true, + }).Return(&pb.RpcObjectSetIsArchivedResponse{Error: &pb.RpcObjectSetIsArchivedResponseError{Code: pb.RpcObjectSetIsArchivedResponseError_NULL}}) + + result, err := fx.DeleteType(context.Background(), testSpaceId, "meeting_note", false) + + require.NoError(t, err) + assert.Equal(t, "type-meeting", result.Id) + }) + + t.Run("an ambiguous slug is a loud 400 listing both holders", func(t *testing.T) { + // twin slugs — the (a) strategy's accepted concurrency artifact — + // must never resolve by store order (the D2 lesson at the key layer) + fx := slugSpaceFixture(t) + fx.addRelation(t, testSpaceId, objectstore.TestObject{ + bundle.RelationKeyId: domain.String("rel-manual-twin"), + bundle.RelationKeyRelationKey: domain.String("6a7663db61fab21cd4b9e104"), + bundle.RelationKeyApiObjectKey: domain.String("manual_property"), + bundle.RelationKeyName: domain.String("Manual property twin"), + }) + name := "X" + + _, err := fx.UpdateProperty(context.Background(), testSpaceId, "manual_property", + v2model.UpdatePropertyRequest{Name: &name}, false) + + var apiErr *v2model.Error + require.ErrorAs(t, err, &apiErr) + assert.Equal(t, http.StatusBadRequest, apiErr.Status) + assert.Equal(t, v2model.CodeAmbiguousInput, apiErr.Code) + require.NotEmpty(t, apiErr.Issues) + assert.Contains(t, apiErr.Issues[0].Message, "Manual property twin") + }) +} + +func TestV2SlugAddressedDocuments(t *testing.T) { + t.Run("create canonicalizes slug property keys and the type slug", func(t *testing.T) { + // given: a document naming the slug spellings — the snapshot must + // carry the stored spellings (detail keys and the ot- URL are the + // store's vocabulary, not the wire's) + fx := slugSpaceFixture(t) + captured := fx.expectCreate("obj-new") + fx.expectEtagRead("obj-new") + + // when + result, err := fx.CreateObject(context.Background(), testSpaceId, []byte(`{ + "version":1,"type":"meeting_note", + "properties":{"name":"Standup","manual_property":"hello"}}`), false, true) + + // then + require.NoError(t, err) + require.NotNil(t, *captured) + snapshot := *captured + assert.Equal(t, []string{"ot-" + slugTypeKey}, snapshot.ObjectTypes, + "the type slug canonicalizes to the internal key before the ot- URL is derived") + assert.Equal(t, "hello", pbtypes.GetString(snapshot.Details, slugPropKey), + "the property slug canonicalizes to the stored key — details bind by stored key") + assert.Empty(t, pbtypes.GetString(snapshot.Details, "manual_property"), + "the slug spelling must not land as a detail key") + _ = result + }) + + t.Run("two spellings of one property are a loud 400", func(t *testing.T) { + fx := slugSpaceFixture(t) + + _, err := fx.CreateObject(context.Background(), testSpaceId, []byte(`{ + "version":1,"type":"meeting_note", + "properties":{"manual_property":"a","`+slugPropKey+`":"b"}}`), false, true) + + var apiErr *v2model.Error + require.ErrorAs(t, err, &apiErr) + assert.Equal(t, http.StatusBadRequest, apiErr.Status) + assert.Contains(t, apiErr.Message, "duplicate property key") + }) +} + +func TestV2SlugAddressedOps(t *testing.T) { + ctx := context.Background() + + t.Run("set_properties by slug writes the stored key", func(t *testing.T) { + // given + fx := slugSpaceFixture(t) + captured := fx.expectMutate(editRead(t, editBaseDoc), "headB") + + // when + _, err := fx.PatchObject(ctx, testSpaceId, "obj1", + patchBody(`{"op":"set_properties","set":{"manual_property":"from-slug"}}`), "", false, true) + + // then + require.NoError(t, err) + st := *captured + assert.Equal(t, "from-slug", st.CombinedDetails().GetString(domain.RelationKey(slugPropKey)), + "the detail lands under the stored BSON key") + assert.False(t, st.CombinedDetails().Has("manual_property"), + "the slug spelling must not become a detail key") + }) + + t.Run("create-missing options bind to the stored key, not the slug", func(t *testing.T) { + // given: a select property addressed by slug with a new option name — + // the prewarm must canonicalize BEFORE the create RPC, or the option + // is minted bound to the slug string and orphaned forever + fx := slugSpaceFixture(t) + fx.expectMutate(editRead(t, editBaseDoc), "headB") + fx.mwMock.EXPECT().ObjectCreateRelationOption(mock.Anything, mock.MatchedBy(func(req *pb.RpcObjectCreateRelationOptionRequest) bool { + return pbtypes.GetString(req.Details, bundle.RelationKeyRelationKey.String()) == slugSelectKey && + pbtypes.GetString(req.Details, bundle.RelationKeyName.String()) == "Brand new" + })).Return(&pb.RpcObjectCreateRelationOptionResponse{ + ObjectId: "opt-new", + Error: &pb.RpcObjectCreateRelationOptionResponseError{Code: pb.RpcObjectCreateRelationOptionResponseError_NULL}, + }) + + // when + _, err := fx.PatchObject(ctx, testSpaceId, "obj1", + patchBody(`{"op":"set_properties","set":{"mood_level":["Brand new"]}}`), "", false, true) + + // then + require.NoError(t, err) + }) + + t.Run("the M5 bound sees folded spellings (the reviewed bypass repro)", func(t *testing.T) { + // the review's repro: a PATCH naming 70 new options under a FOLDED + // key spelling. Pre-fix, the prewarm (no fold) recorded zero + // pending, guardCreateMissing short-circuited on len(pending)==0, + // and all 70 were created inside the object lock — cap 64 lost. + fx := slugSpaceFixture(t) + fx.readerMock.EXPECT().ReadObject(mock.Anything, testSpaceId, "obj1"). + Return(editRead(t, editBaseDoc), nil) + // no ObjectCreateRelationOption and no mutator expectation: creating + // anything, anywhere, fails the test — the bound must refuse first + + names := make([]string, 0, v2MaxCreatedOptionsPerPatch+6) + for i := 0; i < v2MaxCreatedOptionsPerPatch+6; i++ { + names = append(names, fmt.Sprintf(`"Opt %03d"`, i)) + } + _, err := fx.PatchObject(ctx, testSpaceId, "obj1", + patchBody(`{"op":"set_properties","set":{"moodLevel":[`+strings.Join(names, ",")+`]}}`), "", false, true) + + apiErr := v2Err(t, err) + assert.Equal(t, http.StatusBadRequest, apiErr.Status) + require.NotEmpty(t, apiErr.Issues) + assert.Contains(t, apiErr.Issues[0].Message, "limit 64") + }) + + t.Run("a FOLDED slug spelling reaches the prewarm too (the M5 bypass)", func(t *testing.T) { + // the reviewed regression: the prewarm lacked the fold the in-lock + // pass had, so "moodLevel" was invisible pre-lock — the M5 bound + // short-circuited on len(pending)==0 and the creates ran INSIDE the + // object lock. The prewarm now walks the same chain: the option is + // created pre-lock, bound to the stored key. + fx := slugSpaceFixture(t) + fx.expectMutate(editRead(t, editBaseDoc), "headB") + fx.mwMock.EXPECT().ObjectCreateRelationOption(mock.Anything, mock.MatchedBy(func(req *pb.RpcObjectCreateRelationOptionRequest) bool { + return pbtypes.GetString(req.Details, bundle.RelationKeyRelationKey.String()) == slugSelectKey && + pbtypes.GetString(req.Details, bundle.RelationKeyName.String()) == "Folded new" + })).Return(&pb.RpcObjectCreateRelationOptionResponse{ + ObjectId: "opt-folded", + Error: &pb.RpcObjectCreateRelationOptionResponseError{Code: pb.RpcObjectCreateRelationOptionResponseError_NULL}, + }) + + _, err := fx.PatchObject(ctx, testSpaceId, "obj1", + patchBody(`{"op":"set_properties","set":{"moodLevel":["Folded new"]}}`), "", false, true) + + require.NoError(t, err) + }) +} diff --git a/core/api/v2/service/keys_output_test.go b/core/api/v2/service/keys_output_test.go new file mode 100644 index 0000000000..a2120ec043 --- /dev/null +++ b/core/api/v2/service/keys_output_test.go @@ -0,0 +1,382 @@ +package v2service + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/anyproto/anytype-heart/core/domain" + "github.com/anyproto/anytype-heart/pkg/lib/bundle" + "github.com/anyproto/anytype-heart/pkg/lib/database" + "github.com/anyproto/anytype-heart/pkg/lib/localstore/objectstore" +) + +// The served spelling (§7.5a): the slug is the ONLY key vocabulary the API +// serves — a bundled key spells its DERIVED slug (the table in code is its +// authority in every space and offline, stored detail or not), everything +// else its stored `apiObjectKey`. A pre-slug entity has none and keeps its +// stored key: the honest degradation, not a second vocabulary. +// +// One guard, three ways to fail it: an address the API serves must resolve +// back to the row it labels, so a spelling that a live stored key wins at +// chain step 1, that another live holder answers to at step 2, or that the +// bundled table resolves elsewhere at step 3 (the §7.5a-6 shadow) is refused +// and the honest stored key is served instead. servedKeyOf is that predicate; +// storeresolver's keyMaps.roundTrips is the same one on the document side, and +// they must stay the same — the address a listing advertises and the address a +// document carries are the same address. + +func TestV2ListingsServeSlugs(t *testing.T) { + t.Run("a BSON-keyed property row advertises its slug", func(t *testing.T) { + // given + fx := slugSpaceFixture(t) + + // when + rows, _, _, err := fx.ListProperties(context.Background(), testSpaceId, 0, 25) + + // then + require.NoError(t, err) + keys := map[string]bool{} + for _, row := range rows { + keys[row.Key] = true + } + assert.True(t, keys["manual_property"], "the slug is the served address") + assert.False(t, keys[slugPropKey], "the BSON spelling must not appear once the slug serves") + }) + + t.Run("twin slugs fall back to the honest BSON spelling", func(t *testing.T) { + // given: two live holders of one slug — serving it on either row + // would advertise an address that resolves to a 400 + fx := slugSpaceFixture(t) + fx.addRelation(t, testSpaceId, objectstore.TestObject{ + bundle.RelationKeyId: domain.String("rel-manual-twin"), + bundle.RelationKeyRelationKey: domain.String("6a7663db61fab21cd4b9e105"), + bundle.RelationKeyApiObjectKey: domain.String("manual_property"), + bundle.RelationKeyName: domain.String("Manual property twin"), + }) + + // when + rows, _, _, err := fx.ListProperties(context.Background(), testSpaceId, 0, 25) + + // then + require.NoError(t, err) + keys := map[string]bool{} + for _, row := range rows { + keys[row.Key] = true + } + assert.False(t, keys["manual_property"]) + assert.True(t, keys[slugPropKey]) + assert.True(t, keys["6a7663db61fab21cd4b9e105"]) + }) + + t.Run("a slug shadowed by a live stored key is not served", func(t *testing.T) { + // given: a legacy relation whose STORED key equals another's slug — + // chain step 1 wins on input, so serving the slug would label the + // wrong row + fx := slugSpaceFixture(t) + fx.addRelation(t, testSpaceId, objectstore.TestObject{ + bundle.RelationKeyId: domain.String("rel-legacy"), + bundle.RelationKeyRelationKey: domain.String("manual_property"), + bundle.RelationKeyName: domain.String("Legacy readable key"), + }) + + // when + rows, _, _, err := fx.ListProperties(context.Background(), testSpaceId, 0, 25) + + // then: the legacy row keeps its key; the BSON row keeps its BSON + require.NoError(t, err) + byId := map[string]string{} + for _, row := range rows { + byId[row.Name] = row.Key + } + assert.Equal(t, "manual_property", byId["Legacy readable key"]) + assert.Equal(t, slugPropKey, byId["Manual property"]) + }) + + t.Run("a BSON-keyed type row advertises its slug", func(t *testing.T) { + fx := slugSpaceFixture(t) + + rows, _, _, err := fx.ListTypes(context.Background(), testSpaceId, 0, 25) + + require.NoError(t, err) + keys := map[string]bool{} + for _, row := range rows { + keys[row.Key] = true + } + assert.True(t, keys["meeting_note"]) + assert.False(t, keys[slugTypeKey]) + }) + + t.Run("object rows spell a slug-keyed type by its slug", func(t *testing.T) { + // typeKeysById feeds every object/search row's type column — the + // spelling must be the address the search type filter resolves back + fx := slugSpaceFixture(t) + + keys, err := fx.typeKeysById(testSpaceId) + + require.NoError(t, err) + assert.Equal(t, "meeting_note", keys["type-meeting"]) + }) + + t.Run("a corpse type keeps its internal spelling in rows", func(t *testing.T) { + // its slug vacated the namespace (§8-OQ2) — a recreated live type + // may hold it now, and two rows advertising one address would be + // the D2 shape all over. + // + // All three corpse shapes run, THROUGH THE ROW BUILDER — the path + // that serves production. The original flag-only fixture asserted on + // typeKeysById alone, whose plain query the injected isDeleted + // default emptied of every prod corpse: its isUninstalled branch was + // dead code in production and the fixture could not know (§8.41). + // typeKeysById now suppresses both defaults, so the flag-only and + // prod legs serve identically; the tombstone row carries no + // uniqueKey, so its objects' rows serve an EMPTY type for that + // window — the only honest answer, pinned here as such. + // Revert check: dropping the two Condition None clauses from + // typeKeysById fails the prod leg (the row falls back to the per-row + // point lookup — same spelling — but the map assertion sees the + // corpse vanish); dropping the corpseFlagged branch serves the slug + // and fails flag-only and prod both. + corpseShapes(t, func(t *testing.T, shape corpseShape) { + fx := slugSpaceFixture(t) + if shape == corpseTombstone { + fx.addTombstone(t, "type-corpse") + } else { + obj := objectstore.TestObject{ + bundle.RelationKeyId: domain.String("type-corpse"), + bundle.RelationKeyUniqueKey: domain.String("ot-6a7663db61fab21cd4b9e106"), + bundle.RelationKeyApiObjectKey: domain.String("meeting_note"), + bundle.RelationKeyName: domain.String("Old meeting note"), + bundle.RelationKeyIsUninstalled: domain.Bool(true), + } + if shape == corpseProd { + obj[bundle.RelationKeyIsDeleted] = domain.Bool(true) + } + fx.addType(t, testSpaceId, obj) + } + + builder, err := fx.newObjectRowBuilder(testSpaceId, nil) + require.NoError(t, err) + details := domain.NewDetails() + details.SetString(bundle.RelationKeyId, "note1") + details.SetString(bundle.RelationKeyName, "Standup") + details.SetString(bundle.RelationKeyType, "type-corpse") + row := builder.row(database.Record{Details: details}) + + if shape == corpseTombstone { + assert.Equal(t, "", row.Type, "a tombstoned type row spells nothing — the store has nothing to spell") + } else { + assert.Equal(t, "6a7663db61fab21cd4b9e106", row.Type, "a corpse type spells its internal key") + } + + keys, err := fx.typeKeysById(testSpaceId) + require.NoError(t, err) + assert.Equal(t, "meeting_note", keys["type-meeting"], "the live holder keeps the slug") + if shape != corpseTombstone { + // the bulk map itself must hold the corpse (the suppression fix): + // without it the prod row was served by the per-row point-lookup + // fallback — same spelling, so the row assertion above cannot + // tell the paths apart, and the corpse branch was dead code + assert.Equal(t, "6a7663db61fab21cd4b9e106", keys["type-corpse"], + "the corpse is in the bulk map, not just the per-row fallback") + } + }) + }) +} + +// The §7.5a sweep: bundled keys re-spell on the wire too. The authority is +// the derived table in code, not a stored detail — an old space stores no +// apiObjectKey for its installed bundled relations and must still serve the +// slug. + +func TestV2ListingsServeBundledSlugs(t *testing.T) { + t.Run("an installed bundled property with no stored slug still serves its derived slug", func(t *testing.T) { + // given + fx := slugSpaceFixture(t) + fx.addRelation(t, testSpaceId, objectstore.TestObject{ + bundle.RelationKeyId: domain.String("rel-dueDate"), + bundle.RelationKeyRelationKey: domain.String("dueDate"), + bundle.RelationKeyName: domain.String("Due date"), + }) + + // when + rows, _, _, err := fx.ListProperties(context.Background(), testSpaceId, 0, 25) + + // then + require.NoError(t, err) + keys := map[string]bool{} + for _, row := range rows { + keys[row.Key] = true + } + assert.True(t, keys["due_date"], "the derived table is the authority, stored detail or not") + assert.False(t, keys["dueDate"], "the camel stored key is not a wire spelling any more") + }) + + t.Run("a bundled type re-spells in object rows", func(t *testing.T) { + fx := slugSpaceFixture(t) + fx.addType(t, testSpaceId, objectstore.TestObject{ + bundle.RelationKeyId: domain.String("type-objectType"), + bundle.RelationKeyUniqueKey: domain.String("ot-objectType"), + bundle.RelationKeyName: domain.String("Type"), + }) + + keys, err := fx.typeKeysById(testSpaceId) + + require.NoError(t, err) + assert.Equal(t, "object_type", keys["type-objectType"]) + }) + + t.Run("a squatted bundled slug keeps the honest stored key", func(t *testing.T) { + // given — a pre-mint-check space where a custom property took + // due_date. Serving it on the bundled row would advertise an address + // that now 400s (the shadow is ambiguous), so the bundled row keeps + // its stored spelling. + fx := slugSpaceFixture(t) + fx.addRelation(t, testSpaceId, objectstore.TestObject{ + bundle.RelationKeyId: domain.String("rel-dueDate"), + bundle.RelationKeyRelationKey: domain.String("dueDate"), + bundle.RelationKeyName: domain.String("Due date"), + }) + fx.addRelation(t, testSpaceId, objectstore.TestObject{ + bundle.RelationKeyId: domain.String("rel-squatter"), + bundle.RelationKeyRelationKey: domain.String("6a7663db61fab21cd4b9e107"), + bundle.RelationKeyApiObjectKey: domain.String("due_date"), + bundle.RelationKeyName: domain.String("Due Date"), + }) + + rows, _, _, err := fx.ListProperties(context.Background(), testSpaceId, 0, 25) + + require.NoError(t, err) + byName := map[string]string{} + for _, row := range rows { + byName[row.Name] = row.Key + } + assert.Equal(t, "dueDate", byName["Due date"]) + assert.Equal(t, "6a7663db61fab21cd4b9e107", byName["Due Date"], + "the squatter's slug is refused too — the whole spelling is ambiguous, on both rows") + }) +} + +// TestV2ServedKeyRefusesAShadowedSlug is the third guard, alone. The subtest +// above only asserted the BUNDLED row, which the slugHolders guard already +// covered; with the bundled relation NOT INSTALLED there is no bundled row at +// all and nothing in the space reveals the clash — so the listing served +// `due_date` for the squatter and the very next GET /properties/due_date +// answered 400 ambiguous. Revert the shadowed() call in servedKeyOf (keys.go) +// and this fails. +func TestV2ServedKeyRefusesAShadowedSlug(t *testing.T) { + t.Run("a property slug the bundled table resolves elsewhere is not served", func(t *testing.T) { + // given: the bundled dueDate is NOT installed in this space + fx := slugSpaceFixture(t) + fx.addRelation(t, testSpaceId, objectstore.TestObject{ + bundle.RelationKeyId: domain.String("rel-squatter"), + bundle.RelationKeyRelationKey: domain.String("6a7663db61fab21cd4b9e107"), + bundle.RelationKeyApiObjectKey: domain.String("due_date"), + bundle.RelationKeyName: domain.String("Due Date"), + }) + + // when + rows, _, _, err := fx.ListProperties(context.Background(), testSpaceId, 0, 25) + + // then + require.NoError(t, err) + served := map[string]string{} + for _, row := range rows { + served[row.Name] = row.Key + } + assert.Equal(t, "6a7663db61fab21cd4b9e107", served["Due Date"], + "an address the listing serves must resolve back to the row it labels") + + // and the reason, executed: the served spelling is exactly the one the + // input chain refuses + entries, err := fx.liveProperties(testSpaceId) + require.NoError(t, err) + _, ok, ambiguous := fx.resolvePropertyInput("due_date", entries) + assert.False(t, ok) + assert.NotEmpty(t, ambiguous) + entry, ok, ambiguous := fx.resolvePropertyInput(served["Due Date"], entries) + require.True(t, ok, "the served address resolves") + assert.Empty(t, ambiguous) + assert.Equal(t, "rel-squatter", entry.Id) + }) + + t.Run("a type slug the bundled table resolves elsewhere is not served", func(t *testing.T) { + fx := slugSpaceFixture(t) + fx.addType(t, testSpaceId, objectstore.TestObject{ + bundle.RelationKeyId: domain.String("type-squatter"), + bundle.RelationKeyUniqueKey: domain.String("ot-6a7663db61fab21cd4b9e108"), + bundle.RelationKeyApiObjectKey: domain.String("object_type"), + bundle.RelationKeyName: domain.String("Object Type"), + }) + + rows, _, _, err := fx.ListTypes(context.Background(), testSpaceId, 0, 25) + + require.NoError(t, err) + served := map[string]string{} + for _, row := range rows { + served[row.Name] = row.Key + } + assert.Equal(t, "6a7663db61fab21cd4b9e108", served["Object Type"]) + }) +} + +// TestV2ShadowedBundledSlugIsLoud is the 1.2 floor: a pre-existing shadow +// used to resolve silently to the squatter. Revert the shadowedBundled* +// branches in keys.go and this passes with the WRONG entity instead of +// refusing. +func TestV2ShadowedBundledSlugIsLoud(t *testing.T) { + // given + fx := slugSpaceFixture(t) + fx.addRelation(t, testSpaceId, objectstore.TestObject{ + bundle.RelationKeyId: domain.String("rel-squatter"), + bundle.RelationKeyRelationKey: domain.String("6a7663db61fab21cd4b9e107"), + bundle.RelationKeyApiObjectKey: domain.String("due_date"), + bundle.RelationKeyName: domain.String("Due Date"), + }) + entries, err := fx.liveProperties(testSpaceId) + require.NoError(t, err) + + // when + _, ok, ambiguous := fx.resolvePropertyInput("due_date", entries) + + // then + assert.False(t, ok, "a shadowed bundled slug must never resolve by store order") + require.Len(t, ambiguous, 2) + assert.Contains(t, ambiguous[0], "6a7663db61fab21cd4b9e107", "the squatter, addressable by its stored key") + assert.Contains(t, ambiguous[1], "dueDate", "and the bundled property it shadows") +} + +// TestV2ShadowedBundledTypeIsLoud is the same floor in the TYPE namespace, +// which had the branch and no test at all: revert the shadowedBundledType +// branch in resolveTypeInput and a document naming `object_type` silently +// binds the squatter instead of refusing. +func TestV2ShadowedBundledTypeIsLoud(t *testing.T) { + // given + fx := slugSpaceFixture(t) + fx.addType(t, testSpaceId, objectstore.TestObject{ + bundle.RelationKeyId: domain.String("type-squatter"), + bundle.RelationKeyUniqueKey: domain.String("ot-6a7663db61fab21cd4b9e108"), + bundle.RelationKeyApiObjectKey: domain.String("object_type"), + bundle.RelationKeyName: domain.String("Object Type"), + }) + entries, err := fx.liveTypes(testSpaceId) + require.NoError(t, err) + + // when + _, ok, ambiguous := fx.resolveTypeInput("object_type", entries) + + // then + assert.False(t, ok) + require.Len(t, ambiguous, 2) + assert.Contains(t, ambiguous[0], "6a7663db61fab21cd4b9e108") + assert.Contains(t, ambiguous[1], "objectType") + + // and the stored key still addresses the squatter, which is what makes the + // refusal actionable + entry, ok, ambiguous := fx.resolveTypeInput("6a7663db61fab21cd4b9e108", entries) + require.True(t, ok) + assert.Empty(t, ambiguous) + assert.Equal(t, "type-squatter", entry.Id) +} diff --git a/core/api/v2/service/keys_test.go b/core/api/v2/service/keys_test.go new file mode 100644 index 0000000000..775953445a --- /dev/null +++ b/core/api/v2/service/keys_test.go @@ -0,0 +1,220 @@ +package v2service + +import ( + "context" + "net/http" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + v2model "github.com/anyproto/anytype-heart/core/api/v2/model" + "github.com/anyproto/anytype-heart/core/domain" + "github.com/anyproto/anytype-heart/pkg/lib/bundle" + "github.com/anyproto/anytype-heart/pkg/lib/localstore/objectstore" + "github.com/anyproto/anytype-heart/pkg/lib/pb/model" +) + +// The §7.5-requirement-2 corpse policy: an archived (v2 delete) or +// uninstalled (UI delete) type/property must neither list, nor resolve as an +// address, nor be suggested as a remedy. Before this landed, isUninstalled +// was filtered NOWHERE (ADDRESSING §2.3-6): a UI-deleted property still +// appeared in GET /properties and PATCH steered callers into editing a +// corpse. + +// addRelation registers one relation object in the store fixture. +func (fx *v2Fixture) addRelation(t *testing.T, spaceId string, obj objectstore.TestObject) { + base := objectstore.TestObject{ + bundle.RelationKeyResolvedLayout: domain.Int64(int64(model.ObjectType_relation)), + bundle.RelationKeyRelationFormat: domain.Int64(int64(model.RelationFormat_longtext)), + } + for k, v := range obj { + base[k] = v + } + fx.objectStore.AddObjects(t, spaceId, []objectstore.TestObject{base}) +} + +// addType registers one type object in the store fixture. +func (fx *v2Fixture) addType(t *testing.T, spaceId string, obj objectstore.TestObject) { + base := objectstore.TestObject{ + bundle.RelationKeyResolvedLayout: domain.Int64(int64(model.ObjectType_objectType)), + } + for k, v := range obj { + base[k] = v + } + fx.objectStore.AddObjects(t, spaceId, []objectstore.TestObject{base}) +} + +func corpsePolicyFixture(t *testing.T) *v2Fixture { + fx := newV2Fixture(t) + fx.addRelation(t, testSpaceId, objectstore.TestObject{ + bundle.RelationKeyId: domain.String("rel-live"), + bundle.RelationKeyRelationKey: domain.String("liveKey"), + bundle.RelationKeyName: domain.String("Live property"), + }) + fx.addRelation(t, testSpaceId, objectstore.TestObject{ + bundle.RelationKeyId: domain.String("rel-uninstalled"), + bundle.RelationKeyRelationKey: domain.String("corpseKey"), + bundle.RelationKeyName: domain.String("UI-deleted property"), + bundle.RelationKeyIsUninstalled: domain.Bool(true), + }) + fx.addRelation(t, testSpaceId, objectstore.TestObject{ + bundle.RelationKeyId: domain.String("rel-archived"), + bundle.RelationKeyRelationKey: domain.String("archivedKey"), + bundle.RelationKeyName: domain.String("v2-deleted property"), + bundle.RelationKeyIsArchived: domain.Bool(true), + }) + fx.addType(t, testSpaceId, objectstore.TestObject{ + bundle.RelationKeyId: domain.String("type-live"), + bundle.RelationKeyUniqueKey: domain.String("ot-livetype"), + bundle.RelationKeyName: domain.String("Live type"), + }) + fx.addType(t, testSpaceId, objectstore.TestObject{ + bundle.RelationKeyId: domain.String("type-uninstalled"), + bundle.RelationKeyUniqueKey: domain.String("ot-corpsetype"), + bundle.RelationKeyName: domain.String("UI-deleted type"), + bundle.RelationKeyIsUninstalled: domain.Bool(true), + }) + return fx +} + +func requireNotFoundError(t *testing.T, err error) *v2model.Error { + t.Helper() + var apiErr *v2model.Error + require.ErrorAs(t, err, &apiErr) + assert.Equal(t, http.StatusNotFound, apiErr.Status) + return apiErr +} + +func TestV2CorpsePolicyProperties(t *testing.T) { + t.Run("uninstalled and archived properties do not list", func(t *testing.T) { + // given + fx := corpsePolicyFixture(t) + + // when + rows, total, _, err := fx.ListProperties(context.Background(), testSpaceId, 0, 25) + + // then: only the live property — before the fix the uninstalled one + // passed every filter (nothing anywhere excluded isUninstalled) + require.NoError(t, err) + keys := make([]string, 0, len(rows)) + for _, row := range rows { + keys = append(keys, row.Key) + } + assert.Equal(t, []string{"liveKey"}, keys) + assert.Equal(t, 1, total) + }) + + t.Run("known property keys never suggest a corpse", func(t *testing.T) { + fx := corpsePolicyFixture(t) + assert.Equal(t, []string{"liveKey"}, fx.knownPropertyKeys(testSpaceId)) + }) + + t.Run("PATCH of a UI-deleted property is 404, not a corpse edit", func(t *testing.T) { + // given + fx := corpsePolicyFixture(t) + name := "renamed" + + // when: before the fix GetRelationByKey saw the uninstalled relation + // and the PATCH proceeded against the object the user deleted + _, err := fx.UpdateProperty(context.Background(), testSpaceId, "corpseKey", v2model.UpdatePropertyRequest{Name: &name}, false) + + // then + requireNotFoundError(t, err) + }) + + t.Run("DELETE of a UI-deleted property is 404, not a re-archive", func(t *testing.T) { + // the UNINSTALLED corpse is the revert-sensitive case: the old + // GetRelationByKey lookup saw it (nothing filtered isUninstalled) + // and the DELETE archived the corpse. (The archived variant cannot + // fail on revert — the store's injected defaults hid archived + // objects from the old lookup too — so it is asserted only as a + // bonus line, not as this subtest's claim.) + fx := corpsePolicyFixture(t) + _, err := fx.DeleteProperty(context.Background(), testSpaceId, "corpseKey", false) + requireNotFoundError(t, err) + _, err = fx.DeleteProperty(context.Background(), testSpaceId, "archivedKey", false) + requireNotFoundError(t, err) + }) + + t.Run("options of a corpse property are 404", func(t *testing.T) { + fx := corpsePolicyFixture(t) + _, _, _, err := fx.ListPropertyOptions(context.Background(), testSpaceId, "corpseKey", "", 0, 25) + requireNotFoundError(t, err) + }) + + t.Run("a live property still resolves on every touched route", func(t *testing.T) { + fx := corpsePolicyFixture(t) + _, _, _, err := fx.ListPropertyOptions(context.Background(), testSpaceId, "liveKey", "", 0, 25) + assert.NoError(t, err) + }) +} + +func TestV2CorpsePolicyTypes(t *testing.T) { + t.Run("uninstalled types do not list", func(t *testing.T) { + // given + fx := corpsePolicyFixture(t) + + // when + rows, total, _, err := fx.ListTypes(context.Background(), testSpaceId, 0, 25) + + // then + require.NoError(t, err) + keys := make([]string, 0, len(rows)) + for _, row := range rows { + keys = append(keys, row.Key) + } + assert.Equal(t, []string{"livetype"}, keys) + assert.Equal(t, 1, total) + }) + + t.Run("GET of a UI-deleted type is 404 with live candidates", func(t *testing.T) { + fx := corpsePolicyFixture(t) + _, _, err := fx.GetType(context.Background(), testSpaceId, "corpsetype", ObjectQuery{}) + apiErr := requireNotFoundError(t, err) + assert.Contains(t, apiErr.Message, "livetype") + assert.NotContains(t, apiErr.Message, "corpsetype: ") + }) + + t.Run("PATCH and DELETE of a UI-deleted type are 404", func(t *testing.T) { + fx := corpsePolicyFixture(t) + _, err := fx.UpdateType(context.Background(), testSpaceId, "corpsetype", []byte(`{"properties":{"name":"x"}}`), false, true) + requireNotFoundError(t, err) + _, err = fx.DeleteType(context.Background(), testSpaceId, "corpsetype", false) + requireNotFoundError(t, err) + }) + + t.Run("search scoped to a corpse type is a loud 400", func(t *testing.T) { + // given + fx := corpsePolicyFixture(t) + + // when + _, _, _, _, err := fx.SearchObjects(context.Background(), testSpaceId, v2model.SearchRequest{Type: "corpsetype"}, 0, 25) + + // then: unknown-type steering, live keys only + var apiErr *v2model.Error + require.ErrorAs(t, err, &apiErr) + assert.Equal(t, http.StatusBadRequest, apiErr.Status) + }) +} + +func TestSanitizeApiSlug(t *testing.T) { + // derived slugs (from names and document keys — inputs no pattern ever + // checked) must land inside the advertised key grammar or not exist: + // before this, "50% done" and "☕" (unidecode: "?") became + // identity-bearing apiObjectKey values no /properties/{key} route could + // accept + cases := map[string]string{ + "50%_done": "50_done", + "c++": "c", + "?": "", + "foo/bar": "foo_bar", + "a__b": "a_b", + "_x_": "x", + "clean_key": "clean_key", + "": "", + } + for in, want := range cases { + assert.Equal(t, want, sanitizeApiSlug(in), "input %q", in) + } +} diff --git a/core/api/v2/service/keys_write_test.go b/core/api/v2/service/keys_write_test.go new file mode 100644 index 0000000000..8dbf75647e --- /dev/null +++ b/core/api/v2/service/keys_write_test.go @@ -0,0 +1,211 @@ +package v2service + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + + "github.com/anyproto/anytype-heart/core/domain" + "github.com/anyproto/anytype-heart/pb" + "github.com/anyproto/anytype-heart/pkg/lib/bundle" + "github.com/anyproto/anytype-heart/pkg/lib/localstore/objectstore" + "github.com/anyproto/anytype-heart/util/pbtypes" +) + +// The WRITE half's key vocabulary (§7.5a-5). Every import channel of this +// service rides creatingResolvers.Options(); before `Keys: r.reads` landed +// there the write half fell back to BundledKeyVocabulary while the read half +// exported through storeresolver, and the two halves named different entities. +// +// Every fixture here is deliberately BSON-keyed with a stored apiObjectKey, or +// a stored key that the bundled table resolves elsewhere: a fixture whose key +// the bundled table happens to invert (name, page, dueDate) cannot tell the +// two vocabularies apart and proves nothing. + +// slugDataviewDoc is a set whose dataview names the BSON-keyed property by its +// STORED key — the shape a real space holds. editRead imports with the bundled +// vocabulary, so the stored spelling survives into the snapshot verbatim. +const slugDataviewDoc = `{"version":1,"id":"obj1","type":"set","properties":{"name":"Bugs","setOf":["ot-bug"]},"blocks":[` + + `{"id":"dataview","type":"dataview",` + + `"properties":[{"property":"name","format":"text"},{"property":"` + slugPropKey + `","format":"text"}],` + + `"views":[{"id":"viewAll1","name":"All",` + + `"columns":[{"property":"name"},{"property":"` + slugPropKey + `","hidden":true,"width":100}]}]}]}` + +// TestV2WriteVocabularyIsTheReadVocabulary is the pin for the asymmetry: +// revert `Keys: r.reads` in creatingResolvers.Options() (resolver.go) and every +// subtest here fails. +func TestV2WriteVocabularyIsTheReadVocabulary(t *testing.T) { + ctx := context.Background() + + t.Run("update_view re-import keeps the stored relation key, not the slug", func(t *testing.T) { + // given: the executed defect — the whole-dataview re-import behind + // every view op rewrote the stored key to the literal slug, so the + // dataview named a relation key no relation object owns + fx := slugSpaceFixture(t) + captured := fx.expectMutate(editRead(t, slugDataviewDoc), "headB") + + // when: the column is addressed by the slug the listings serve + _, err := fx.PatchObject(ctx, testSpaceId, "obj1", + patchBody(`{"op":"update_view","columns":{"manual_property":{"hidden":false}}}`), "", false, true) + + // then + require.NoError(t, err) + view := viewsOf(t, dataviewOf(t, *captured, "dataview"))[0] + assert.Nil(t, columnByProperty(t, view, "manual_property"), + "the slug must never become a stored relation key") + col := columnByProperty(t, view, slugPropKey) + require.NotNil(t, col, "the column keeps the stored key it was addressed through") + _, stillHidden := col["hidden"] + assert.False(t, stillHidden, "and the edit itself happened") + assert.Equal(t, float64(100), col["width"]) + + // the dataview's properties list is the same slot, one level over + dv := dataviewOf(t, *captured, "dataview") + props, _ := dv["properties"].([]any) + var keys []string + for _, p := range props { + keys = append(keys, p.(map[string]any)["property"].(string)) + } + assert.Contains(t, keys, slugPropKey) + assert.NotContains(t, keys, "manual_property") + }) + + t.Run("a document key a live stored key claims does not fold into the bundled property", func(t *testing.T) { + // given: a space holding a legacy relation STORED under `due_date` + // beside the installed bundled `dueDate`. canonicalizeDocumentKeys + // resolves `due_date` correctly at chain step 1 — and the import + // vocabulary then rewrote it to `dueDate`, landing the value on the + // bundled property (chain step 1 has no meaning in the bundled table). + fx := newV2Fixture(t) + fx.addRelation(t, testSpaceId, objectstore.TestObject{ + bundle.RelationKeyId: domain.String("rel-legacy-due"), + bundle.RelationKeyRelationKey: domain.String("due_date"), + bundle.RelationKeyName: domain.String("Legacy due date"), + }) + fx.addRelation(t, testSpaceId, objectstore.TestObject{ + bundle.RelationKeyId: domain.String("rel-bundled-due"), + bundle.RelationKeyRelationKey: domain.String("dueDate"), + bundle.RelationKeyName: domain.String("Due date"), + }) + captured := fx.expectCreate("obj-new") + fx.expectEtagRead("obj-new") + + // when + _, err := fx.CreateObject(ctx, testSpaceId, []byte(`{ + "version":1,"type":"page", + "properties":{"name":"Ship it","due_date":"2026-01-02T00:00:00Z"}}`), false, true) + + // then + require.NoError(t, err) + require.NotNil(t, *captured) + snapshot := *captured + assert.NotEmpty(t, pbtypes.Get(snapshot.Details, "due_date"), + "an exact live stored key wins the whole chain, import included") + assert.Nil(t, pbtypes.Get(snapshot.Details, "dueDate"), + "the value must not land on the bundled property") + }) + + t.Run("insert_blocks lands a property block on the stored key", func(t *testing.T) { + // the fragment-import channel (stateops importOptions): a `property` + // block's key is a key slot, so it inverts through the same vocabulary + fx := slugSpaceFixture(t) + captured := fx.expectMutate(editRead(t, editBaseDoc), "headB") + + _, err := fx.PatchObject(ctx, testSpaceId, "obj1", + patchBody(`{"op":"insert_blocks","blocks":[{"type":"property","property":"manual_property"}]}`), "", false, true) + + require.NoError(t, err) + var keys []string + for _, b := range docBlocks(stateDoc(t, *captured)) { + if b["type"] == "property" { + key, _ := b["property"].(string) + keys = append(keys, key) + } + } + assert.Equal(t, []string{slugPropKey}, keys, + "the property block binds the stored key the slug names") + }) + + t.Run("PATCH types typeProperties speak the same vocabulary as the document", func(t *testing.T) { + // given: the PATCH channel writes the SAME §2a array a type document + // carries, so it must invert its key slots through the same + // vocabulary — BuildRecommendedLists took a bare resolver and did not + fx := newV2Fixture(t) + fx.addRelation(t, testSpaceId, objectstore.TestObject{ + bundle.RelationKeyId: domain.String("rel-legacy-due"), + bundle.RelationKeyRelationKey: domain.String("due_date"), + bundle.RelationKeyName: domain.String("Legacy due date"), + }) + fx.addRelation(t, testSpaceId, objectstore.TestObject{ + bundle.RelationKeyId: domain.String("rel-bundled-due"), + bundle.RelationKeyRelationKey: domain.String("dueDate"), + bundle.RelationKeyName: domain.String("Due date"), + }) + fx.addType(t, testSpaceId, objectstore.TestObject{ + bundle.RelationKeyId: domain.String("type-chore"), + bundle.RelationKeyUniqueKey: domain.String("ot-chore"), + bundle.RelationKeyName: domain.String("Chore"), + }) + var setDetails *pb.RpcObjectSetDetailsRequest + fx.mwMock.EXPECT().ObjectSetDetails(mock.Anything, mock.Anything). + RunAndReturn(func(ctx context.Context, req *pb.RpcObjectSetDetailsRequest) *pb.RpcObjectSetDetailsResponse { + setDetails = req + return &pb.RpcObjectSetDetailsResponse{Error: &pb.RpcObjectSetDetailsResponseError{Code: pb.RpcObjectSetDetailsResponseError_NULL}} + }) + fx.expectEtagRead("type-chore") + + // when + _, err := fx.UpdateType(ctx, testSpaceId, "chore", + []byte(`{"type_settings":{"property_definitions":[{"property":"due_date","section":"featured"}]}}`), false, true) + + // then + require.NoError(t, err) + require.NotNil(t, setDetails) + byKey := map[string][]string{} + for _, d := range setDetails.Details { + byKey[d.Key] = pbtypes.GetStringListValue(d.Value) + } + assert.Equal(t, []string{"rel-legacy-due"}, byKey[bundle.RelationKeyRecommendedFeaturedRelations.String()], + "the exact live stored key wins here too") + }) + + t.Run("a type document's typeProperties resolve the live stored key, not the bundled twin", func(t *testing.T) { + // typeproperties.go inverts tp.Key through the same vocabulary before + // the resolver ever sees it, so the bundled table's over-reach steered + // the recommended list onto the wrong relation object + fx := newV2Fixture(t) + fx.addRelation(t, testSpaceId, objectstore.TestObject{ + bundle.RelationKeyId: domain.String("rel-legacy-due"), + bundle.RelationKeyRelationKey: domain.String("due_date"), + bundle.RelationKeyName: domain.String("Legacy due date"), + }) + fx.addRelation(t, testSpaceId, objectstore.TestObject{ + bundle.RelationKeyId: domain.String("rel-bundled-due"), + bundle.RelationKeyRelationKey: domain.String("dueDate"), + bundle.RelationKeyName: domain.String("Due date"), + }) + var created *pb.RpcObjectCreateObjectTypeRequest + fx.mwMock.EXPECT().ObjectCreateObjectType(mock.Anything, mock.Anything). + RunAndReturn(func(ctx context.Context, req *pb.RpcObjectCreateObjectTypeRequest) *pb.RpcObjectCreateObjectTypeResponse { + created = req + return &pb.RpcObjectCreateObjectTypeResponse{ + ObjectId: "type-chore", + Error: &pb.RpcObjectCreateObjectTypeResponseError{Code: pb.RpcObjectCreateObjectTypeResponseError_NULL}, + } + }) + fx.expectEtagRead("type-chore") + + // when + _, err := fx.CreateType(ctx, testSpaceId, []byte(`{"kind":"object_type","type_settings":{"api_key":"chore","property_definitions":[{"property":"due_date","section":"featured"}]}}`), false, true) + + // then + require.NoError(t, err) + require.NotNil(t, created) + assert.Equal(t, []string{"rel-legacy-due"}, + pbtypes.GetStringList(created.Details, bundle.RelationKeyRecommendedFeaturedRelations.String()), + "the exact live stored key wins on the type-create path too") + }) +} diff --git a/core/api/v2/service/list_create.go b/core/api/v2/service/list_create.go new file mode 100644 index 0000000000..040f84f4aa --- /dev/null +++ b/core/api/v2/service/list_create.go @@ -0,0 +1,490 @@ +package v2service + +// list_create.go implements POST sets and POST collections (APIV2.md §2 +// Phase 2). Sets follow §8/R10: ObjectCreateSet takes no filters, so the set +// is built as one AnyBlock document whose initial state carries a fully- +// formed dataview block — one change set, honestly atomic — reusing the +// generic create path. Collections use the AnyBlock items import path. + +import ( + "context" + "encoding/json" + "fmt" + "sort" + + v2model "github.com/anyproto/anytype-heart/core/api/v2/model" + "github.com/anyproto/anytype-heart/core/domain" + "github.com/anyproto/anytype-heart/pkg/lib/anyblockjson" + "github.com/anyproto/anytype-heart/pkg/lib/anyblockjson/filterstring" + "github.com/anyproto/anytype-heart/pkg/lib/bundle" + "github.com/anyproto/anytype-heart/pkg/lib/pb/model" +) + +// dataviewBlockId is the block id the set/collection editors expect their +// dataview under (template.DataviewBlockId) — a fresh id would make the +// editor add a second, default dataview at first open. +const dataviewBlockId = "dataview" + +// CreateSet implements POST /v2/spaces/{space_id}/sets. +func (s *Service) CreateSet(ctx context.Context, spaceId string, req v2model.CreateSetRequest, dryRun, createMissingOptions bool) (*v2model.CreateResult, error) { + if err := s.ensureSpaceWrite(ctx, spaceId); err != nil { + return nil, err + } + if req.Name == "" { + return nil, v2model.ValidationFailed("name is required", + v2model.Issue{Path: "/name", Message: "a set needs a name"}) + } + if req.Type == "" { + return nil, v2model.ValidationFailed("type is required", + v2model.Issue{Path: "/type", Message: "a set queries one type — name its key", Hint: fmt.Sprintf("list keys with GET /v2/spaces/%s/types", spaceId)}) + } + // the bounds the set kind advertises (M6): field lengths and the + // sorts/views item caps + if err := validateV2FieldLength("/name", req.Name, maxV2NameLength); err != nil { + return nil, err + } + if err := validateV2FieldLength("/type", req.Type, maxV2KeyLength); err != nil { + return nil, err + } + if err := validateV2FieldLength("/filter", req.Filter, maxV2FilterLength); err != nil { + return nil, err + } + if err := validateV2ArrayCount("/sorts", req.Sorts, maxV2SetSorts); err != nil { + return nil, err + } + if err := validateV2ArrayCount("/views", req.Views, maxV2SetViews); err != nil { + return nil, err + } + // C6: filter and filters are mutually exclusive; both → ambiguous_input + if req.Filter != "" && len(req.Filters) > 0 { + return nil, v2model.AmbiguousInput("provide filter or filters, not both", + v2model.Issue{Path: "/filter", Message: "conflicts with filters"}, + v2model.Issue{Path: "/filters", Message: "conflicts with filter"}) + } + if len(req.Views) > 0 && (req.Filter != "" || len(req.Filters) > 0 || len(req.Sorts) > 0) { + return nil, v2model.AmbiguousInput("provide views or top-level filter/filters/sorts, not both", + v2model.Issue{Path: "/views", Message: "views carry their own filters and sorts"}) + } + + // the queried type must exist in the space — its property keys are the + // R9 reference set for the filters. Live lookup, slug-aware: a set over + // a UI-deleted type would be a set over a corpse (§7.5-2 corpse policy). + typeEntries, err := s.liveTypes(spaceId) + if err != nil { + return nil, err + } + entry, ok, ambiguous := s.resolveTypeInput(req.Type, typeEntries) + if len(ambiguous) > 0 { + return nil, ambiguousKeyError("type key", req.Type, "/type", ambiguous) + } + if !ok || entry.Id == "" { + // a bundled key resolving with no live install may be one this space + // REMOVED — say that, not "unknown" with a did-you-mean (§8.41-10); + // the refusal itself predates §8.41 (a set requires an installed + // type either way) + if ok && entry.Id == "" { + if err := s.refuseRemovedType(ctx, spaceId, entry.Key, "/type"); err != nil { + return nil, err + } + } + return nil, s.unknownTypeKeyError(spaceId, req.Type, "/type") + } + typeId := entry.Id + // downstream builders derive `ot-` URLs from the type term — hand them + // the canonical internal key, not the caller's slug spelling + req.Type = entry.Key + + // the compact filter string (SPEC §6.2.1) parses to the structured array + // through the same reference set the structured form is validated + // against; the set document stores the structured array (export keeps + // writing it — the document field `filter` stays reserved post-v1). + // Option names are deliberately NOT parse-validated here: a set create is + // a WRITE, where select option names create-missing (R9/§8.1) — unlike + // the read-only query path. + // kc canonicalizes the request's property spellings (served slugs → + // stored keys) — the set DOCUMENT persists these keys, and a served + // spelling landing in a dataview filter would bind a RelationKey the + // store never matches, silently (review cause 3) + kc, err := s.newKeyCanon(spaceId) + if err != nil { + return nil, err + } + if req.Filter != "" { + // "type" joins the reference set only so the discovery-served grammar + // example (`type IN (…)`) parses to a targeted error below instead of + // an unknown-key message that cannot explain itself + refKeys := appendMissing(append(kc.withServedSpellings(s.typePropertyKeys(spaceId, typeId)), "name", "type"), v2SystemQueryKeys...) + sort.Strings(refKeys) + parsed, err := filterstring.Parse(req.Filter, filterstring.Options{ + KnownKeys: refKeys, + ResolveFormat: canonFormatName(s.formatNameResolver(spaceId), kc), + }) + if err != nil { + return nil, filterStringError(err) + } + req.Filter = "" + req.Filters = parsed + } + if req.Filters, err = kc.canonicalizeRawChannel(req.Filters, "filters", "/filters"); err != nil { + return nil, err + } + if req.Sorts, err = kc.canonicalizeRawChannel(req.Sorts, "sorts", "/sorts"); err != nil { + return nil, err + } + if req.Views, err = kc.canonicalizeRawChannel(req.Views, "views", "/views"); err != nil { + return nil, err + } + // M3: the same structural gate the query path runs. A set persists its + // filter, so a match-everything shape here is not a bad query — it is a + // set that quietly contains the whole space, for good. (The string form + // above cannot produce these shapes; the parser emits a condition on + // every leaf and never a childless group.) + if len(req.Filters) > 0 { + if _, err := decodeFilterNodes(req.Filters, "/filters"); err != nil { + return nil, err + } + } + + // R9 referential validation: every property key the view addresses must + // be one the type actually recommends + referenced, err := collectViewPropertyKeys(req) + if err != nil { + return nil, err + } + if err := s.validateViewKeys(ctx, spaceId, typeId, req.Type, referenced); err != nil { + return nil, err + } + + doc, err := s.buildSetDocument(spaceId, typeId, req, referenced) + if err != nil { + return nil, err + } + return s.createFromDocument(ctx, spaceId, doc, docCreateOptions{dryRun: dryRun, createMissingOptions: createMissingOptions}) +} + +// CreateCollection implements POST /v2/spaces/{space_id}/collections: the +// AnyBlock items import path builds the collection store. +func (s *Service) CreateCollection(ctx context.Context, spaceId string, req v2model.CreateCollectionRequest, dryRun bool) (*v2model.CreateResult, error) { + if err := s.ensureSpaceWrite(ctx, spaceId); err != nil { + return nil, err + } + if req.Name == "" { + return nil, v2model.ValidationFailed("name is required", + v2model.Issue{Path: "/name", Message: "a collection needs a name"}) + } + if err := validateV2FieldLength("/name", req.Name, maxV2NameLength); err != nil { + return nil, err + } + // the advertised items cap (M6) — checked BEFORE the per-item store + // lookups, so an oversized list costs nothing + if len(req.Items) > maxV2CollectionItems { + return nil, v2model.ValidationFailed("too many items", + v2model.Issue{Path: "/items", + Message: fmt.Sprintf("%d items — the cap is %d (the advertised maxItems)", len(req.Items), maxV2CollectionItems), + Hint: "create the collection with the first items, then add the rest with the add_items PATCH op"}) + } + + // referential validation: items must be existing objects in the space + var issues []v2model.Issue + index := s.store.SpaceIndex(spaceId) + for i, itemId := range req.Items { + details, err := index.GetDetails(itemId) + if err != nil || details.GetString(bundle.RelationKeyId) == "" { + issues = append(issues, v2model.Issue{ + Path: fmt.Sprintf("/items/%d", i), + Message: fmt.Sprintf("object %q not found in space %q", itemId, spaceId), + Hint: "items are full object ids — find them with GET /v2/spaces/{space_id}/objects", + }) + } + } + if len(issues) > 0 { + return nil, v2model.ValidationFailed("unknown collection items", issues...) + } + + fields := map[string]json.RawMessage{} + var err error + if fields["version"], err = rawJSON(anyblockjson.FormatVersion); err != nil { + return nil, err + } + if fields["type"], err = rawJSON(string(bundle.TypeKeyCollection)); err != nil { + return nil, err + } + if fields["properties"], err = rawJSON(map[string]string{"name": req.Name}); err != nil { + return nil, err + } + if len(req.Items) > 0 { + if fields["items"], err = rawJSON(req.Items); err != nil { + return nil, err + } + } + doc, err := encodeEnvelope(fields) + if err != nil { + return nil, err + } + // a collection body is {name, items}: object ids, no property values, so + // no select name can reach the resolver and no consent is meaningful + return s.createFromDocument(ctx, spaceId, doc, docCreateOptions{dryRun: dryRun}) +} + +// viewKeyRef is one property reference inside the requested views, with its +// JSON path for error addressing. +type viewKeyRef struct { + key string + path string +} + +// filterNodeProbe decodes the §6.2 filter tree just deep enough to collect +// property keys. +type filterNodeProbe struct { + Property string `json:"property"` + Filters []filterNodeProbe `json:"filters"` +} + +type sortProbe struct { + Property string `json:"property"` + // IncludeTime distinguishes "omitted" from an explicit false — the + // search path defaults date sorts to second granularity only when the + // request did not decide (search.go). + IncludeTime *bool `json:"include_time"` + // CustomOrder is probed by the view ops for the advertised maxItems + // bound (viewops.go). + CustomOrder []json.RawMessage `json:"custom_order"` +} + +type viewProbe struct { + GroupBy string `json:"group_by"` + Sorts []sortProbe `json:"sorts"` + Filters []filterNodeProbe `json:"filters"` + Columns []sortProbe `json:"columns"` // columns carry `property` too +} + +// collectViewPropertyKeys gathers every property key the request's filters, +// sorts and views address, each with its JSON path. +func collectViewPropertyKeys(req v2model.CreateSetRequest) ([]viewKeyRef, error) { + var refs []viewKeyRef + if len(req.Filters) > 0 { + var nodes []filterNodeProbe + if err := json.Unmarshal(req.Filters, &nodes); err != nil { + return nil, v2model.ValidationFailed("invalid filters", + v2model.Issue{Path: "/filters", Message: err.Error(), Hint: "filters is the SPEC §6.2 array of filter nodes"}) + } + collectFilterKeys(nodes, "/filters", &refs) + } + if len(req.Sorts) > 0 { + var sorts []sortProbe + if err := json.Unmarshal(req.Sorts, &sorts); err != nil { + return nil, v2model.ValidationFailed("invalid sorts", + v2model.Issue{Path: "/sorts", Message: err.Error(), Hint: "sorts is the SPEC §6.2 array of sort objects"}) + } + for i, sort := range sorts { + if sort.Property != "" { + refs = append(refs, viewKeyRef{key: sort.Property, path: fmt.Sprintf("/sorts/%d/property", i)}) + } + } + } + if len(req.Views) > 0 { + var views []viewProbe + if err := json.Unmarshal(req.Views, &views); err != nil { + return nil, v2model.ValidationFailed("invalid views", + v2model.Issue{Path: "/views", Message: err.Error(), Hint: "views is the SPEC §6.2 array of view objects"}) + } + for i, view := range views { + prefix := fmt.Sprintf("/views/%d", i) + if view.GroupBy != "" { + refs = append(refs, viewKeyRef{key: view.GroupBy, path: prefix + "/groupBy"}) + } + for j, sort := range view.Sorts { + if sort.Property != "" { + refs = append(refs, viewKeyRef{key: sort.Property, path: fmt.Sprintf("%s/sorts/%d/property", prefix, j)}) + } + } + for j, column := range view.Columns { + if column.Property != "" { + refs = append(refs, viewKeyRef{key: column.Property, path: fmt.Sprintf("%s/columns/%d/property", prefix, j)}) + } + } + collectFilterKeys(view.Filters, prefix+"/filters", &refs) + } + } + return refs, nil +} + +func collectFilterKeys(nodes []filterNodeProbe, path string, refs *[]viewKeyRef) { + for i, node := range nodes { + nodePath := fmt.Sprintf("%s/%d", path, i) + if node.Property != "" { + *refs = append(*refs, viewKeyRef{key: node.Property, path: nodePath + "/property"}) + } + collectFilterKeys(node.Filters, nodePath+"/filters", refs) + } +} + +// validateViewKeys rejects filter/sort/view property keys the type lacks — +// the R9 error lists the type's actual keys. The Phase-4 system-key +// allowlist (createdDate, lastModifiedDate, creator, lastOpenedDate) is +// always part of the reference set: those keys appear in no type's +// recommended lists yet back bread-and-butter queries (rule 2 — the +// widening of the shipped R9 sets rule). +// +// Membership in the type's recommended lists is NOT enough on its own: the +// lists are resolved by id and nothing strips a deleted relation from them, +// so after any UI delete the default state is a type still recommending the +// corpse — and a NEW set filtering or sorting on it would persist a query +// against a property the user removed (§8.41). The removal gate runs after +// the membership pass for exactly that row. +func (s *Service) validateViewKeys(ctx context.Context, spaceId, typeId, typeKey string, refs []viewKeyRef) error { + if len(refs) == 0 { + return nil + } + typeKeys := s.typePropertyKeys(spaceId, typeId) + allowed := map[string]bool{"name": true} // universal + for _, key := range v2SystemQueryKeys { + allowed[key] = true + } + for _, key := range typeKeys { + allowed[key] = true + } + // inputs arrive canonicalized (CreateSet's kc rewrite); the candidate + // list must speak the SERVED spelling — never advertise what the + // channel rejects (review cause 3) + entries, entriesErr := s.liveProperties(spaceId) + if entriesErr == nil { + kc := &keyCanon{s: s, entries: entries} + typeKeys = kc.servedSpellings(typeKeys) + } + // the bundled-removal set, primed lazily and only when an allowed key is + // not live-installed (§7.5a-2) + var removedBundled map[string]bool + var issues []v2model.Issue + for _, ref := range refs { + if allowed[ref.key] { + if entriesErr == nil && bundle.HasRelation(domain.RelationKey(ref.key)) && !propertyKeyInstalledIn(entries, ref.key) { + if removedBundled == nil { + var err error + if removedBundled, err = s.bundledRemovalSet(spaceId); err != nil { + return err + } + } + isRemoved, err := s.bundledPropertyRemoved(ctx, spaceId, entries, removedBundled, ref.key) + if err != nil { + return err + } + if isRemoved { + issues = append(issues, removedPropertyIssue(spaceId, ref.key, ref.key, ref.path)) + continue + } + } + continue + } + if ref.key == "type" { + // the search surface takes `type` as a pseudo-key; a set carries + // its scope in setOf already, so the leaf is redundant here — say + // that instead of "unknown property" + issues = append(issues, v2model.Issue{ + Path: ref.path, + Message: fmt.Sprintf("a set is already scoped to type %q — drop the type filter", typeKey), + Hint: "to query across types use POST /v2/spaces/{space_id}/search, where type is a filterable pseudo-key", + }) + continue + } + issues = append(issues, v2model.Issue{ + Path: ref.path, + Message: fmt.Sprintf("type %q has no property %q — %s", typeKey, ref.key, listKnown("property keys of the type", typeKeys)), + Hint: didYouMean(ref.key, typeKeys, fmt.Sprintf("inspect the type with GET /v2/spaces/%s/types/%s", spaceId, typeKey)), + }) + } + if len(issues) > 0 { + return v2model.ValidationFailed(fmt.Sprintf("the view addresses properties type %q does not have", typeKey), issues...) + } + return nil +} + +// buildSetDocument synthesizes the set's AnyBlock document: name + setOf in +// properties, and one dataview block (id "dataview") carrying the views — +// the §8/R10 initial-state construction. +func (s *Service) buildSetDocument(spaceId, typeId string, req v2model.CreateSetRequest, referenced []viewKeyRef) ([]byte, error) { + fields := map[string]json.RawMessage{} + var err error + if fields["version"], err = rawJSON(anyblockjson.FormatVersion); err != nil { + return nil, err + } + if fields["type"], err = rawJSON(string(bundle.TypeKeySet)); err != nil { + return nil, err + } + if fields["properties"], err = rawJSON(map[string]any{"name": req.Name, "setOf": []string{typeId}}); err != nil { + return nil, err + } + + views := req.Views + if len(views) == 0 { + view := map[string]any{"name": "All"} + if len(req.Filters) > 0 { + view["filters"] = req.Filters + } + if len(req.Sorts) > 0 { + view["sorts"] = req.Sorts + } + if views, err = json.Marshal([]any{view}); err != nil { + return nil, fmt.Errorf("encode default view: %w", err) + } + } + + dataview := map[string]any{ + "id": dataviewBlockId, + "type": "dataview", + "properties": s.dataviewProperties(spaceId, referenced), + "views": views, + } + if fields["blocks"], err = rawJSON([]any{dataview}); err != nil { + return nil, err + } + return encodeEnvelope(fields) +} + +// dataviewProperties lists the dataview's available properties ({key, +// format}, §6.2): name plus every referenced key, formats resolved from the +// space (falling back to text). +func (s *Service) dataviewProperties(spaceId string, referenced []viewKeyRef) []map[string]string { + resolve := storeFormatResolver(s, spaceId) + keys := []string{"name"} + seen := map[string]bool{"name": true} + for _, ref := range referenced { + if !seen[ref.key] { + seen[ref.key] = true + keys = append(keys, ref.key) + } + } + out := make([]map[string]string, 0, len(keys)) + for _, key := range keys { + // the format vocabulary has a single text name: the stored + // longtext/shorttext split folds into "text" (§3), so `name` resolves + // through the same path as every other key + format := "text" + if f, ok := resolve(domain.RelationKey(key)); ok { + if name := anyblockjson.FormatName(f); name != "" { + format = name + } + } + // §2e: a dataview property entry names its property by the + // document-facing SPELLING under `property` (the member `key` used to + // mean both this and the stored id, and the split gave each its own + // name). The entry has no stored-key member at all — + // `dataviewProperty` is {property, format}, additionalProperties + // false — and the §3 chain resolves an exact stored key here anyway. + out = append(out, map[string]string{"property": key, "format": format}) + } + return out +} + +// storeFormatResolver builds a bundle-aware format resolver over the space. +func storeFormatResolver(s *Service, spaceId string) anyblockjson.FormatResolver { + // read-only: this resolver answers formats and never writes, so the + // option-creation consent is irrelevant and denied on principle + reads := s.newCreatingResolvers(context.Background(), spaceId, true, false) + return func(key domain.RelationKey) (format model.RelationFormat, ok bool) { + if rel, err := bundle.GetRelation(key); err == nil { + return rel.Format, true + } + return reads.ResolveFormat(key) + } +} diff --git a/core/api/v2/service/list_create_test.go b/core/api/v2/service/list_create_test.go new file mode 100644 index 0000000000..0a6b03408c --- /dev/null +++ b/core/api/v2/service/list_create_test.go @@ -0,0 +1,385 @@ +package v2service + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + v2model "github.com/anyproto/anytype-heart/core/api/v2/model" + "github.com/anyproto/anytype-heart/core/domain" + "github.com/anyproto/anytype-heart/pkg/lib/bundle" + "github.com/anyproto/anytype-heart/pkg/lib/localstore/objectstore" + "github.com/anyproto/anytype-heart/pkg/lib/pb/model" + "github.com/anyproto/anytype-heart/util/pbtypes" +) + +func TestV2CreateSet(t *testing.T) { + setup := func(t *testing.T) *v2Fixture { + fx := newV2Fixture(t) + fx.addSelectProperty(t) + fx.addTaskType(t) // type "chore" recommending "severity" + return fx + } + + t.Run("set lands with a fully-formed dataview block in one change set", func(t *testing.T) { + // given + fx := setup(t) + captured := fx.expectCreate("newSet") + fx.expectEtagRead("newSet") + + // when + result, err := fx.CreateSet(context.Background(), testSpaceId, v2model.CreateSetRequest{ + Name: "Open chores", + Type: "chore", + Filters: json.RawMessage(`[{"property":"severity","condition":"in","value":["High"]}]`), + Sorts: json.RawMessage(`[{"property":"severity","direction":"desc"}]`), + }, false, true) + + // then + require.NoError(t, err) + assert.Equal(t, "newSet", result.Id) + snapshot := *captured + require.NotNil(t, snapshot) + assert.Equal(t, []string{"ot-set"}, snapshot.ObjectTypes) + assert.Equal(t, []string{"type-chore"}, pbtypes.GetStringList(snapshot.Details, bundle.RelationKeySetOf.String())) + + require.Len(t, snapshot.Blocks, 2, "root + dataview") + dvBlock := snapshot.Blocks[1] + assert.Equal(t, "dataview", dvBlock.Id, "the editor finds its dataview under the template block id") + dv := dvBlock.GetDataview() + require.NotNil(t, dv) + require.Len(t, dv.Views, 1) + view := dv.Views[0] + assert.Equal(t, "All", view.Name) + require.Len(t, view.Filters, 1) + assert.Equal(t, "severity", view.Filters[0].RelationKey) + // option NAME resolved to the existing option id (SPEC §3/§6.2) + assert.Equal(t, []string{"opt-high"}, pbtypes.GetStringListValue(view.Filters[0].Value)) + require.Len(t, view.Sorts, 1) + assert.Equal(t, "severity", view.Sorts[0].RelationKey) + assert.Equal(t, model.BlockContentDataviewSort_Desc, view.Sorts[0].Type) + }) + + t.Run("filter naming a property the type lacks lists the actual keys (R9)", func(t *testing.T) { + // given + fx := setup(t) + + // when + _, err := fx.CreateSet(context.Background(), testSpaceId, v2model.CreateSetRequest{ + Name: "Broken", + Type: "chore", + Filters: json.RawMessage(`[{"property":"sevirity","condition":"equal","value":true}]`), + }, false, true) + + // then + apiErr := v2Err(t, err) + assert.Equal(t, v2model.CodeValidationFailed, apiErr.Code) + require.Len(t, apiErr.Issues, 1) + assert.Equal(t, "/filters/0/property", apiErr.Issues[0].Path) + assert.Contains(t, apiErr.Issues[0].Message, "severity", "the error lists the type's actual keys") + assert.Contains(t, apiErr.Issues[0].Hint, "severity", "did-you-mean suggests the close key") + }) + + t.Run("unknown type key gets a did-you-mean 400", func(t *testing.T) { + // given + fx := setup(t) + + // when + _, err := fx.CreateSet(context.Background(), testSpaceId, + v2model.CreateSetRequest{Name: "X", Type: "chores"}, false, true) + + // then + apiErr := v2Err(t, err) + require.Len(t, apiErr.Issues, 1) + assert.Equal(t, "/type", apiErr.Issues[0].Path) + assert.Contains(t, apiErr.Issues[0].Hint, "chore") + }) + + t.Run("a type filter gets a targeted message, not unknown-property (string form)", func(t *testing.T) { + // given: the discovery-served grammar example uses `type IN (…)`, + // which search accepts — a set is already type-scoped, and the error + // must say that instead of "unknown property key" + fx := setup(t) + + // when + _, err := fx.CreateSet(context.Background(), testSpaceId, v2model.CreateSetRequest{ + Name: "Open work", + Type: "chore", + Filter: `type IN ("chore") AND severity IS EMPTY`, + }, false, true) + + // then + apiErr := v2Err(t, err) + require.Len(t, apiErr.Issues, 1) + assert.Contains(t, apiErr.Issues[0].Message, `a set is already scoped to type "chore" — drop the type filter`) + assert.Contains(t, apiErr.Issues[0].Hint, "POST /v2/spaces/{space_id}/search") + }) + + t.Run("a type filter gets the targeted message in the structured form too", func(t *testing.T) { + // given + fx := setup(t) + + // when + _, err := fx.CreateSet(context.Background(), testSpaceId, v2model.CreateSetRequest{ + Name: "Open work", + Type: "chore", + Filters: json.RawMessage(`[{"property":"type","condition":"in","value":["chore"]}]`), + }, false, true) + + // then + apiErr := v2Err(t, err) + require.Len(t, apiErr.Issues, 1) + assert.Equal(t, "/filters/0/property", apiErr.Issues[0].Path) + assert.Contains(t, apiErr.Issues[0].Message, `a set is already scoped to type "chore"`) + }) + + // M3 (surface review): the same match-everything shapes the query path + // rejects. Here the stakes are higher — a set PERSISTS its filter, so a + // malformed one is not a bad query but a set that quietly contains the + // whole space, permanently. + t.Run("M3: a match-everything filter shape is refused, not persisted", func(t *testing.T) { + fx := setup(t) + + for _, tc := range []struct { + name string + filters string + path string + }{ + {"group and leaf in one node", `[{"operator":"and","property":"severity","condition":"equal","value":"High"}]`, "/filters/0"}, + {"leaf with no condition", `[{"property":"severity","value":"High"}]`, "/filters/0/condition"}, + {"group with no filters", `[{"operator":"and","filters":[]}]`, "/filters/0/filters"}, + } { + t.Run(tc.name, func(t *testing.T) { + // no creator expectation: reaching the create path fails the test + _, err := fx.CreateSet(context.Background(), testSpaceId, v2model.CreateSetRequest{ + Name: "Open work", + Type: "chore", + Filters: json.RawMessage(tc.filters), + }, false, true) + + apiErr := v2Err(t, err) + assert.Equal(t, http.StatusBadRequest, apiErr.Status) + require.NotEmpty(t, apiErr.Issues) + assert.Equal(t, tc.path, apiErr.Issues[0].Path) + }) + } + }) + + t.Run("filter and filters together are ambiguous_input (C6)", func(t *testing.T) { + // given + fx := setup(t) + + // when + _, err := fx.CreateSet(context.Background(), testSpaceId, v2model.CreateSetRequest{ + Name: "X", Type: "chore", + Filter: `done = false`, + Filters: json.RawMessage(`[]`), + }, false, true) + + // then — note: `[]` is non-empty as raw JSON + apiErr := v2Err(t, err) + assert.Equal(t, v2model.CodeAmbiguousInput, apiErr.Code) + assert.Contains(t, apiErr.Message, "not both") + }) + + t.Run("the compact filter string parses into the stored structured array", func(t *testing.T) { + // given + fx := setup(t) + captured := fx.expectCreate("newSet") + fx.expectEtagRead("newSet") + + // when + result, err := fx.CreateSet(context.Background(), testSpaceId, v2model.CreateSetRequest{ + Name: "High chores", + Type: "chore", + Filter: `severity IN ("High") AND name CONTAINS "fix"`, + }, false, true) + + // then — the set document stores the structured array (SPEC §6.2.1: + // the document field `filter` stays reserved; export writes filters) + require.NoError(t, err) + assert.Equal(t, "newSet", result.Id) + snapshot := *captured + require.NotNil(t, snapshot) + dv := snapshot.Blocks[1].GetDataview() + require.NotNil(t, dv) + require.Len(t, dv.Views, 1) + filters := dv.Views[0].Filters + require.Len(t, filters, 2) + assert.Equal(t, "severity", filters[0].RelationKey) + assert.Equal(t, model.BlockContentDataviewFilter_In, filters[0].Condition) + assert.Equal(t, []string{"opt-high"}, pbtypes.GetStringListValue(filters[0].Value), + "the option NAME in the string resolves to the existing option id") + assert.Equal(t, "name", filters[1].RelationKey) + assert.Equal(t, model.BlockContentDataviewFilter_Like, filters[1].Condition) + }) + + t.Run("a filter-string parse error is offset-addressed with did-you-mean", func(t *testing.T) { + // given + fx := setup(t) + + // when — "sevirity" is a typo of the type's "severity" + _, err := fx.CreateSet(context.Background(), testSpaceId, v2model.CreateSetRequest{ + Name: "X", Type: "chore", Filter: `sevirity IN ("High")`, + }, false, true) + + // then + apiErr := v2Err(t, err) + assert.Equal(t, http.StatusBadRequest, apiErr.Status) + assert.Equal(t, v2model.CodeValidationFailed, apiErr.Code) + require.Len(t, apiErr.Issues, 1) + assert.Equal(t, "/filter", apiErr.Issues[0].Path) + assert.Contains(t, apiErr.Issues[0].Message, `parse error at offset 0 near "sevirity"`) + assert.Contains(t, apiErr.Issues[0].Message, `unknown property key "sevirity"`) + assert.Equal(t, "did you mean severity?", apiErr.Issues[0].Hint) + }) + + t.Run("system keys pass the sets reference set (rule 2)", func(t *testing.T) { + // given + fx := setup(t) + captured := fx.expectCreate("newSet") + fx.expectEtagRead("newSet") + + // when — lastModifiedDate is in no type's recommended lists + result, err := fx.CreateSet(context.Background(), testSpaceId, v2model.CreateSetRequest{ + Name: "Fresh chores", Type: "chore", + Filter: `lastModifiedDate > daysAgo(7)`, + }, false, true) + + // then + require.NoError(t, err) + assert.Equal(t, "newSet", result.Id) + dv := (*captured).Blocks[1].GetDataview() + require.Len(t, dv.Views[0].Filters, 1) + assert.Equal(t, "lastModifiedDate", dv.Views[0].Filters[0].RelationKey) + assert.Equal(t, model.BlockContentDataviewFilter_NumberOfDaysAgo, dv.Views[0].Filters[0].QuickOption) + }) + + t.Run("views with top-level filters are ambiguous_input", func(t *testing.T) { + // given + fx := setup(t) + + // when + _, err := fx.CreateSet(context.Background(), testSpaceId, v2model.CreateSetRequest{ + Name: "X", Type: "chore", + Views: json.RawMessage(`[{"name":"V"}]`), + Filters: json.RawMessage(`[{"property":"severity","condition":"not_empty"}]`), + }, false, true) + + // then + apiErr := v2Err(t, err) + assert.Equal(t, v2model.CodeAmbiguousInput, apiErr.Code) + }) + + t.Run("M6: the advertised sorts cap is enforced", func(t *testing.T) { + fx := setup(t) + sorts := make([]map[string]string, maxV2SetSorts+1) + for i := range sorts { + sorts[i] = map[string]string{"property": "severity"} + } + raw, err := json.Marshal(sorts) + require.NoError(t, err) + + _, err = fx.CreateSet(context.Background(), testSpaceId, + v2model.CreateSetRequest{Name: "Sorted", Type: "chore", Sorts: raw}, false, true) + + apiErr := v2Err(t, err) + assert.Equal(t, v2model.CodeValidationFailed, apiErr.Code) + require.NotEmpty(t, apiErr.Issues) + assert.Equal(t, "/sorts", apiErr.Issues[0].Path) + }) + + t.Run("dry run validates without creating", func(t *testing.T) { + // given: no creator expectations + fx := setup(t) + + // when + result, err := fx.CreateSet(context.Background(), testSpaceId, v2model.CreateSetRequest{ + Name: "Open chores", Type: "chore", + Filters: json.RawMessage(`[{"property":"severity","condition":"not_empty"}]`), + }, true, true) + + // then + require.NoError(t, err) + assert.True(t, result.DryRun) + assert.Empty(t, result.Id) + }) +} + +func TestV2CreateCollection(t *testing.T) { + t.Run("collection carries its items through the import path", func(t *testing.T) { + // given + fx := newV2Fixture(t) + fx.objectStore.AddObjects(t, testSpaceId, []objectstore.TestObject{{ + bundle.RelationKeyId: domain.String("member1"), + bundle.RelationKeyName: domain.String("Member"), + bundle.RelationKeyResolvedLayout: domain.Int64(int64(model.ObjectType_basic)), + }}) + captured := fx.expectCreate("newCollection") + fx.expectEtagRead("newCollection") + + // when + result, err := fx.CreateCollection(context.Background(), testSpaceId, + v2model.CreateCollectionRequest{Name: "Reading list", Items: []string{"member1"}}, false) + + // then + require.NoError(t, err) + assert.Equal(t, "newCollection", result.Id) + snapshot := *captured + require.NotNil(t, snapshot) + assert.Equal(t, []string{"ot-collection"}, snapshot.ObjectTypes) + require.NotNil(t, snapshot.Collections, "items land in the collection store") + objects := snapshot.Collections.Fields["objects"] + require.NotNil(t, objects) + assert.Equal(t, []string{"member1"}, pbtypes.GetStringListValue(objects)) + }) + + t.Run("unknown items are rejected with their index", func(t *testing.T) { + // given + fx := newV2Fixture(t) + + // when + _, err := fx.CreateCollection(context.Background(), testSpaceId, + v2model.CreateCollectionRequest{Name: "X", Items: []string{"ghost"}}, false) + + // then + apiErr := v2Err(t, err) + require.Len(t, apiErr.Issues, 1) + assert.Equal(t, "/items/0", apiErr.Issues[0].Path) + }) + + t.Run("M6: the items cap is enforced before any store lookup", func(t *testing.T) { + fx := newV2Fixture(t) + items := make([]string, maxV2CollectionItems+1) + for i := range items { + items[i] = fmt.Sprintf("obj%d", i) + } + + _, err := fx.CreateCollection(context.Background(), testSpaceId, + v2model.CreateCollectionRequest{Name: "Big", Items: items}, false) + + apiErr := v2Err(t, err) + assert.Equal(t, v2model.CodeValidationFailed, apiErr.Code) + require.NotEmpty(t, apiErr.Issues) + assert.Equal(t, "/items", apiErr.Issues[0].Path) + assert.NotContains(t, apiErr.Message, "not found", + "the cap must fire before the per-item existence walk") + }) + + t.Run("name is required", func(t *testing.T) { + // given + fx := newV2Fixture(t) + + // when + _, err := fx.CreateCollection(context.Background(), testSpaceId, v2model.CreateCollectionRequest{}, false) + + // then + apiErr := v2Err(t, err) + assert.Equal(t, v2model.CodeValidationFailed, apiErr.Code) + }) +} diff --git a/core/api/v2/service/list_read.go b/core/api/v2/service/list_read.go new file mode 100644 index 0000000000..60b245d3d8 --- /dev/null +++ b/core/api/v2/service/list_read.go @@ -0,0 +1,556 @@ +package v2service + +// list_read.go implements the Phase-4 sets/collections read path +// (APIV2.md §2 Phase 4): +// +// GET /v2/spaces/{space_id}/sets/{set_id}/objects?view=&fields= +// GET /v2/spaces/{space_id}/sets/{set_id}/views +// GET /v2/spaces/{space_id}/collections/{collection_id}/objects?view=&fields= +// GET /v2/spaces/{space_id}/collections/{collection_id}/views +// +// One implementation branches on layout exactly as v1's GetObjectsInList +// does — but a set addressed through the collections route (or vice versa) +// is a 400 naming the other route. Execution is the direct store-query path +// (database.Query over the set's source / the collection's store slice) — +// explicitly NOT v1's shared-subId ObjectSearchSubscribe hack, whose +// constant subId is racy under concurrent requests. Stored-view execution +// substitutes the SPEC §6.2 dynamic placeholders server-side; any +// placeholder that cannot resolve degrades to a C6 warning, never a silent +// no-match (v1's silent-empty-result bug). + +import ( + "context" + "encoding/json" + "fmt" + "sort" + "strings" + + "github.com/gogo/protobuf/types" + + apicore "github.com/anyproto/anytype-heart/core/api/core" + "github.com/anyproto/anytype-heart/core/api/pagination" + v2model "github.com/anyproto/anytype-heart/core/api/v2/model" + "github.com/anyproto/anytype-heart/core/domain" + "github.com/anyproto/anytype-heart/pkg/lib/anyblockjson" + "github.com/anyproto/anytype-heart/pkg/lib/anyblockjson/storeresolver" + "github.com/anyproto/anytype-heart/pkg/lib/bundle" + coresb "github.com/anyproto/anytype-heart/pkg/lib/core/smartblock" + "github.com/anyproto/anytype-heart/pkg/lib/database" + "github.com/anyproto/anytype-heart/pkg/lib/pb/model" +) + +// listKind names which route addressed the object. +type listKind int + +const ( + listKindSet listKind = iota + listKindCollection +) + +// storeSliceKey is the collection membership key in the object's store. +const storeSliceKey = "objects" + +// filterTemplateHost and filterTemplateUser are the SPEC §6.2 dynamic +// placeholders with defined substitutions: the object hosting the view and +// the current user. +const ( + filterTemplateHost = "_filter_template_1_" + filterTemplateUser = "_filter_template_2_" +) + +// listTarget is one resolved set/collection read target. +type listTarget struct { + read apicore.ObjectRead + dataview *model.BlockContentDataview // nil when the object has none +} + +// GetSetViews implements GET /v2/spaces/{space_id}/sets/{set_id}/views. +func (s *Service) GetSetViews(ctx context.Context, spaceId, setId string, offset, limit int) ([]json.RawMessage, int, bool, error) { + return s.listViews(ctx, spaceId, setId, listKindSet, offset, limit) +} + +// GetCollectionViews implements GET /v2/spaces/{space_id}/collections/{collection_id}/views. +func (s *Service) GetCollectionViews(ctx context.Context, spaceId, collectionId string, offset, limit int) ([]json.RawMessage, int, bool, error) { + return s.listViews(ctx, spaceId, collectionId, listKindCollection, offset, limit) +} + +// GetSetObjects implements GET /v2/spaces/{space_id}/sets/{set_id}/objects. +func (s *Service) GetSetObjects(ctx context.Context, spaceId, setId, viewRef string, fields []string, offset, limit int) ([]v2model.ObjectRow, int, bool, []v2model.Issue, error) { + return s.listObjects(ctx, spaceId, setId, listKindSet, viewRef, fields, offset, limit) +} + +// GetCollectionObjects implements GET /v2/spaces/{space_id}/collections/{collection_id}/objects. +func (s *Service) GetCollectionObjects(ctx context.Context, spaceId, collectionId, viewRef string, fields []string, offset, limit int) ([]v2model.ObjectRow, int, bool, []v2model.Issue, error) { + return s.listObjects(ctx, spaceId, collectionId, listKindCollection, viewRef, fields, offset, limit) +} + +// readListTarget reads the addressed object live and enforces the layout ↔ +// route contract: the sets route requires a set, the collections route a +// collection, and a wrong-layout target is a 400 naming the other route. +func (s *Service) readListTarget(ctx context.Context, spaceId, listId string, want listKind) (listTarget, error) { + if err := s.ensureSpace(ctx, spaceId); err != nil { + return listTarget{}, err + } + read, err := s.reader.ReadObject(ctx, spaceId, listId) + if err != nil { + return listTarget{}, mapReadError(spaceId, listId, err) + } + + layout := model.ObjectTypeLayout(-1) + if read.Snapshot != nil && read.Snapshot.Details != nil { + if v, ok := read.Snapshot.Details.Fields[bundle.RelationKeyResolvedLayout.String()]; ok { + layout = model.ObjectTypeLayout(v.GetNumberValue()) + } + } + isSet := layout == model.ObjectType_set + isCollection := layout == model.ObjectType_collection + switch { + case want == listKindSet && isCollection: + return listTarget{}, v2model.ValidationFailed( + fmt.Sprintf("object %q is a collection, not a set — use GET /v2/spaces/%s/collections/%s/objects", listId, spaceId, listId)) + case want == listKindCollection && isSet: + return listTarget{}, v2model.ValidationFailed( + fmt.Sprintf("object %q is a set, not a collection — use GET /v2/spaces/%s/sets/%s/objects", listId, spaceId, listId)) + case !isSet && !isCollection: + return listTarget{}, v2model.ValidationFailed( + fmt.Sprintf("object %q is neither a set nor a collection — sets read via /v2/spaces/{space_id}/sets/{set_id}/objects, collections via /v2/spaces/{space_id}/collections/{collection_id}/objects", listId)) + } + + target := listTarget{read: read} + // prefer the canonical "dataview" block id; fall back to the first + // dataview-content block (legacy objects) + var fallback *model.BlockContentDataview + for _, block := range read.Snapshot.Blocks { + dv := block.GetDataview() + if dv == nil { + continue + } + if block.Id == dataviewBlockId { + target.dataview = dv + break + } + if fallback == nil { + fallback = dv + } + } + if target.dataview == nil { + target.dataview = fallback + } + return target, nil +} + +// listViews returns the object's views as raw §6.2 view objects — the same +// shape the AnyBlock document carries them in (C2: one vocabulary), with +// option names resolved and object refs full. +func (s *Service) listViews(ctx context.Context, spaceId, listId string, want listKind, offset, limit int) ([]json.RawMessage, int, bool, error) { + target, err := s.readListTarget(ctx, spaceId, listId, want) + if err != nil { + return nil, 0, false, err + } + if target.dataview == nil { + page, hasMore := pagination.Paginate([]json.RawMessage{}, offset, limit) + return page, 0, hasMore, nil + } + + // render the dataview block through the format's own §6.2 serialization + // (no compaction: a fragment has no refs legend to resolve labels) + dvBlock := &model.Block{ + Id: dataviewBlockId, + Content: &model.BlockContentOfDataview{Dataview: target.dataview}, + } + raw, err := anyblockjson.MarshalBlockSubtree([]*model.Block{dvBlock}, storeresolver.New(s.store.SpaceIndex(spaceId)).Options()) + if err != nil { + return nil, 0, false, fmt.Errorf("marshal dataview of %s: %w", listId, err) + } + // a fragment is an ENVELOPE now, not a bare blocks array: it carries the + // §9a typed legends beside its blocks. This surface serves views only — + // each view's option values are already the names the legend would map, + // so the legends are read past rather than forwarded. + var fragment struct { + Blocks []struct { + Views []json.RawMessage `json:"views"` + } `json:"blocks"` + } + if err := json.Unmarshal(raw, &fragment); err != nil { + return nil, 0, false, fmt.Errorf("decode dataview of %s: %w", listId, err) + } + var views []json.RawMessage + if len(fragment.Blocks) > 0 { + views = fragment.Blocks[0].Views + } + total := len(views) + page, hasMore := pagination.Paginate(views, offset, limit) + return page, total, hasMore, nil +} + +// listObjects executes the set query / collection membership, optionally +// through one stored view's filters and sorts. +func (s *Service) listObjects(ctx context.Context, spaceId, listId string, want listKind, viewRef string, fields []string, offset, limit int) ([]v2model.ObjectRow, int, bool, []v2model.Issue, error) { + target, err := s.readListTarget(ctx, spaceId, listId, want) + if err != nil { + return nil, 0, false, nil, err + } + if err := s.validateListFields(spaceId, fields); err != nil { + return nil, 0, false, nil, err + } + + var ( + filters []database.FilterRequest + sorts []database.SortRequest + warnings []v2model.Issue + ) + if viewRef != "" { + view, err := resolveViewRef(target.dataview, viewRef, listId) + if err != nil { + return nil, 0, false, nil, err + } + viewFilters, viewWarnings := s.substitutePlaceholders(spaceId, listId, view.Filters) + warnings = viewWarnings + filters = append(filters, database.FiltersFromProto(viewFilters)...) + sorts = database.SortsFromProto(view.Sorts) + } + + var members []string // collection membership, in store-slice order + switch want { + case listKindSet: + sourceFilters, err := s.setSourceFilters(spaceId, listId, target.read) + if err != nil { + return nil, 0, false, nil, err + } + filters = append(filters, sourceFilters...) + case listKindCollection: + members = storeSlice(target.read.Snapshot) + if len(members) == 0 { + return []v2model.ObjectRow{}, 0, false, warnings, nil + } + filters = append(filters, database.FilterRequest{ + RelationKey: bundle.RelationKeyId, + Condition: model.BlockContentDataviewFilter_In, + Value: domain.StringList(members), + }) + } + + index := s.store.SpaceIndex(spaceId) + var ( + records []database.Record + total int + ) + if want == listKindCollection && len(sorts) == 0 { + // no stored sort: a collection reads in its curated store-slice + // order, so the matching members are fetched in full and reordered + all, err := index.Query(database.Query{Filters: filters}) + if err != nil { + return nil, 0, false, nil, fmt.Errorf("query collection %s: %w", listId, err) + } + orderByMembership(all, members) + total = len(all) + records = pageRecords(all, offset, limit) + } else { + if len(sorts) == 0 { + sorts = []database.SortRequest{{ + RelationKey: bundle.RelationKeyLastModifiedDate, + Type: model.BlockContentDataviewSort_Desc, + IncludeTime: true, + }} + } + records, total, err = index.QueryAndCount(database.Query{ + Filters: filters, + Sorts: sorts, + Offset: offset, + Limit: limit, + }) + if err != nil { + return nil, 0, false, nil, fmt.Errorf("query list %s: %w", listId, err) + } + } + + builder, err := s.newObjectRowBuilder(spaceId, fields) + if err != nil { + return nil, 0, false, nil, err + } + rows := make([]v2model.ObjectRow, 0, len(records)) + for _, record := range records { + rows = append(rows, builder.row(record)) + } + return rows, total, offset+len(records) < total, warnings, nil +} + +// validateListFields runs the search surface's rule-1 key check over the +// ?fields= query param: a typoed key must 400 with did-you-mean exactly like +// POST search's /fields/i does — a 200 whose rows silently carry no +// properties is indistinguishable from "no object has a value". The +// reference set is the space's property keys plus the system allowlist (a +// set/collection read has no top-level type to narrow by). +func (s *Service) validateListFields(spaceId string, fields []string) error { + if len(fields) == 0 { + return nil + } + // stored keys and served spellings both accepted (the row builder + // canonicalizes for the read — review cause 3); candidate lists speak + // the served spelling only + kc, err := s.newKeyCanon(spaceId) + if err != nil { + return err + } + stored := make([]string, 0, len(kc.entries)) + for _, entry := range kc.entries { + stored = append(stored, entry.Key) + } + acceptKeys := appendMissing(kc.withServedSpellings(sortedDistinct(stored)), "name", "type") + acceptKeys = appendMissing(acceptKeys, v2SystemQueryKeys...) + refKeys := appendMissing(kc.servedSpellings(sortedDistinct(stored)), "name", "type") + refKeys = appendMissing(refKeys, v2SystemQueryKeys...) + // the file aliases are valid ?fields= keys when active (per space — a + // real property claiming the spelling wins instead) + for alias := range kc.aliases { + acceptKeys = appendMissing(acceptKeys, alias) + refKeys = appendMissing(refKeys, alias) + } + sort.Strings(refKeys) + allowed := map[string]bool{} + for _, key := range acceptKeys { + allowed[key] = true + } + listUrl := fmt.Sprintf("list keys with GET /v2/spaces/%s/properties", spaceId) + var issues []v2model.Issue + for _, field := range fields { + if canonical, ambiguous := kc.canon(field); len(ambiguous) > 0 { + issues = append(issues, ambiguousInputIssue("property key", field, "fields", ambiguous)) + } else if !allowed[field] && !allowed[canonical] { + issues = append(issues, unknownPropertyIssue(field, "fields", refKeys, listUrl)) + } + } + if len(issues) > 0 { + return v2model.ValidationFailed("unknown property keys", issues...) + } + return nil +} + +// resolveViewRef picks a stored view by exact id or unique suffix (the C4 +// leniency block refs get). +func resolveViewRef(dv *model.BlockContentDataview, viewRef, listId string) (*model.BlockContentDataviewView, error) { + if dv == nil || len(dv.Views) == 0 { + return nil, v2model.NotFound(fmt.Sprintf("view %q not found — object %q has no views", viewRef, listId)) + } + ids := make([]string, len(dv.Views)) + for i, view := range dv.Views { + ids[i] = view.Id + } + idx, matches := matchBlockRef(ids, viewRef) + switch { + case matches == 1: + return dv.Views[idx], nil + case matches > 1: + return nil, v2model.AmbiguousInput( + fmt.Sprintf("view reference %q matches more than one view — use the full view id", viewRef), + v2model.Issue{Path: "view", Message: "the reference is a suffix of several view ids"}) + default: + return nil, v2model.NotFound( + fmt.Sprintf("view %q not found in object %q — view ids: %s", viewRef, listId, strings.Join(ids, ", "))) + } +} + +// setSourceFilters resolves the set's source (setOf) into store filters: +// object-type sources become `type In […]`, relation sources become +// `key NotEmpty`, OR-combined — the dataview resolution order, without v1's +// silent degradation to an unscoped query. +func (s *Service) setSourceFilters(spaceId, setId string, read apicore.ObjectRead) ([]database.FilterRequest, error) { + var sources []string + if read.Snapshot != nil && read.Snapshot.Details != nil { + if v, ok := read.Snapshot.Details.Fields[bundle.RelationKeySetOf.String()]; ok { + for _, entry := range v.GetListValue().GetValues() { + if id := entry.GetStringValue(); id != "" { + sources = append(sources, id) + } + } + } + } + if len(sources) == 0 { + return nil, v2model.ValidationFailed( + fmt.Sprintf("set %q queries nothing — its source (setOf) is empty", setId)) + } + + index := s.store.SpaceIndex(spaceId) + var typeIds []string + var relationKeys []string + for _, entry := range sources { + if uk, err := domain.UnmarshalUniqueKey(entry); err == nil { + switch uk.SmartblockType() { + case coresb.SmartBlockTypeObjectType: + if details, err := index.GetObjectByUniqueKey(uk); err == nil { + typeIds = append(typeIds, details.GetString(bundle.RelationKeyId)) + continue + } + case coresb.SmartBlockTypeRelation: + relationKeys = append(relationKeys, uk.InternalKey()) + continue + } + } + if _, err := index.GetObjectType(entry); err == nil { + typeIds = append(typeIds, entry) + continue + } + if relation, err := index.GetRelationById(entry); err == nil { + relationKeys = append(relationKeys, relation.Key) + continue + } + return nil, v2model.ValidationFailed( + fmt.Sprintf("set %q has an unresolvable source %q — setOf entries are type or property object ids", setId, entry)) + } + + var alternatives []database.FilterRequest + if len(typeIds) > 0 { + alternatives = append(alternatives, database.FilterRequest{ + RelationKey: bundle.RelationKeyType, + Condition: model.BlockContentDataviewFilter_In, + Value: domain.StringList(typeIds), + }) + } + for _, key := range relationKeys { + alternatives = append(alternatives, database.FilterRequest{ + RelationKey: domain.RelationKey(key), + Condition: model.BlockContentDataviewFilter_NotEmpty, + }) + } + if len(alternatives) == 1 { + return alternatives, nil + } + return []database.FilterRequest{{ + Operator: model.BlockContentDataviewFilter_Or, + NestedFilters: alternatives, + }}, nil +} + +// storeSlice reads the collection membership ids from the snapshot's store. +func storeSlice(snapshot *model.SmartBlockSnapshotBase) []string { + if snapshot == nil || snapshot.Collections == nil { + return nil + } + v, ok := snapshot.Collections.Fields[storeSliceKey] + if !ok { + return nil + } + var out []string + for _, entry := range v.GetListValue().GetValues() { + if id := entry.GetStringValue(); id != "" { + out = append(out, id) + } + } + return out +} + +// orderByMembership reorders records to the collection's store-slice order. +// The input is the collection's WHOLE matching membership (the honest total +// requires materializing it), not one page, so the sort is O(n log n) with +// exactly one details read per record — the store returns essentially a +// random permutation of the curated order, which made a comparison-time +// details-reading insertion sort O(n²) on every unpaginated read. +func orderByMembership(records []database.Record, members []string) { + position := make(map[string]int, len(members)) + for i, id := range members { + position[id] = i + } + keys := make([]int, len(records)) + for i, record := range records { + keys[i] = position[record.Details.GetString(bundle.RelationKeyId)] + } + sort.Sort(&byPrecomputedKey{records: records, keys: keys}) +} + +// byPrecomputedKey sorts records by a parallel precomputed key slice. +type byPrecomputedKey struct { + records []database.Record + keys []int +} + +func (b *byPrecomputedKey) Len() int { return len(b.records) } +func (b *byPrecomputedKey) Less(i, j int) bool { return b.keys[i] < b.keys[j] } +func (b *byPrecomputedKey) Swap(i, j int) { + b.records[i], b.records[j] = b.records[j], b.records[i] + b.keys[i], b.keys[j] = b.keys[j], b.keys[i] +} + +// substitutePlaceholders resolves the SPEC §6.2 dynamic placeholders in a +// stored view's filters before execution: `_filter_template_2_` → the +// caller's participant id, `_filter_template_1_` → the hosting object id. +// Any other placeholder — or the user placeholder when no account identity +// is wired — drops its leaf and degrades to a C6 warning: evaluated +// literally it would match nothing, v1's silent-empty-result bug. The +// snapshot is a per-read copy, so substitution never touches live state. +func (s *Service) substitutePlaceholders(spaceId, hostId string, filters []*model.BlockContentDataviewFilter) ([]*model.BlockContentDataviewFilter, []v2model.Issue) { + var warnings []v2model.Issue + substitute := func(value string) (string, bool) { + switch value { + case filterTemplateHost: + return hostId, true + case filterTemplateUser: + if s.accountId == "" { + warnings = append(warnings, v2model.Issue{ + Path: "view", + Message: fmt.Sprintf("the current-user placeholder %q could not be resolved — the filter carrying it was ignored", value), + }) + return "", false + } + return domain.NewParticipantId(spaceId, s.accountId), true + default: + if strings.HasPrefix(value, "_filter_template_") { + warnings = append(warnings, v2model.Issue{ + Path: "view", + Message: fmt.Sprintf("%q is an unresolvable placeholder — the filter carrying it was ignored", value), + }) + return "", false + } + return value, true + } + } + + var walk func(nodes []*model.BlockContentDataviewFilter) []*model.BlockContentDataviewFilter + walk = func(nodes []*model.BlockContentDataviewFilter) []*model.BlockContentDataviewFilter { + out := make([]*model.BlockContentDataviewFilter, 0, len(nodes)) + for _, node := range nodes { + if node == nil { + continue + } + if len(node.NestedFilters) > 0 { + node.NestedFilters = walk(node.NestedFilters) + if len(node.NestedFilters) == 0 { + continue // a group whose children all dropped is a no-op + } + out = append(out, node) + continue + } + if node.Value == nil { + out = append(out, node) + continue + } + keep := true + switch kind := node.Value.GetKind().(type) { + case *types.Value_StringValue: + resolved, ok := substitute(kind.StringValue) + if !ok { + keep = false + break + } + node.Value = &types.Value{Kind: &types.Value_StringValue{StringValue: resolved}} + case *types.Value_ListValue: + for i, entry := range kind.ListValue.Values { + value := entry.GetStringValue() + if value == "" { + continue + } + resolved, ok := substitute(value) + if !ok { + keep = false + break + } + kind.ListValue.Values[i] = &types.Value{Kind: &types.Value_StringValue{StringValue: resolved}} + } + } + if keep { + out = append(out, node) + } + } + return out + } + return walk(filters), warnings +} diff --git a/core/api/v2/service/list_read_test.go b/core/api/v2/service/list_read_test.go new file mode 100644 index 0000000000..fb752baada --- /dev/null +++ b/core/api/v2/service/list_read_test.go @@ -0,0 +1,427 @@ +package v2service + +import ( + "context" + "encoding/json" + "testing" + + "github.com/gogo/protobuf/types" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + + apicore "github.com/anyproto/anytype-heart/core/api/core" + v2model "github.com/anyproto/anytype-heart/core/api/v2/model" + "github.com/anyproto/anytype-heart/pkg/lib/bundle" + "github.com/anyproto/anytype-heart/pkg/lib/pb/model" + "github.com/anyproto/anytype-heart/util/pbtypes" +) + +// listReadRead builds one live ObjectRead for a set/collection target. +func listReadRead(layout model.ObjectTypeLayout, details map[string]*types.Value, dv *model.BlockContentDataview, members []string) apicore.ObjectRead { + fields := map[string]*types.Value{ + bundle.RelationKeyResolvedLayout.String(): pbtypes.Int64(int64(layout)), + } + for k, v := range details { + fields[k] = v + } + snapshot := &model.SmartBlockSnapshotBase{ + Details: &types.Struct{Fields: fields}, + } + if dv != nil { + snapshot.Blocks = []*model.Block{{ + Id: dataviewBlockId, + Content: &model.BlockContentOfDataview{Dataview: dv}, + }} + } + if members != nil { + snapshot.Collections = &types.Struct{Fields: map[string]*types.Value{ + storeSliceKey: pbtypes.StringList(members), + }} + } + return apicore.ObjectRead{Snapshot: snapshot, Heads: []string{"headL"}} +} + +func setRead(dv *model.BlockContentDataview) apicore.ObjectRead { + return listReadRead(model.ObjectType_set, map[string]*types.Value{ + bundle.RelationKeySetOf.String(): pbtypes.StringList([]string{"type-chore"}), + }, dv, nil) +} + +func collectionRead(dv *model.BlockContentDataview, members []string) apicore.ObjectRead { + return listReadRead(model.ObjectType_collection, nil, dv, members) +} + +func (fx *v2Fixture) expectListRead(objectId string, read apicore.ObjectRead) { + fx.readerMock.EXPECT().ReadObject(mock.Anything, testSpaceId, objectId).Return(read, nil) +} + +func TestV2GetSetObjects(t *testing.T) { + t.Run("a set executes its stored query directly against the store", func(t *testing.T) { + // given + fx := searchSetup(t) + fx.expectListRead("set1", setRead(nil)) + + // when + rows, total, hasMore, warnings, err := fx.GetSetObjects(context.Background(), testSpaceId, "set1", "", nil, 0, 25) + + // then: the two chores, newest-modified first; never the page + require.NoError(t, err) + assert.Equal(t, 2, total) + assert.False(t, hasMore) + assert.Empty(t, warnings) + assert.Equal(t, []string{"chore2", "chore1"}, rowIds(rows)) + }) + + t.Run("?view= applies the stored view's filters and sorts", func(t *testing.T) { + // given: a view filtering to severity=opt-high (the stored id form) + fx := searchSetup(t) + dv := &model.BlockContentDataview{Views: []*model.BlockContentDataviewView{{ + Id: "view1abc", + Filters: []*model.BlockContentDataviewFilter{{ + RelationKey: "severity", + Condition: model.BlockContentDataviewFilter_In, + Value: pbtypes.StringList([]string{"opt-high"}), + }}, + }}} + fx.expectListRead("set1", setRead(dv)) + + // when: the view resolves by unique suffix (C4 leniency) + rows, total, _, _, err := fx.GetSetObjects(context.Background(), testSpaceId, "set1", "1abc", nil, 0, 25) + + // then + require.NoError(t, err) + assert.Equal(t, 1, total) + require.Len(t, rows, 1) + assert.Equal(t, "chore1", rows[0].Id) + }) + + t.Run("stored-view execution substitutes the current-user placeholder", func(t *testing.T) { + // given: the fixture's chore1 is created by the caller, chore2 by + // someone else (addChoreObjects) + fx := searchSetup(t) + dv := &model.BlockContentDataview{Views: []*model.BlockContentDataviewView{{ + Id: "v1", + Filters: []*model.BlockContentDataviewFilter{{ + RelationKey: bundle.RelationKeyCreator.String(), + Condition: model.BlockContentDataviewFilter_Equal, + Value: pbtypes.String(filterTemplateUser), + }}, + }}} + fx.expectListRead("set1", setRead(dv)) + + // when + rows, _, _, warnings, err := fx.GetSetObjects(context.Background(), testSpaceId, "set1", "v1", nil, 0, 25) + + // then: the literal placeholder would match nothing — substitution + // resolves it to the caller's participant id + require.NoError(t, err) + assert.Empty(t, warnings) + assert.Equal(t, []string{"chore1"}, rowIds(rows)) + }) + + t.Run("an unresolvable placeholder degrades to a warning, never a silent no-match", func(t *testing.T) { + // given + fx := searchSetup(t) + dv := &model.BlockContentDataview{Views: []*model.BlockContentDataviewView{{ + Id: "v1", + Filters: []*model.BlockContentDataviewFilter{{ + RelationKey: bundle.RelationKeyCreator.String(), + Condition: model.BlockContentDataviewFilter_Equal, + Value: pbtypes.String("_filter_template_9_"), + }}, + }}} + fx.expectListRead("set1", setRead(dv)) + + // when + rows, _, _, warnings, err := fx.GetSetObjects(context.Background(), testSpaceId, "set1", "v1", nil, 0, 25) + + // then: the filter is dropped (both chores return) and the response warns + require.NoError(t, err) + assert.ElementsMatch(t, []string{"chore1", "chore2"}, rowIds(rows)) + require.Len(t, warnings, 1) + assert.Contains(t, warnings[0].Message, `"_filter_template_9_" is an unresolvable placeholder`) + assert.Contains(t, warnings[0].Message, "ignored") + }) + + t.Run("unknown view is a 404 listing the view ids", func(t *testing.T) { + // given + fx := searchSetup(t) + dv := &model.BlockContentDataview{Views: []*model.BlockContentDataviewView{{Id: "v1"}, {Id: "v2"}}} + fx.expectListRead("set1", setRead(dv)) + + // when + _, _, _, _, err := fx.GetSetObjects(context.Background(), testSpaceId, "set1", "ghost", nil, 0, 25) + + // then + apiErr := v2Err(t, err) + assert.Equal(t, v2model.CodeNotFound, apiErr.Code) + assert.Contains(t, apiErr.Message, `view "ghost" not found`) + assert.Contains(t, apiErr.Message, "v1, v2") + }) + + t.Run("a collection addressed through the sets route names the other route", func(t *testing.T) { + // given + fx := searchSetup(t) + fx.expectListRead("col1", collectionRead(nil, []string{"chore1"})) + + // when + _, _, _, _, err := fx.GetSetObjects(context.Background(), testSpaceId, "col1", "", nil, 0, 25) + + // then + apiErr := v2Err(t, err) + assert.Equal(t, v2model.CodeValidationFailed, apiErr.Code) + assert.Contains(t, apiErr.Message, `object "col1" is a collection, not a set`) + assert.Contains(t, apiErr.Message, "GET /v2/spaces/space1/collections/col1/objects") + }) + + t.Run("a set over a file type returns its rows and renders the file fields", func(t *testing.T) { + // given: §8.8 claims the sets read never had the layout scope (so a + // file set already worked) — previously verified only by reading + // listObjects; this pins it, together with the mimeType/size alias + // rendering on the ?fields= channel + fx := searchSetup(t) + fx.addImageObjects(t) + fx.expectListRead("set1", listReadRead(model.ObjectType_set, map[string]*types.Value{ + bundle.RelationKeySetOf.String(): pbtypes.StringList([]string{"type-image"}), + }, nil, nil)) + + // when + rows, total, _, _, err := fx.GetSetObjects(context.Background(), testSpaceId, "set1", "", + []string{"mimeType", "size"}, 0, 25) + + // then + require.NoError(t, err) + assert.Equal(t, 1, total) + require.Len(t, rows, 1) + assert.Equal(t, "img1", rows[0].Id) + require.NotNil(t, rows[0].Properties) + assert.Equal(t, "image/png", rows[0].Properties["mimeType"]) + assert.EqualValues(t, 12345, rows[0].Properties["size"]) + }) + + t.Run("an empty setOf is an explicit error, not an unscoped query", func(t *testing.T) { + // given + fx := searchSetup(t) + fx.expectListRead("set1", listReadRead(model.ObjectType_set, nil, nil, nil)) + + // when + _, _, _, _, err := fx.GetSetObjects(context.Background(), testSpaceId, "set1", "", nil, 0, 25) + + // then + apiErr := v2Err(t, err) + assert.Contains(t, apiErr.Message, "queries nothing") + }) + + t.Run("a typoed fields key 400s with did-you-mean, like search does", func(t *testing.T) { + // given: without the check the response is a 200 whose rows silently + // carry no properties — indistinguishable from "no object has a value" + fx := searchSetup(t) + fx.expectListRead("set1", setRead(nil)) + + // when + _, _, _, _, err := fx.GetSetObjects(context.Background(), testSpaceId, "set1", "", []string{"sevirity"}, 0, 25) + + // then + apiErr := v2Err(t, err) + require.Len(t, apiErr.Issues, 1) + assert.Equal(t, "fields", apiErr.Issues[0].Path) + assert.Contains(t, apiErr.Issues[0].Message, `unknown property key "sevirity"`) + assert.Equal(t, "did you mean severity?", apiErr.Issues[0].Hint) + }) +} + +func TestV2GetCollectionObjects(t *testing.T) { + t.Run("a collection reads in its curated store-slice order", func(t *testing.T) { + // given: membership order chore2 before chore1, plus a ghost id + fx := searchSetup(t) + fx.expectListRead("col1", collectionRead(nil, []string{"chore2", "chore1", "ghost"})) + + // when + rows, total, hasMore, _, err := fx.GetCollectionObjects(context.Background(), testSpaceId, "col1", "", nil, 0, 25) + + // then: store order preserved; the dangling member is not a row + require.NoError(t, err) + assert.Equal(t, 2, total) + assert.False(t, hasMore) + assert.Equal(t, []string{"chore2", "chore1"}, rowIds(rows)) + }) + + t.Run("an empty collection is an empty page, not an error", func(t *testing.T) { + // given + fx := searchSetup(t) + fx.expectListRead("col1", collectionRead(nil, nil)) + + // when + rows, total, hasMore, _, err := fx.GetCollectionObjects(context.Background(), testSpaceId, "col1", "", nil, 0, 25) + + // then + require.NoError(t, err) + assert.Empty(t, rows) + assert.Equal(t, 0, total) + assert.False(t, hasMore) + }) + + t.Run("a set addressed through the collections route names the other route", func(t *testing.T) { + // given + fx := searchSetup(t) + fx.expectListRead("set1", setRead(nil)) + + // when + _, _, _, _, err := fx.GetCollectionObjects(context.Background(), testSpaceId, "set1", "", nil, 0, 25) + + // then + apiErr := v2Err(t, err) + assert.Contains(t, apiErr.Message, `object "set1" is a set, not a collection`) + assert.Contains(t, apiErr.Message, "GET /v2/spaces/space1/sets/set1/objects") + }) + + t.Run("a plain object is neither — the error names both routes", func(t *testing.T) { + // given + fx := searchSetup(t) + fx.expectListRead("page1", listReadRead(model.ObjectType_basic, nil, nil, nil)) + + // when + _, _, _, _, err := fx.GetCollectionObjects(context.Background(), testSpaceId, "page1", "", nil, 0, 25) + + // then + apiErr := v2Err(t, err) + assert.Contains(t, apiErr.Message, "neither a set nor a collection") + assert.Contains(t, apiErr.Message, "/sets/{set_id}/objects") + assert.Contains(t, apiErr.Message, "/collections/{collection_id}/objects") + }) + + t.Run("an offset past the membership is an empty page, has_more false", func(t *testing.T) { + // given + fx := searchSetup(t) + fx.expectListRead("col1", collectionRead(nil, []string{"chore2", "chore1"})) + + // when + rows, total, hasMore, _, err := fx.GetCollectionObjects(context.Background(), testSpaceId, "col1", "", nil, 5, 25) + + // then + require.NoError(t, err) + assert.Empty(t, rows) + assert.Equal(t, 2, total) + assert.False(t, hasMore) + }) + + t.Run("a stored view's sorts override the membership order", func(t *testing.T) { + // given + fx := searchSetup(t) + dv := &model.BlockContentDataview{Views: []*model.BlockContentDataviewView{{ + Id: "v1", + Sorts: []*model.BlockContentDataviewSort{{ + RelationKey: bundle.RelationKeyLastModifiedDate.String(), + Type: model.BlockContentDataviewSort_Asc, + IncludeTime: true, + }}, + }}} + fx.expectListRead("col1", collectionRead(dv, []string{"chore2", "chore1"})) + + // when + rows, _, _, _, err := fx.GetCollectionObjects(context.Background(), testSpaceId, "col1", "v1", nil, 0, 25) + + // then: oldest-modified first per the view, not membership order + require.NoError(t, err) + assert.Equal(t, []string{"chore1", "chore2"}, rowIds(rows)) + }) +} + +func TestV2ListViews(t *testing.T) { + t.Run("views render as §6.2 view objects with option names", func(t *testing.T) { + // given + fx := searchSetup(t) + dv := &model.BlockContentDataview{ + RelationLinks: []*model.RelationLink{{Key: "severity", Format: model.RelationFormat_status}}, + Views: []*model.BlockContentDataviewView{{ + Id: "v1", + Name: "High only", + Type: model.BlockContentDataviewView_Kanban, + Filters: []*model.BlockContentDataviewFilter{{ + RelationKey: "severity", + Condition: model.BlockContentDataviewFilter_In, + Value: pbtypes.StringList([]string{"opt-high"}), + }}, + Sorts: []*model.BlockContentDataviewSort{{ + RelationKey: "severity", + Type: model.BlockContentDataviewSort_Desc, + }}, + }}, + } + fx.expectListRead("set1", setRead(dv)) + + // when + views, total, hasMore, err := fx.GetSetViews(context.Background(), testSpaceId, "set1", 0, 25) + + // then + require.NoError(t, err) + assert.Equal(t, 1, total) + assert.False(t, hasMore) + require.Len(t, views, 1) + var view map[string]any + require.NoError(t, json.Unmarshal(views[0], &view)) + assert.Equal(t, "v1", view["id"]) + assert.Equal(t, "High only", view["name"]) + assert.Equal(t, "kanban", view["type"], "the §6.2 lowerCamel view-type vocabulary") + filters, _ := view["filters"].([]any) + require.Len(t, filters, 1) + leaf, _ := filters[0].(map[string]any) + assert.Equal(t, "severity", leaf["property"]) + assert.Equal(t, "in", leaf["condition"]) + assert.Equal(t, []any{"High"}, leaf["value"], "option ids render as NAMES (C2)") + }) + + t.Run("an object without a dataview has zero views", func(t *testing.T) { + // given + fx := searchSetup(t) + fx.expectListRead("col1", collectionRead(nil, []string{"chore1"})) + + // when + views, total, hasMore, err := fx.GetCollectionViews(context.Background(), testSpaceId, "col1", 0, 25) + + // then + require.NoError(t, err) + assert.Empty(t, views) + assert.Equal(t, 0, total) + assert.False(t, hasMore) + }) +} + +func TestV2SubstitutePlaceholders(t *testing.T) { + placeholderFilter := func(value string) []*model.BlockContentDataviewFilter { + return []*model.BlockContentDataviewFilter{{ + RelationKey: bundle.RelationKeyCreator.String(), + Condition: model.BlockContentDataviewFilter_Equal, + Value: pbtypes.String(value), + }} + } + + t.Run("the host placeholder resolves to the hosting object id", func(t *testing.T) { + // given + fx := newV2Fixture(t) + + // when + out, warnings := fx.substitutePlaceholders(testSpaceId, "set1", placeholderFilter(filterTemplateHost)) + + // then: resolved, not dropped-with-warning (the default placeholder arm) + require.Empty(t, warnings) + require.Len(t, out, 1) + assert.Equal(t, "set1", out[0].Value.GetStringValue()) + }) + + t.Run("an empty account identity degrades the user placeholder to a warning", func(t *testing.T) { + // given: a service wired without an account identity + fx := newV2Fixture(t) + fx.Service.accountId = "" + + // when + out, warnings := fx.substitutePlaceholders(testSpaceId, "set1", placeholderFilter(filterTemplateUser)) + + // then: the leaf drops (evaluated literally it would match nothing) + assert.Empty(t, out) + require.Len(t, warnings, 1) + assert.Contains(t, warnings[0].Message, `the current-user placeholder "_filter_template_2_" could not be resolved`) + assert.Contains(t, warnings[0].Message, "ignored") + }) +} diff --git a/core/api/v2/service/locator.go b/core/api/v2/service/locator.go new file mode 100644 index 0000000000..674de075be --- /dev/null +++ b/core/api/v2/service/locator.go @@ -0,0 +1,149 @@ +package v2service + +// locator.go resolves a PATCH op's block from CONTENT instead of an id — +// Wave 2.1a/2.1b (APIV2_TOKENS.md §5, APIV2.md §8.43, §8.45): on +// replace_text the find text doubles as the locator when id is omitted; on +// update_block and delete_block the same job is done by `match`, an exact +// substring of the block's text. The resolution rule is the shipped +// wrapper rule (§8.21 locateBlock), moved down a layer and run per-op +// against the applier's live document view under the object lock: the +// wrapper's version was a read-then-patch TOCTOU — GET, resolve +// client-side, PATCH by id, with the document free to move in between — +// and in-API resolution is what removes the race (§5.5). +// +// The load-bearing rule (§5.3): the text must identify exactly ONE block, +// or the op refuses — zero matches steer to the outline read, several +// matching blocks list ≤8 candidates with context. Never a guess: a +// silent wrong match is the failure this design exists to prevent, and on +// delete_block it is a wrongly deleted subtree. ONE resolver serves every +// op, so there is never a second rule or a second refusal vocabulary; +// what an op contributes is its scope (which blocks it can act on at all) +// and the name of the field that carried the text. Later slices (2.1c–d) +// grow this file with the `under`/`nth` scoping vocabulary and the +// move_block/insert_blocks anchors. +// +// Multiplicity WITHIN the one matched block is not this function's +// business: it identifies a block, and every op here acts on the block as +// a whole. replace_text alone splices text and therefore keeps its own +// more-context refusal (stateops.go applyReplaceText) once the block is +// resolved — that is replace_all's (and later nth's) territory, not a +// resolution failure. + +import ( + "fmt" + "strings" + "unicode/utf8" + + v2model "github.com/anyproto/anytype-heart/core/api/v2/model" + "github.com/anyproto/anytype-heart/pkg/lib/anyblockjson" +) + +// maxLocatorCandidates bounds how many candidate blocks an ambiguity +// refusal lists — the wrapper's measured refusal shape (§8.21: 54 tokens, +// repaired first-try). +const maxLocatorCandidates = 8 + +// locatorContextWindow is how much surrounding text a candidate carries on +// each side of the matched text. +const locatorContextWindow = 30 + +// locatorScope reports whether a block of this type is one the op could act +// on — the candidate set the locator scans. It is a per-OP fact, not a +// second resolution rule, and it exists because narrowing the scan is only +// safe where the excluded blocks could never be the intent: an excluded +// block cannot capture the match, but it also cannot make a wrong match +// AMBIGUOUS, which is the direction that hurts on a destructive op. +type locatorScope func(blockType string) bool + +// textBlocksOnly is replace_text's scope: only text-bearing blocks (code and +// embed included, §8.4). replace_text can edit nothing else, so a block it +// would refuse must neither capture the match nor make a unique one +// ambiguous. +func textBlocksOnly(typ string) bool { return anyblockjson.TextBlockType(typ) } + +// everyBlock is update_block's and delete_block's scope: those ops address any +// block, so nothing may be filtered out of their candidate set. Today the +// two scopes coincide — the exporter writes `text` only on the types +// TextBlockType covers — so this is not an observable difference but the +// record of WHY each op has the scope it has: were a non-text block ever to +// carry text, filtering it out of a delete_block's candidates would turn a +// two-block ambiguity into a silent, wrong, destructive match. +func everyBlock(string) bool { return true } + +// resolveByText maps a locator's text to the ONE view block whose text +// contains it. field is the op field that carried it ("find" on +// replace_text, "match" on update_block/delete_block) — it appears in the +// refusals so the repair names the caller's own vocabulary. The doc is the +// applier's LIVE view — mid-batch, op i scans the document op i−1 left, +// whether that op maintained the view in place (replace_text, M7) or forced +// a rebuild. +func resolveByText(doc *v2EditDoc, text, field, path string, scope locatorScope) (int, error) { + var matches []int + for i, b := range doc.blocks { + if !scope(blockType(b)) { + continue + } + if blockText, _ := b["text"].(string); blockText != "" && strings.Contains(blockText, text) { + matches = append(matches, i) + } + } + switch len(matches) { + case 1: + return matches[0], nil + case 0: + // 404-class (C6): the steer is the outline read, and the exact-copy + // tip is the one the id path already ships — the snippet may have + // missed only because text is markup source + return -1, v2model.NotFound( + fmt.Sprintf("no block contains %q — copy the %s text exactly, including inline markup (text is markdown source: ** [ ] etc. count)", text, field), + v2model.Issue{ + Path: path, + Message: fmt.Sprintf("the %s text must appear in exactly one block for the locator to resolve", field), + Hint: "GET the object with ?outline=true to list them, then copy the text exactly as a read serves it — or give the block id", + }) + default: + var b strings.Builder + fmt.Fprintf(&b, "%q appears in %d blocks — retry with id naming one of:", text, len(matches)) + for k, i := range matches { + if k == maxLocatorCandidates { + fmt.Fprintf(&b, "\n … and %d more", len(matches)-maxLocatorCandidates) + break + } + blk := doc.blocks[i] + blockText, _ := blk["text"].(string) + fmt.Fprintf(&b, "\n block %s (%s): %q", blockId(blk), blockType(blk), locatorContext(blockText, text)) + } + return -1, v2model.AmbiguousInput(b.String(), + v2model.Issue{ + Path: path, + Message: fmt.Sprintf("the %s text appears in %d blocks — a locator must identify exactly one", field, len(matches)), + Hint: fmt.Sprintf("add surrounding text to %s until it appears in one block only, or give the block id", field), + }) + } +} + +// locatorContext excerpts ~locatorContextWindow bytes around the locator +// text's first occurrence — enough context to tell candidate blocks apart +// without dumping whole blocks into the refusal. (The wrapper's +// snippetContext, moved down with the resolution it serves.) +func locatorContext(text, needle string) string { + idx := strings.Index(text, needle) + start := idx - locatorContextWindow + prefix := "…" + if start <= 0 { + start, prefix = 0, "" + } + end := idx + len(needle) + locatorContextWindow + suffix := "…" + if end >= len(text) { + end, suffix = len(text), "" + } + // never slice mid-rune: move both cuts forward to rune boundaries + for start < len(text) && !utf8.RuneStart(text[start]) { + start++ + } + for end < len(text) && !utf8.RuneStart(text[end]) { + end++ + } + return prefix + text[start:end] + suffix +} diff --git a/core/api/v2/service/locator_test.go b/core/api/v2/service/locator_test.go new file mode 100644 index 0000000000..552a8d0fef --- /dev/null +++ b/core/api/v2/service/locator_test.go @@ -0,0 +1,158 @@ +package v2service + +// locator_test.go — unit pins for resolveByText (Wave 2.1a/2.1b, §8.43, +// §8.45) that the service-level tests in edit_test.go cannot reach: the +// candidate cap (documents with >8 matching blocks) and the per-op scope +// (the real format never puts `text` on a non-text block, so the scopes are +// unobservable through PatchObject — they are the invariants that a block +// replace_text cannot edit never captures or contaminates ITS match, while +// nothing is ever filtered out of a destructive op's candidate set). +// locatorContext's windowing tests moved here from the wrapper with the +// function itself. + +import ( + "fmt" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + v2model "github.com/anyproto/anytype-heart/core/api/v2/model" +) + +// findDoc builds a v2EditDoc straight from block objects. +func findDoc(t *testing.T, blocks ...string) *v2EditDoc { + t.Helper() + doc, err := parseEditDoc([]byte(`{"blocks":[` + strings.Join(blocks, ",") + `]}`)) + require.NoError(t, err) + return doc +} + +func TestResolveByText(t *testing.T) { + t.Run("the candidate list caps at 8 and counts the rest", func(t *testing.T) { + blocks := make([]string, 10) + for i := range blocks { + blocks[i] = fmt.Sprintf(`{"id":"blk%02d","type":"paragraph","text":"needle %d"}`, i, i) + } + doc := findDoc(t, blocks...) + + _, err := resolveByText(doc, "needle", "find", "ops[0].find", textBlocksOnly) + + apiErr := v2Err(t, err) + assert.Equal(t, v2model.CodeAmbiguousInput, apiErr.Code) + assert.Contains(t, apiErr.Message, `"needle" appears in 10 blocks`) + assert.Contains(t, apiErr.Message, "block blk07 (paragraph)", "the 8th candidate is listed") + assert.NotContains(t, apiErr.Message, "blk08", "the 9th is not") + assert.Contains(t, apiErr.Message, "… and 2 more") + }) + + t.Run("a non-text block never captures replace_text's match", func(t *testing.T) { + // were a non-text block to carry the snippet, resolving to it would + // produce an op the applier must then refuse — so it must not count + doc := findDoc(t, + `{"id":"bmOne1","type":"bookmark","text":"needle"}`, + `{"id":"parOne1","type":"paragraph","text":"needle"}`) + + idx, err := resolveByText(doc, "needle", "find", "ops[0].find", textBlocksOnly) + + require.NoError(t, err, "the paragraph is the unique TEXT match — no ambiguity") + assert.Equal(t, 1, idx) + }) + + t.Run("a snippet found only in a non-text block is zero matches for replace_text", func(t *testing.T) { + doc := findDoc(t, + `{"id":"bmOne1","type":"bookmark","text":"needle"}`, + `{"id":"parOne1","type":"paragraph","text":"other"}`) + + _, err := resolveByText(doc, "needle", "find", "ops[0].find", textBlocksOnly) + + apiErr := v2Err(t, err) + assert.Equal(t, v2model.CodeNotFound, apiErr.Code) + }) + + t.Run("code and embed text participates (§8.4 literal channels)", func(t *testing.T) { + doc := findDoc(t, + `{"id":"parOne1","type":"paragraph","text":"prose"}`, + `{"id":"codeOne1","type":"code","text":"var needle int"}`) + + idx, err := resolveByText(doc, "needle", "find", "ops[0].find", textBlocksOnly) + + require.NoError(t, err) + assert.Equal(t, 1, idx) + }) + + // ---- the everyBlock scope (2.1b): nothing is filtered out of a + // destructive op's candidate set ---- + + t.Run("a non-text block IS a candidate for match, so two candidates refuse", func(t *testing.T) { + // the same document replace_text resolves uniquely above. delete_block + // can delete a bookmark, so filtering it out would answer a genuinely + // two-block question with one silent, wrong, destructive match. The + // exporter never writes `text` on a bookmark today — this fixture is + // synthetic on purpose: it is the only way to state which scope each + // op has before a format change makes it observable. + doc := findDoc(t, + `{"id":"bmOne1","type":"bookmark","text":"needle"}`, + `{"id":"parOne1","type":"paragraph","text":"needle"}`) + + _, err := resolveByText(doc, "needle", "match", "ops[0].match", everyBlock) + + apiErr := v2Err(t, err) + assert.Equal(t, v2model.CodeAmbiguousInput, apiErr.Code) + assert.Contains(t, apiErr.Message, "block bmOne1 (bookmark)") + assert.Contains(t, apiErr.Message, "block parOne1 (paragraph)") + }) + + t.Run("the refusals name the field that carried the text", func(t *testing.T) { + // the repair has to speak the caller's own vocabulary: an update_block + // told to "add surrounding text to find" is told to edit a field it + // does not have + doc := findDoc(t, `{"id":"parOne1","type":"paragraph","text":"other"}`) + + _, err := resolveByText(doc, "needle", "match", "ops[0].match", everyBlock) + + apiErr := v2Err(t, err) + assert.Contains(t, apiErr.Message, "copy the match text exactly") + require.Len(t, apiErr.Issues, 1) + assert.Contains(t, apiErr.Issues[0].Message, "the match text must appear in exactly one block") + assert.NotContains(t, apiErr.Message, "find") + }) + + t.Run("repeats within the one block still resolve it", func(t *testing.T) { + // within-block multiplicity is not a resolution failure: this + // function identifies a BLOCK, and update_block/delete_block act on the + // block as a whole. Only replace_text, which has to splice one + // occurrence, refuses on the count (applyReplaceText) + doc := findDoc(t, + `{"id":"parOne1","type":"paragraph","text":"the Q3 report and the Q3 plan"}`, + `{"id":"parTwo2","type":"paragraph","text":"unrelated"}`) + + idx, err := resolveByText(doc, "Q3", "match", "ops[0].match", everyBlock) + + require.NoError(t, err) + assert.Equal(t, 0, idx) + }) +} + +func TestLocatorContext(t *testing.T) { + t.Run("short text passes whole", func(t *testing.T) { + assert.Equal(t, "the Q3 budget", locatorContext("the Q3 budget", "Q3")) + }) + t.Run("long text windows with ellipses", func(t *testing.T) { + text := strings.Repeat("a", 100) + " needle " + strings.Repeat("b", 100) + got := locatorContext(text, "needle") + assert.True(t, strings.HasPrefix(got, "…")) + assert.True(t, strings.HasSuffix(got, "…")) + assert.Contains(t, got, "needle") + assert.Less(t, len(got), 80) + }) + t.Run("never slices mid-rune", func(t *testing.T) { + text := strings.Repeat("é", 40) + "needle" + strings.Repeat("Ω", 40) + got := locatorContext(text, "needle") + assert.True(t, strings.HasPrefix(got, "…")) + for _, r := range got { + assert.NotEqual(t, '�', r, "excerpt must stay valid UTF-8") + } + }) +} diff --git a/core/api/v2/service/mint_test.go b/core/api/v2/service/mint_test.go new file mode 100644 index 0000000000..f20841a821 --- /dev/null +++ b/core/api/v2/service/mint_test.go @@ -0,0 +1,523 @@ +package v2service + +import ( + "context" + "testing" + + "github.com/gogo/protobuf/types" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + + v2model "github.com/anyproto/anytype-heart/core/api/v2/model" + "github.com/anyproto/anytype-heart/core/domain" + "github.com/anyproto/anytype-heart/pb" + "github.com/anyproto/anytype-heart/pkg/lib/bundle" + "github.com/anyproto/anytype-heart/pkg/lib/localstore/objectstore" + "github.com/anyproto/anytype-heart/pkg/lib/pb/model" + "github.com/anyproto/anytype-heart/util/pbtypes" +) + +// The (a) identity layer (ADDRESSING §7.5) and its union collision check +// (§7.5a-6, §7.6-3): every v2 create mints a BSON internal key, the caller's +// key lives only in the apiObjectKey slug, and the slug is checked at mint +// against bundled keys, bundled-derived slugs, live stored keys and live +// stored slugs — with corpses vacated (§8-OQ2), which is what makes +// delete-then-recreate mint cleanly. + +func v2ErrWithIssue(t *testing.T, err error) *v2model.Error { + t.Helper() + var apiErr *v2model.Error + require.ErrorAs(t, err, &apiErr) + return apiErr +} + +func TestV2PropertyMintCollisionCheck(t *testing.T) { + t.Run("a caller key cannot shadow a bundled slug", func(t *testing.T) { + // given: "due_date" is bundled dueDate's derived slug. Before the + // union check, propertyKeyExists("due_date") missed (the store has + // dueDate, the bundle has dueDate) and the create pinned due_date as + // a stored relation key — shadowing the bundled slug forever. + fx := newV2Fixture(t) + + // when + _, err := fx.CreateProperty(context.Background(), testSpaceId, + v2model.CreatePropertyRequest{Key: "due_date", Name: "My due date", Format: "date"}, false) + + // then: loud refusal naming the bundled holder — no create RPC + apiErr := v2ErrWithIssue(t, err) + assert.Equal(t, v2model.CodeValidationFailed, apiErr.Code) + require.NotEmpty(t, apiErr.Issues) + assert.Contains(t, apiErr.Issues[0].Message, "bundled property") + }) + + t.Run("normalization makes camel and snake collide", func(t *testing.T) { + // dueDate2 and due_date2 are one slug after snake-at-mint — the + // §7.5a-6 example: the sequential second create is refused. (The + // joint spelling is due_date_2: strcase separates trailing digits, + // which both spellings normalize into.) + fx := newV2Fixture(t) + fx.addRelation(t, testSpaceId, objectstore.TestObject{ + bundle.RelationKeyId: domain.String("rel-dd2"), + bundle.RelationKeyRelationKey: domain.String("6a7663db61fab21cd4b9e001"), + bundle.RelationKeyApiObjectKey: domain.String("due_date_2"), + bundle.RelationKeyName: domain.String("Due date 2"), + }) + + _, err := fx.CreateProperty(context.Background(), testSpaceId, + v2model.CreatePropertyRequest{Key: "dueDate2", Name: "Another", Format: "date"}, false) + + apiErr := v2ErrWithIssue(t, err) + require.NotEmpty(t, apiErr.Issues) + assert.Contains(t, apiErr.Issues[0].Message, `"due_date_2"`) + + // the snake spelling of the same key is the same refusal + _, err = fx.CreateProperty(context.Background(), testSpaceId, + v2model.CreatePropertyRequest{Key: "due_date2", Name: "Another", Format: "date"}, false) + v2ErrWithIssue(t, err) + }) + + t.Run("a name-derived slug is guarded too", func(t *testing.T) { + // no key given: the slug derives from the name and the union check + // still runs — a property NAMED "Due date" steers to bundled + // due_date instead of silently minting a shadowing slug + fx := newV2Fixture(t) + + _, err := fx.CreateProperty(context.Background(), testSpaceId, + v2model.CreatePropertyRequest{Name: "Due date", Format: "date"}, false) + + apiErr := v2ErrWithIssue(t, err) + require.NotEmpty(t, apiErr.Issues) + assert.Equal(t, "/name", apiErr.Issues[0].Path) + assert.Contains(t, apiErr.Issues[0].Hint, "due_date") + }) + + t.Run("corpses vacate the namespace: delete-then-recreate mints cleanly", func(t *testing.T) { + // given: an uninstalled (UI-deleted) and an archived (v2-deleted) + // relation both holding "corpse_key"-ish identities. Before the + // rework the uninstalled corpse made the guard refuse "already + // exists" (steering to PATCH an object the user deleted), and the + // archived one passed the guard only to die on ErrTreeExists at + // PutTree — because the caller's key WAS the derived tree. + fx := newV2Fixture(t) + fx.addRelation(t, testSpaceId, objectstore.TestObject{ + bundle.RelationKeyId: domain.String("rel-uninstalled"), + bundle.RelationKeyRelationKey: domain.String("corpse_key"), + bundle.RelationKeyName: domain.String("UI-deleted"), + bundle.RelationKeyIsUninstalled: domain.Bool(true), + }) + fx.addRelation(t, testSpaceId, objectstore.TestObject{ + bundle.RelationKeyId: domain.String("rel-archived"), + bundle.RelationKeyRelationKey: domain.String("6a7663db61fab21cd4b9e002"), + bundle.RelationKeyApiObjectKey: domain.String("corpse_key"), + bundle.RelationKeyName: domain.String("v2-deleted"), + bundle.RelationKeyIsArchived: domain.Bool(true), + }) + var captured *pb.RpcObjectCreateRelationRequest + fx.mwMock.EXPECT().ObjectCreateRelation(mock.Anything, mock.Anything). + RunAndReturn(func(ctx context.Context, req *pb.RpcObjectCreateRelationRequest) *pb.RpcObjectCreateRelationResponse { + captured = req + return &pb.RpcObjectCreateRelationResponse{ + ObjectId: "rel-fresh", Key: "6a7663db61fab21cd4b9e003", + Error: &pb.RpcObjectCreateRelationResponseError{Code: pb.RpcObjectCreateRelationResponseError_NULL}, + } + }) + + // when + result, err := fx.CreateProperty(context.Background(), testSpaceId, + v2model.CreatePropertyRequest{Key: "corpse_key", Name: "Recreated", Format: "text"}, false) + + // then: a clean create — fresh BSON identity (no relationKey in the + // payload, so no derived tree to collide with), slug re-taken + require.NoError(t, err) + require.NotNil(t, captured) + assert.Empty(t, pbtypes.GetString(captured.Details, bundle.RelationKeyRelationKey.String()), + "a caller key in the payload would re-derive the corpse's tree and die on ErrTreeExists") + assert.Equal(t, "corpse_key", pbtypes.GetString(captured.Details, bundle.RelationKeyApiObjectKey.String())) + assert.Equal(t, "corpse_key", result.Key) + }) + + t.Run("a live custom slug refuses the twin", func(t *testing.T) { + fx := newV2Fixture(t) + fx.addRelation(t, testSpaceId, objectstore.TestObject{ + bundle.RelationKeyId: domain.String("rel-live"), + bundle.RelationKeyRelationKey: domain.String("6a7663db61fab21cd4b9e004"), + bundle.RelationKeyApiObjectKey: domain.String("priority_level"), + bundle.RelationKeyName: domain.String("Priority level"), + }) + + _, err := fx.CreateProperty(context.Background(), testSpaceId, + v2model.CreatePropertyRequest{Key: "priorityLevel", Name: "Priority level 2", Format: "text"}, false) + + apiErr := v2ErrWithIssue(t, err) + require.NotEmpty(t, apiErr.Issues) + assert.Contains(t, apiErr.Issues[0].Message, `"priority_level"`) + }) + + t.Run("propertyKeyExists is corpse-blind and live-sighted", func(t *testing.T) { + fx := newV2Fixture(t) + fx.addRelation(t, testSpaceId, objectstore.TestObject{ + bundle.RelationKeyId: domain.String("rel-c"), + bundle.RelationKeyRelationKey: domain.String("corpseKey"), + bundle.RelationKeyIsUninstalled: domain.Bool(true), + }) + fx.addRelation(t, testSpaceId, objectstore.TestObject{ + bundle.RelationKeyId: domain.String("rel-l"), + bundle.RelationKeyRelationKey: domain.String("liveKey"), + }) + assert.False(t, fx.propertyKeyExists(testSpaceId, "corpseKey")) + assert.True(t, fx.propertyKeyExists(testSpaceId, "liveKey")) + assert.True(t, fx.propertyKeyExists(testSpaceId, "dueDate"), "bundled keys always exist") + }) +} + +func TestV2TypeMintCollisionCheck(t *testing.T) { + t.Run("a document key cannot shadow a bundled type slug", func(t *testing.T) { + // "object_type" is bundled objectType's derived slug; the old guard + // checked only the exact bundled key, so object_type passed and + // minted uniqueKey ot-object_type — a permanent shadow + fx := newV2Fixture(t) + + _, err := fx.CreateType(context.Background(), testSpaceId, + []byte(`{"kind":"object_type","properties":{"name":"Shadow"},"type_settings":{"api_key":"object_type"}}`), false, true) + + apiErr := v2ErrWithIssue(t, err) + assert.Equal(t, v2model.CodeValidationFailed, apiErr.Code) + require.NotEmpty(t, apiErr.Issues) + assert.Contains(t, apiErr.Issues[0].Message, "bundled type") + }) + + t.Run("type corpses vacate the namespace", func(t *testing.T) { + // given: a UI-deleted type holding ot-corpsetype — the old guard saw + // it and refused "already exists", steering PATCH at a corpse + fx := newV2Fixture(t) + fx.addType(t, testSpaceId, objectstore.TestObject{ + bundle.RelationKeyId: domain.String("type-corpse"), + bundle.RelationKeyUniqueKey: domain.String("ot-corpsetype"), + bundle.RelationKeyName: domain.String("Old"), + bundle.RelationKeyIsUninstalled: domain.Bool(true), + }) + var captured *pb.RpcObjectCreateObjectTypeRequest + fx.mwMock.EXPECT().ObjectCreateObjectType(mock.Anything, mock.Anything). + RunAndReturn(func(ctx context.Context, req *pb.RpcObjectCreateObjectTypeRequest) *pb.RpcObjectCreateObjectTypeResponse { + captured = req + return &pb.RpcObjectCreateObjectTypeResponse{ + ObjectId: "type-fresh", + Error: &pb.RpcObjectCreateObjectTypeResponseError{Code: pb.RpcObjectCreateObjectTypeResponseError_NULL}, + } + }) + fx.expectEtagRead("type-fresh") + + // when + result, err := fx.CreateType(context.Background(), testSpaceId, + []byte(`{"kind":"object_type","properties":{"name":"Recreated"},"type_settings":{"api_key":"corpsetype"}}`), false, true) + + // then: a clean create with fresh BSON identity + require.NoError(t, err) + require.NotNil(t, captured) + assert.Empty(t, pbtypes.GetString(captured.Details, bundle.RelationKeyUniqueKey.String()), + "the corpse's key in uniqueKey would re-derive its tree") + assert.Equal(t, "corpsetype", pbtypes.GetString(captured.Details, bundle.RelationKeyApiObjectKey.String())) + assert.Equal(t, "corpsetype", result.Key) + }) + + t.Run("a live type slug refuses the twin", func(t *testing.T) { + fx := newV2Fixture(t) + fx.addType(t, testSpaceId, objectstore.TestObject{ + bundle.RelationKeyId: domain.String("type-live"), + bundle.RelationKeyUniqueKey: domain.String("ot-6a7663db61fab21cd4b9e005"), + bundle.RelationKeyApiObjectKey: domain.String("meeting_note"), + bundle.RelationKeyName: domain.String("Meeting note"), + }) + + _, err := fx.CreateType(context.Background(), testSpaceId, + []byte(`{"kind":"object_type","properties":{"name":"Meeting note 2"},"type_settings":{"api_key":"meetingNote"}}`), false, true) + + apiErr := v2ErrWithIssue(t, err) + require.NotEmpty(t, apiErr.Issues) + assert.Contains(t, apiErr.Issues[0].Message, `"meeting_note"`) + }) +} + +func TestV2PropertyIdResolutionChain(t *testing.T) { + t.Run("a document key resolves through the space slug namespace", func(t *testing.T) { + // given: a v2-created property — BSON stored key, slug + // priority_level. A typeProperties entry naming priority_level must + // resolve to it (chain step 2), never mint a twin. + fx := newV2Fixture(t) + fx.addRelation(t, testSpaceId, objectstore.TestObject{ + bundle.RelationKeyId: domain.String("rel-priority"), + bundle.RelationKeyRelationKey: domain.String("6a7663db61fab21cd4b9e006"), + bundle.RelationKeyApiObjectKey: domain.String("priority_level"), + bundle.RelationKeyName: domain.String("Priority level"), + bundle.RelationKeyRelationFormat: domain.Int64(int64(model.RelationFormat_number)), + }) + var captured *pb.RpcObjectCreateObjectTypeRequest + fx.mwMock.EXPECT().ObjectCreateObjectType(mock.Anything, mock.Anything). + RunAndReturn(func(ctx context.Context, req *pb.RpcObjectCreateObjectTypeRequest) *pb.RpcObjectCreateObjectTypeResponse { + captured = req + return &pb.RpcObjectCreateObjectTypeResponse{ + ObjectId: "type-x", + Error: &pb.RpcObjectCreateObjectTypeResponseError{Code: pb.RpcObjectCreateObjectTypeResponseError_NULL}, + } + }) + fx.expectEtagRead("type-x") + + // when: no ObjectCreateRelation expectation — a create RPC fails the + // mock, which is the point + result, err := fx.CreateType(context.Background(), testSpaceId, + []byte(`{"kind":"object_type","properties":{"name":"Sprint"},"type_settings":{"api_key":"sprint","property_definitions":[{"property":"priority_level","section":"featured"}]}}`), false, true) + + // then + require.NoError(t, err) + assert.Nil(t, result.Created, "nothing minted — the slug resolved") + require.NotNil(t, captured) + assert.Equal(t, []string{"rel-priority"}, + pbtypes.GetStringList(captured.Details, bundle.RelationKeyRecommendedFeaturedRelations.String())) + }) + + t.Run("a bundled slug installs the bundled relation, never a twin", func(t *testing.T) { + // chain step 3: due_date names bundled dueDate. The create RPC must + // carry relationKey dueDate (the derived install path — convergence + // is the install mechanism, §2.4-1), NOT pin due_date as a new key. + fx := newV2Fixture(t) + var captured *pb.RpcObjectCreateRelationRequest + fx.mwMock.EXPECT().ObjectCreateRelation(mock.Anything, mock.Anything). + RunAndReturn(func(ctx context.Context, req *pb.RpcObjectCreateRelationRequest) *pb.RpcObjectCreateRelationResponse { + captured = req + return &pb.RpcObjectCreateRelationResponse{ + ObjectId: "rel-duedate", Key: "dueDate", + Error: &pb.RpcObjectCreateRelationResponseError{Code: pb.RpcObjectCreateRelationResponseError_NULL}, + } + }) + fx.mwMock.EXPECT().ObjectCreateObjectType(mock.Anything, mock.Anything). + Return(&pb.RpcObjectCreateObjectTypeResponse{ + ObjectId: "type-y", + Error: &pb.RpcObjectCreateObjectTypeResponseError{Code: pb.RpcObjectCreateObjectTypeResponseError_NULL}, + }) + fx.expectEtagRead("type-y") + + // when + _, err := fx.CreateType(context.Background(), testSpaceId, + []byte(`{"kind":"object_type","properties":{"name":"Errand"},"type_settings":{"api_key":"errand","property_definitions":[{"property":"due_date","section":"featured"}]}}`), false, true) + + // then + require.NoError(t, err) + require.NotNil(t, captured) + assert.Equal(t, "dueDate", pbtypes.GetString(captured.Details, bundle.RelationKeyRelationKey.String()), + "the bundled derived table rewrites the slug to the bundled key so install convergence serves it") + }) + + t.Run("an ambiguous slug fails loud, never by store order", func(t *testing.T) { + // two live holders of one slug (the concurrent-create artifact the + // (a) strategy accepts as its cost): resolution lists both and + // refuses — the D2 lesson applied to the key layer + fx := newV2Fixture(t) + for i, id := range []string{"rel-twin1", "rel-twin2"} { + fx.addRelation(t, testSpaceId, objectstore.TestObject{ + bundle.RelationKeyId: domain.String(id), + bundle.RelationKeyRelationKey: domain.String([]string{"6a7663db61fab21cd4b9e007", "6a7663db61fab21cd4b9e008"}[i]), + bundle.RelationKeyApiObjectKey: domain.String("twin_key"), + bundle.RelationKeyName: domain.String("Twin"), + }) + } + + // when + _, err := fx.CreateType(context.Background(), testSpaceId, + []byte(`{"kind":"object_type","properties":{"name":"Twin user"},"type_settings":{"api_key":"twinuser","property_definitions":[{"property":"twin_key"}]}}`), false, true) + + // then + require.Error(t, err) + assert.Contains(t, err.Error(), "ambiguous") + assert.Contains(t, err.Error(), "rel-twin1") + assert.Contains(t, err.Error(), "rel-twin2") + }) + + t.Run("a declared format conflicting with the resolved property is a loud 400", func(t *testing.T) { + // the format check SPEC §2a promises at the wiring (§2.3-5): before + // this, PropertyId returned the existing relation with the declared + // format silently ignored — the entry's objects then held + // wrong-shaped values + fx := newV2Fixture(t) + fx.addSelectProperty(t) // "severity", format select + + _, err := fx.CreateType(context.Background(), testSpaceId, + []byte(`{"kind":"object_type","properties":{"name":"Incident"},"type_settings":{"api_key":"incident","property_definitions":[{"property":"severity","format":"text"}]}}`), false, true) + + apiErr := v2ErrWithIssue(t, err) + require.NotEmpty(t, apiErr.Issues) + assert.Equal(t, "/type_properties/0/format", apiErr.Issues[0].Path) + assert.Contains(t, apiErr.Issues[0].Message, `"select"`) + }) + + t.Run("a declared format conflicting with a bundled property is a loud 400", func(t *testing.T) { + fx := newV2Fixture(t) + + _, err := fx.CreateType(context.Background(), testSpaceId, + []byte(`{"kind":"object_type","properties":{"name":"Errand 2"},"type_settings":{"api_key":"errand2","property_definitions":[{"property":"due_date","format":"text"}]}}`), false, true) + + apiErr := v2ErrWithIssue(t, err) + require.NotEmpty(t, apiErr.Issues) + assert.Equal(t, "/type_properties/0/format", apiErr.Issues[0].Path) + assert.Contains(t, apiErr.Issues[0].Message, `"date"`) + }) + + t.Run("a matching declared format passes the check", func(t *testing.T) { + fx := newV2Fixture(t) + fx.addSelectProperty(t) + fx.mwMock.EXPECT().ObjectCreateObjectType(mock.Anything, mock.Anything). + Return(&pb.RpcObjectCreateObjectTypeResponse{ + ObjectId: "type-ok", + Error: &pb.RpcObjectCreateObjectTypeResponseError{Code: pb.RpcObjectCreateObjectTypeResponseError_NULL}, + }) + fx.expectEtagRead("type-ok") + + _, err := fx.CreateType(context.Background(), testSpaceId, + []byte(`{"kind":"object_type","properties":{"name":"Incident 2"},"type_settings":{"api_key":"incident2","property_definitions":[{"property":"severity","format":"select"}]}}`), false, true) + + require.NoError(t, err) + }) + + t.Run("the format check guards the PATCH typeProperties channel too", func(t *testing.T) { + fx := newV2Fixture(t) + fx.addSelectProperty(t) + fx.addType(t, testSpaceId, objectstore.TestObject{ + bundle.RelationKeyId: domain.String("type-edit"), + bundle.RelationKeyUniqueKey: domain.String("ot-editable"), + bundle.RelationKeyName: domain.String("Editable"), + }) + + _, err := fx.UpdateType(context.Background(), testSpaceId, "editable", + []byte(`{"type_settings":{"property_definitions":[{"property":"severity","format":"number"}]}}`), false, true) + + apiErr := v2ErrWithIssue(t, err) + require.NotEmpty(t, apiErr.Issues) + assert.Equal(t, "/type_properties/0/format", apiErr.Issues[0].Path) + }) + + t.Run("a custom key still creates on a full miss, slug stamped", func(t *testing.T) { + fx := newV2Fixture(t) + var captured *pb.RpcObjectCreateRelationRequest + fx.mwMock.EXPECT().ObjectCreateRelation(mock.Anything, mock.Anything). + RunAndReturn(func(ctx context.Context, req *pb.RpcObjectCreateRelationRequest) *pb.RpcObjectCreateRelationResponse { + captured = req + return &pb.RpcObjectCreateRelationResponse{ + ObjectId: "rel-new", Key: "6a7663db61fab21cd4b9e009", + Error: &pb.RpcObjectCreateRelationResponseError{Code: pb.RpcObjectCreateRelationResponseError_NULL}, + } + }) + fx.mwMock.EXPECT().ObjectCreateObjectType(mock.Anything, mock.Anything). + Return(&pb.RpcObjectCreateObjectTypeResponse{ + ObjectId: "type-z", + Error: &pb.RpcObjectCreateObjectTypeResponseError{Code: pb.RpcObjectCreateObjectTypeResponseError_NULL}, + }) + fx.expectEtagRead("type-z") + + // when + result, err := fx.CreateType(context.Background(), testSpaceId, + []byte(`{"kind":"object_type","properties":{"name":"Gadget"},"type_settings":{"api_key":"gadget","property_definitions":[{"property":"warranty_until","name":"Warranty until","format":"date"}]}}`), false, true) + + // then + require.NoError(t, err) + require.NotNil(t, captured) + assert.Empty(t, pbtypes.GetString(captured.Details, bundle.RelationKeyRelationKey.String())) + assert.Equal(t, "warranty_until", pbtypes.GetString(captured.Details, bundle.RelationKeyApiObjectKey.String())) + require.NotNil(t, result.Created) + require.Len(t, result.Created.Properties, 1) + assert.Equal(t, "warranty_until", result.Created.Properties[0].Key) + }) +} + +// TestCreateReturnsTheStoredKeyNotTheProposal pins the one thing a 201 owes: +// the key it returns must be a key the key routes accept. v2's pre-check and +// the heart's mint check DIFFERENT namespaces on purpose — the mint counts +// hidden holders (a hidden holder still occupies a stored slug in data, and +// minting a second entity onto it is what creates the ambiguity), v2's +// request namespace excludes them (propertyEntry.Hidden). So the mint can +// suffix a slug v2 just found free, or give up and store none. Returning the +// PROPOSAL made `201 {"key":"manual_property"}` followed by +// `GET …/properties/manual_property` → 404. +func TestCreateReturnsTheStoredKeyNotTheProposal(t *testing.T) { + t.Run("a property mint that suffixed is reported as suffixed", func(t *testing.T) { + // given — a HIDDEN holder of `manual_property`: v2's pre-check does + // not see it, so the create proceeds; the mint does, so it suffixes + fx := newV2Fixture(t) + fx.addRelation(t, testSpaceId, objectstore.TestObject{ + bundle.RelationKeyId: domain.String("rel-hidden"), + bundle.RelationKeyRelationKey: domain.String("6a7663db61fab21cd4b9e201"), + bundle.RelationKeyApiObjectKey: domain.String("manual_property"), + bundle.RelationKeyName: domain.String("Hidden holder"), + bundle.RelationKeyIsHidden: domain.Bool(true), + }) + fx.mwMock.EXPECT().ObjectCreateRelation(mock.Anything, mock.Anything). + Return(&pb.RpcObjectCreateRelationResponse{ + ObjectId: "rel-new", Key: "6a7663db61fab21cd4b9e202", + Details: &types.Struct{Fields: map[string]*types.Value{ + bundle.RelationKeyApiObjectKey.String(): pbtypes.String("manual_property_2"), + }}, + Error: &pb.RpcObjectCreateRelationResponseError{Code: pb.RpcObjectCreateRelationResponseError_NULL}, + }) + + // when + result, err := fx.CreateProperty(context.Background(), testSpaceId, + v2model.CreatePropertyRequest{Key: "manual_property", Name: "Manual property", Format: "text"}, false) + + // then + require.NoError(t, err) + assert.Equal(t, "manual_property_2", result.Key, + "the stored slug, never the proposal — the proposal 404s") + }) + + t.Run("a property mint that stored no slug reports the internal key", func(t *testing.T) { + // given — the walk gave up (maxApiKeySuffix): apiObjectKey is empty + // and the minted BSON is the only address there is + fx := newV2Fixture(t) + fx.mwMock.EXPECT().ObjectCreateRelation(mock.Anything, mock.Anything). + Return(&pb.RpcObjectCreateRelationResponse{ + ObjectId: "rel-new", Key: "6a7663db61fab21cd4b9e203", + Details: &types.Struct{Fields: map[string]*types.Value{ + bundle.RelationKeyApiObjectKey.String(): pbtypes.String(""), + }}, + Error: &pb.RpcObjectCreateRelationResponseError{Code: pb.RpcObjectCreateRelationResponseError_NULL}, + }) + + // when + result, err := fx.CreateProperty(context.Background(), testSpaceId, + v2model.CreatePropertyRequest{Key: "manual_property", Name: "Manual property", Format: "text"}, false) + + // then + require.NoError(t, err) + assert.Equal(t, "6a7663db61fab21cd4b9e203", result.Key) + }) + + t.Run("the type create path has the same shape", func(t *testing.T) { + // given + fx := newV2Fixture(t) + fx.addType(t, testSpaceId, objectstore.TestObject{ + bundle.RelationKeyId: domain.String("type-hidden"), + bundle.RelationKeyUniqueKey: domain.String("ot-6a7663db61fab21cd4b9e204"), + bundle.RelationKeyApiObjectKey: domain.String("invoice"), + bundle.RelationKeyName: domain.String("Hidden type"), + bundle.RelationKeyIsHidden: domain.Bool(true), + }) + fx.mwMock.EXPECT().ObjectCreateObjectType(mock.Anything, mock.Anything). + Return(&pb.RpcObjectCreateObjectTypeResponse{ + ObjectId: "type-new", + Details: &types.Struct{Fields: map[string]*types.Value{ + bundle.RelationKeyUniqueKey.String(): pbtypes.String("ot-6a7663db61fab21cd4b9e205"), + bundle.RelationKeyApiObjectKey.String(): pbtypes.String("invoice_2"), + }}, + Error: &pb.RpcObjectCreateObjectTypeResponseError{Code: pb.RpcObjectCreateObjectTypeResponseError_NULL}, + }) + fx.expectEtagRead("type-new") + + // when + result, err := fx.CreateType(context.Background(), testSpaceId, + []byte(`{"kind":"object_type","properties":{"name":"Invoice"},"type_settings":{"api_key":"invoice"}}`), false, true) + + // then + require.NoError(t, err) + assert.Equal(t, "invoice_2", result.Key) + }) +} diff --git a/core/api/v2/service/object.go b/core/api/v2/service/object.go new file mode 100644 index 0000000000..b8deb5dbe7 --- /dev/null +++ b/core/api/v2/service/object.go @@ -0,0 +1,862 @@ +package v2service + +// object.go implements the Phase-1 object read surface (APIV2.md): +// GET /v2/spaces/{space_id}/objects/{object_id} with include/outline/block/ +// ids/format, and the C5 minimal-row object list. + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "strings" + + "github.com/anyproto/any-sync/commonspace/object/tree/treestorage" + + apicore "github.com/anyproto/anytype-heart/core/api/core" + "github.com/anyproto/anytype-heart/core/api/util" + v2model "github.com/anyproto/anytype-heart/core/api/v2/model" + "github.com/anyproto/anytype-heart/core/block/restriction" + "github.com/anyproto/anytype-heart/core/domain" + "github.com/anyproto/anytype-heart/pb" + "github.com/anyproto/anytype-heart/pkg/lib/anyblockjson" + "github.com/anyproto/anytype-heart/pkg/lib/anyblockjson/storeresolver" + "github.com/anyproto/anytype-heart/pkg/lib/bundle" + "github.com/anyproto/anytype-heart/pkg/lib/database" + "github.com/anyproto/anytype-heart/pkg/lib/localstore/objectstore/spaceindex" + "github.com/anyproto/anytype-heart/pkg/lib/pb/model" + "github.com/anyproto/anytype-heart/space" +) + +// Query parameter values (APIV2.md Phase 1). +const ( + V2IdsCompact = "compact" + V2IdsFull = "full" + + V2FormatAnyblock = "anyblock" + V2FormatMd = "md" + + V2IncludeProperties = "properties" + V2IncludeBlocks = "blocks" +) + +// ObjectQuery carries the GET object query parameters as received. +type ObjectQuery struct { + Include string // comma-separated subset of properties,blocks; "" = both + Outline bool + Block string + Ids string // compact (default) = the edit shape | full = the export shape (C4) + Format string // anyblock (default) | md +} + +// objectReadPlan is the validated form of a ObjectQuery. +// +// One id axis moves per shape: block-id relabeling. Object refs stay full +// inline on EVERY shape — the `refs` legend is a measured net loss on every +// corpus document (TOKENS §1.2; §8.25's legend axis has it costing +// 0.9–11.5 % per document, 5.3 % on the ref-heaviest row), and the +// indirection traps write-back of object-valued properties. +// Legend RESOLUTION on input is untouched and total (SPEC §9a), so a +// document arriving with a legend still writes back. +type objectReadPlan struct { + wantProperties bool + wantBlocks bool + outline bool + block string + // compactBlockLabels relabels machine-minted doc-local block/row/column/ + // view ids (24-hex, view UUIDs — anyblockjson.isMintedLocalId) to short + // suffixes — legend-less and LOSSY (the originals are not recoverable + // from the document), which is why the export shape does not use it. + // Meaningful ids never relabel. Every write channel resolves a label + // back by unique suffix (matchBlockRef), so a labeled read stays + // addressable by PATCH, `?block=` and `?view=`. + compactBlockLabels bool + markdown bool +} + +// validate applies the Phase-1 param legality matrix: outline and block are +// mutually exclusive with each other and with format=md; illegal +// combinations → 400 ambiguous_input naming the conflicting params. +func (q ObjectQuery) validate() (objectReadPlan, error) { + plan := objectReadPlan{wantProperties: true, wantBlocks: true, compactBlockLabels: true} + + if q.Outline && q.Block != "" { + return plan, v2model.AmbiguousInput("outline and block are mutually exclusive — request the outline or one subtree, not both", + v2model.Issue{Path: "outline", Message: "conflicts with block"}, + v2model.Issue{Path: "block", Message: "conflicts with outline"}) + } + if q.Format == V2FormatMd && q.Outline { + return plan, v2model.AmbiguousInput("format=md and outline are mutually exclusive — the outline shape is AnyBlock-only", + v2model.Issue{Path: "format", Message: "conflicts with outline"}, + v2model.Issue{Path: "outline", Message: "conflicts with format=md"}) + } + if q.Format == V2FormatMd && q.Block != "" { + return plan, v2model.AmbiguousInput("format=md and block are mutually exclusive — subtree reads are AnyBlock-only", + v2model.Issue{Path: "format", Message: "conflicts with block"}, + v2model.Issue{Path: "block", Message: "conflicts with format=md"}) + } + + // `?ids=` selects one of TWO document shapes. Wave 2 renames these to + // `?mode=edit|full` with no change of bytes. + // + // compact = the edit shape: short labels for minted block ids. + // full = the export shape: full block ids everywhere, so a GET body PUTs + // back as a minimal diff (APIV2.md §3(b)). No shape serves the refs + // legend — this used to be where it lived, until its own measurement + // showed it a pure loss (§8.26), which left no shape offering full block + // ids without the write-back-trapping indirection. + // + // The parse is ParseIdsShape (idshape.go), the same one the route + // middleware runs to decide how SPACE ids are spelled in the same + // response (§8.36): one parameter, one list of legal values, one 400. + fullIds, err := ParseIdsShape(q.Ids) + if err != nil { + return plan, err + } + plan.compactBlockLabels = !fullIds + + switch q.Format { + case "", V2FormatAnyblock: + case V2FormatMd: + plan.markdown = true + default: + return plan, v2model.ValidationFailed("invalid format value", + v2model.Issue{Path: "format", Message: fmt.Sprintf("unknown value %q", q.Format), Hint: "allowed: anyblock, md"}) + } + + if q.Include != "" { + plan.wantProperties, plan.wantBlocks = false, false + for _, part := range strings.Split(q.Include, ",") { + switch strings.TrimSpace(part) { + case V2IncludeProperties: + plan.wantProperties = true + case V2IncludeBlocks: + plan.wantBlocks = true + case "": + default: + return plan, v2model.ValidationFailed("invalid include value", + v2model.Issue{Path: "include", Message: fmt.Sprintf("unknown value %q", strings.TrimSpace(part)), Hint: "allowed: properties, blocks"}) + } + } + } + + plan.outline = q.Outline + // the outline shape carries properties only when include=properties + // accompanies it ("an accompanying include=properties adds the + // properties map" — Phase 1 param matrix) + if plan.outline && q.Include == "" { + plan.wantProperties = false + } + plan.block = q.Block + // a subtree read implies blocks, like outline does — include=properties + // alongside block= adds the properties map instead of emptying the read + if plan.block != "" { + plan.wantBlocks = true + } + // the outline shape fixes the axis and ignores `?ids=` (C4 T7): it is + // read-only, so lossy block labels are free. + if plan.outline { + plan.compactBlockLabels = true + } + return plan, nil +} + +// mapReadError converts live-read failures into C6 errors. +func mapReadError(spaceId, objectId string, err error) error { + if errors.Is(err, treestorage.ErrUnknownTreeId) { + return v2model.NotFound(fmt.Sprintf("object %q not found in space %q", objectId, spaceId)) + } + if errors.Is(err, space.ErrSpaceNotExists) || errors.Is(err, space.ErrSpaceDeleted) { + return v2model.NotFound(fmt.Sprintf("space %q not found", spaceId)) + } + return fmt.Errorf("read object %s: %w", objectId, err) +} + +// restrictionForbidden is the C6 403 for an object-restriction refusal: +// permanent for this object, so the message says not to retry. +func restrictionForbidden(objectId string, err error) *v2model.Error { + return v2model.NewError(http.StatusForbidden, v2model.CodeForbidden, + fmt.Sprintf("edit of object %q refused by its restrictions: %s — the refusal is permanent for this object, do not retry", objectId, err)) +} + +// mapWriteError classifies mutation-path failures (PATCH/PUT). A +// restriction refusal — the adapter's in-lock checkObjectEditable re-check +// or Apply's per-block restrictions, both wrapping +// restriction.ErrRestricted — is a PERMANENT 403: falling through to +// mapReadError dressed it as a read-shaped 500 and sent retrying agents +// into a loop (surface review M2a). Everything else takes the read +// classification. +func mapWriteError(spaceId, objectId string, err error) error { + if errors.Is(err, restriction.ErrRestricted) { + return restrictionForbidden(objectId, err) + } + return mapReadError(spaceId, objectId, err) +} + +// GetObject reads one object via the live smartblock state → snapshot → +// anyblockjson.Marshal, and derives the etag from the same read (§8). The +// returned body is the flat AnyBlock document with the envelope etag; the +// caller sets the ETag header from the second return. +func (s *Service) GetObject(ctx context.Context, spaceId, objectId string, q ObjectQuery) ([]byte, string, error) { + if err := s.ensureSpace(ctx, spaceId); err != nil { + return nil, "", err + } + plan, err := q.validate() + if err != nil { + return nil, "", err + } + + if plan.markdown { + return s.markdownEnvelope(ctx, spaceId, objectId) + } + + read, err := s.reader.ReadObject(ctx, spaceId, objectId) + if err != nil { + return nil, "", mapReadError(spaceId, objectId, err) + } + etag := ComputeEtag(read.Heads) + + reads := storeresolver.New(s.store.SpaceIndex(spaceId)) + if read.SbType == model.SmartBlockType_STType { + // tombstone window (§8.41): a just-deleted relation's index row is + // {id, isDeleted} only, so the by-id resolve behind typeProperties + // fails and the entry would silently VANISH from the served list — + // and the documented read-modify-write loop would then delete the + // type's reference to it. The surviving tree still knows everything; + // read it and seed the resolver so all three store shapes serve the + // same bytes. + s.seedTombstonedTypeProperties(ctx, spaceId, reads, read.Snapshot) + } + opts := reads.Options() + // the shape comes pre-composed by validate() — see objectReadPlan; + // CompactObjectRefs stays at its zero value on every shape (no legend) + opts.CompactBlockLabels = plan.compactBlockLabels + // C11 (M3): a read never fails on content the format can't represent — + // unmapped/over-deep blocks degrade to warnings that ride the envelope. + var warnings []v2model.Issue + opts.OnWarning = func(iss anyblockjson.Issue) { + warnings = append(warnings, v2model.Issue{Path: iss.Path, Message: iss.Message}) + } + + doc, err := anyblockjson.Marshal(read.SbType, read.Snapshot, opts) + if err != nil { + return nil, "", fmt.Errorf("marshal object %s: %w", objectId, err) + } + fields, err := parseEnvelope(doc) + if err != nil { + return nil, "", fmt.Errorf("object %s: %w", objectId, err) + } + + if fields["etag"], err = rawJSON(etag); err != nil { + return nil, "", err + } + + if plan.outline { + if err := buildOutlineEnvelope(fields, plan.wantProperties); err != nil { + return nil, "", fmt.Errorf("object %s: %w", objectId, err) + } + } else { + if !plan.wantProperties { + delete(fields, "properties") + } + if !plan.wantBlocks { + delete(fields, "blocks") + } + if plan.block != "" { + storedIds := func() ([]string, error) { return s.exportShapeBlockIds(spaceId, read) } + if err := filterBlockSubtree(fields, plan.block, storedIds); err != nil { + return nil, "", err + } + } + } + + if len(warnings) > 0 { + if fields["warnings"], err = rawJSON(warnings); err != nil { + return nil, "", err + } + } + + body, err := encodeEnvelope(fields) + if err != nil { + return nil, "", fmt.Errorf("object %s: %w", objectId, err) + } + return body, etag, nil +} + +// seedTombstonedTypeProperties makes a type read serve the SAME +// typeProperties in the tombstone window as before the delete and after the +// next space load (§8.41). For every recommended-relation id the store +// resolver cannot answer (GetRelationById needs a relationKey the tombstone +// row lost), it confirms the row exists as a tombstone and reads the LIVE +// object — the tree survives a UI delete by design (ADDRESSING §2.4-5) — to +// recover key, name and format, then seeds the resolver. Every miss degrades +// to the pre-§8.41 behavior for that entry (dropped), never to an error: a +// dangling id in a recommended list has always been dropped, and the read +// must not fail on it. +func (s *Service) seedTombstonedTypeProperties(ctx context.Context, spaceId string, reads *storeresolver.Resolvers, snapshot *model.SmartBlockSnapshotBase) { + if snapshot == nil || snapshot.Details == nil { + return + } + index := s.store.SpaceIndex(spaceId) + details := domain.NewDetailsFromProto(snapshot.Details) + for _, listKey := range typeRecommendedListKeys { + for _, id := range details.GetStringList(listKey) { + if _, ok := reads.PropertyById(id); ok { + continue + } + row, err := index.GetDetails(id) + if err != nil || row.GetString(bundle.RelationKeyId) == "" || !row.GetBool(bundle.RelationKeyIsDeleted) { + continue // no row, or not a tombstone — the drop stands + } + relRead, err := s.reader.ReadObject(ctx, spaceId, id) + if err != nil || relRead.Snapshot == nil || relRead.Snapshot.Details == nil { + continue + } + live := domain.NewDetailsFromProto(relRead.Snapshot.Details) + key := live.GetString(bundle.RelationKeyRelationKey) + if key == "" { + continue + } + reads.SeedProperty(id, anyblockjson.PropertyDefinition{ + Key: domain.RelationKey(key), + Name: live.GetString(bundle.RelationKeyName), + Format: model.RelationFormat(live.GetInt64(bundle.RelationKeyRelationFormat)), + }) + } + } +} + +// markdownEnvelope builds the format=md response. The etag is read AFTER the +// export (§8, M2): a concurrent edit between the two can only make the etag +// reflect a state at or after the markdown, never before it, so the returned +// markdown is never newer than the etag advertises (over-reporting freshness +// is the safe direction for a later If-Match). +// The markdown converter has no loss channel today, so the response carries no +// warnings. TODO(GO-7383): surface C11 warnings for nodes the markdown export +// drops once core/converter/md grows a loss channel (APIV2.md §3 build item +// "md-export loss detector"). +func (s *Service) markdownEnvelope(ctx context.Context, spaceId, objectId string) ([]byte, string, error) { + resp := s.mw.ObjectExport(ctx, &pb.RpcObjectExportRequest{ + SpaceId: spaceId, + ObjectId: objectId, + Format: model.Export_Markdown, + }) + if resp.Error != nil && resp.Error.Code != pb.RpcObjectExportResponseError_NULL { + return nil, "", fmt.Errorf("export markdown for object %s: %s", objectId, resp.Error.Description) + } + + read, err := s.reader.ReadObject(ctx, spaceId, objectId) + if err != nil { + return nil, "", mapReadError(spaceId, objectId, err) + } + etag := ComputeEtag(read.Heads) + + fields := map[string]json.RawMessage{} + if fields["id"], err = rawJSON(objectId); err != nil { + return nil, "", err + } + if typeKey := objectTypeKey(read); typeKey != "" { + if fields["type"], err = rawJSON(typeKey); err != nil { + return nil, "", err + } + } + if fields["etag"], err = rawJSON(etag); err != nil { + return nil, "", err + } + if fields["markdown"], err = rawJSON(resp.Result); err != nil { + return nil, "", err + } + body, err := encodeEnvelope(fields) + return body, etag, err +} + +// objectTypeKey extracts the object's type key from the snapshot. +func objectTypeKey(read apicore.ObjectRead) string { + if read.Snapshot == nil || len(read.Snapshot.ObjectTypes) == 0 { + return "" + } + return strings.TrimPrefix(read.Snapshot.ObjectTypes[0], domain.TypeKey("").URL()) +} + +// outlineSource is the subset of a marshaled block relevant to the outline. +type outlineSource struct { + Indent int `json:"indent"` + Id string `json:"id"` + Type string `json:"type"` + Text string `json:"text"` +} + +// outlineHeadingTypes are the block types whose text appears in the outline. +var outlineHeadingTypes = map[string]bool{ + "heading_1": true, "heading_2": true, "heading_3": true, + "toggle_heading_1": true, "toggle_heading_2": true, "toggle_heading_3": true, +} + +// buildOutlineEnvelope replaces the blocks array with the outline shape: +// every block's {indent, id, type}, text only on headings — so every block +// id is addressable for a follow-up ?block= read or a PATCH. +func buildOutlineEnvelope(fields map[string]json.RawMessage, keepProperties bool) error { + var blocks []json.RawMessage + if raw, ok := fields["blocks"]; ok { + if err := json.Unmarshal(raw, &blocks); err != nil { + return fmt.Errorf("decode blocks for outline: %w", err) + } + } + outline := make([]v2model.OutlineEntry, 0, len(blocks)) + for i, raw := range blocks { + var src outlineSource + if err := json.Unmarshal(raw, &src); err != nil { + return fmt.Errorf("decode block %d for outline: %w", i, err) + } + entry := v2model.OutlineEntry{Indent: src.Indent, Id: src.Id, Type: src.Type} + if outlineHeadingTypes[src.Type] { + entry.Text = src.Text + } + outline = append(outline, entry) + } + + raw, err := rawJSON(outline) + if err != nil { + return err + } + fields["outline"] = raw + delete(fields, "blocks") + if !keepProperties { + delete(fields, "properties") + delete(fields, "refs") // backstop: no shape serves a legend anymore + } + return nil +} + +// filterBlockSubtree keeps only the addressed block and its contiguous +// indent-run of descendants. Indents stay absolute so ids and depths are +// stable across full and subtree reads. The block reference resolves by +// exact id or by unique suffix (§9a) against the SERVED ids first, then +// against the stored ids (storedIds) — so BOTH spellings of a relabeled +// block address it: the short label a default read shows, and the full +// stored id a `?ids=full` read, PATCH `created_blocks` or another client +// holds. Without the stored-id fallback the full id 404ed on the default +// shape — an addressability hole between the two vocabularies. +// +// The envelope is marked partial with "subtree": true — the way the outline +// shape is partial by construction. Without the marker the subtree body was +// schema-valid, and PUT of that exact body silently deleted every block +// outside the subtree (reproduced: 6-block page, GET ?block= then PUT → +// blocks_removed: 5). The marker makes every write path refuse it — the +// AnyBlock envelope is additionalProperties:false, so Validate rejects it +// structurally, and PUT/create name it precisely before that. +// +// storedIds() lists the stored spelling of each SERVED block, index-aligned +// with the served blocks array (exportShapeBlockIds), and is only invoked on +// the fallback path. +func filterBlockSubtree(fields map[string]json.RawMessage, blockRef string, storedIds func() ([]string, error)) error { + var blocks []json.RawMessage + if raw, ok := fields["blocks"]; ok { + if err := json.Unmarshal(raw, &blocks); err != nil { + return fmt.Errorf("decode blocks for subtree read: %w", err) + } + } + type blockProbe struct { + Indent int `json:"indent"` + Id string `json:"id"` + } + probes := make([]blockProbe, len(blocks)) + ids := make([]string, len(blocks)) + for i, raw := range blocks { + if err := json.Unmarshal(raw, &probes[i]); err != nil { + return fmt.Errorf("decode block %d for subtree read: %w", i, err) + } + ids[i] = probes[i].Id + } + + anchor, err := resolveBlockRef(ids, blockRef) + if err != nil { + // the stored-id vocabulary fallback. Only a not-found falls through: + // a served-vocabulary AMBIGUITY stays a refusal — resolving it + // against the other vocabulary would silently pick one of the blocks + // the 400 exists to make the caller disambiguate. + var refErr *v2model.Error + if !errors.As(err, &refErr) || refErr.Code != v2model.CodeNotFound { + return err + } + stored, storedErr := storedIds() + if storedErr != nil { + return storedErr + } + // stored[i] is the stored spelling of blocks[i] — the same marshal + // modulo relabeling — so a resolved ref maps to its served block by + // POSITION. (A suffix scan here served the WRONG block: any earlier + // served id that happened to tail the matched stored id won — "b1" + // tails "…9ab1".) A stored id that is never served (the root, table + // wrappers, cells) is simply absent from the list and stays a 404. + if len(stored) != len(ids) { + return fmt.Errorf("subtree stored-id fallback: %d stored ids for %d served blocks", len(stored), len(ids)) + } + if anchor, err = resolveBlockRef(stored, blockRef); err != nil { + return err + } + } + + anchorIndent := probes[anchor].Indent + run := []json.RawMessage{blocks[anchor]} + for i := anchor + 1; i < len(blocks); i++ { + if probes[i].Indent <= anchorIndent { + break + } + run = append(run, blocks[i]) + } + raw, err := rawJSON(run) + if err != nil { + return err + } + fields["blocks"] = raw + if fields["subtree"], err = rawJSON(true); err != nil { + return err + } + return nil +} + +// exportShapeBlockIds re-marshals a read WITHOUT block-id relabeling and +// returns the top-level block ids in document order — the second resolution +// vocabulary for ?block= (the first is the served ids). Relabeling changes +// id spellings only, never the block set or order, so index i here is the +// STORED spelling of the served document's block i: the fallback maps a +// resolved stored id to its served block positionally. The discarding +// warning sink keeps degradation identical to the served marshal (without a +// sink, C11-degradable content fails the marshal instead). Blocks that never +// render as flat blocks — the root, table wrappers, cells — are absent here +// exactly as they are absent from the served array. +func (s *Service) exportShapeBlockIds(spaceId string, read apicore.ObjectRead) ([]string, error) { + opts := storeresolver.New(s.store.SpaceIndex(spaceId)).Options() + opts.OnWarning = func(anyblockjson.Issue) {} + doc, err := anyblockjson.Marshal(read.SbType, read.Snapshot, opts) + if err != nil { + return nil, fmt.Errorf("marshal stored-id shape: %w", err) + } + var probe struct { + Blocks []struct { + Id string `json:"id"` + } `json:"blocks"` + } + if err := json.Unmarshal(doc, &probe); err != nil { + return nil, fmt.Errorf("decode stored-id shape: %w", err) + } + ids := make([]string, len(probe.Blocks)) + for i, b := range probe.Blocks { + ids[i] = b.Id + } + return ids, nil +} + +// matchBlockRef maps a block reference to an index into ids: an exact id +// match wins (matches = 1); otherwise ids whose full value ends with ref are +// counted (a compact outline label is the id's last few characters, §9a) and +// idx points at the last suffix match. Shared by the ?block= read and the +// PATCH ops (C4). +func matchBlockRef(ids []string, ref string) (idx, matches int) { + suffix, suffixCount := -1, 0 + for i, id := range ids { + if id == ref { + return i, 1 + } + if ref != "" && strings.HasSuffix(id, ref) { + suffix, suffixCount = i, suffixCount+1 + } + } + return suffix, suffixCount +} + +// resolveBlockRef wraps matchBlockRef with the ?block= read errors: zero +// matches → 404; an ambiguous suffix → 400 steering to the full id. +func resolveBlockRef(ids []string, ref string) (int, error) { + idx, matches := matchBlockRef(ids, ref) + switch { + case matches == 1: + return idx, nil + case matches > 1: + return -1, v2model.AmbiguousInput( + fmt.Sprintf("block label %q matches more than one block — use the full block id", ref), + v2model.Issue{Path: "block", Message: "the label is a suffix of several block ids"}) + default: + return -1, v2model.NotFound(fmt.Sprintf("block %q not found", ref), + v2model.Issue{Path: "block", Message: v2AddressableBlocksMessage, Hint: v2AddressableBlocksHint}) + } +} + +// v2AddressableBlocksMessage / v2AddressableBlocksHint are the shared +// not-found repair loop for a block reference, on the read side (?block=) +// and the write side (the PATCH ops). They have to be TRUE: both messages +// used to say "GET the object with ?outline=true to list block ids", and a +// caller who had just been served a block id the outline does not list was +// sent round that loop forever. +// +// What ?outline=true lists is exactly the document's blocks array — the set +// a block reference resolves against, on every channel. A default read also +// serves ids that live INSIDE a block: table row and column ids, the ids of +// blocks nested in table cells, dataview view ids. Those are real stored ids +// and they relabel like any other, but they are not block references: they +// are addressed through the slot that owns them (set_cell's row/col, the view +// ops' view), and a block nested in a cell has no addressing slot at all — +// it is reached by rewriting its cell. +// +// TODO(GO-7383): make cell descendants addressable. Ticketed, not taken — +// see APIV2.md §8.29 for what it would cost (a second addressing mode in +// every ref-taking op, a served shape for a partial cell run, and an outline +// entry that says "this is not a sibling of the top-level run"). +const ( + v2AddressableBlocksMessage = "the addressable blocks are the entries of the document's blocks array" + v2AddressableBlocksHint = "GET the object with ?outline=true to list them. Ids nested inside a block are served but are not block references: a table's rows and columns are addressed by set_cell's row/col, a dataview's views by the view ops, and a block inside a table cell is not individually addressable — rewrite its cell with set_cell." +) + +// +// ---- object list (C5 minimal rows) ---- +// + +// ListObjects returns minimal rows (id, name, type + requested fields) for +// the space's objects, newest-modified first. +func (s *Service) ListObjects(ctx context.Context, spaceId string, fields []string, offset, limit int) ([]v2model.ObjectRow, int, bool, error) { + if err := s.ensureSpace(ctx, spaceId); err != nil { + return nil, 0, false, err + } + index := s.store.SpaceIndex(spaceId) + records, total, err := index.QueryAndCount(database.Query{ + Filters: []database.FilterRequest{ + { + RelationKey: bundle.RelationKeyResolvedLayout, + Condition: model.BlockContentDataviewFilter_In, + Value: domain.Int64List(util.LayoutsToIntArgs(util.ObjectLayouts)), + }, + { + RelationKey: "type.uniqueKey", + Condition: model.BlockContentDataviewFilter_NotEqual, + Value: domain.String(bundle.TypeKeyTemplate.URL()), + }, + { + RelationKey: bundle.RelationKeyIsHidden, + Condition: model.BlockContentDataviewFilter_NotEqual, + Value: domain.Bool(true), + }, + }, + Sorts: []database.SortRequest{{ + RelationKey: bundle.RelationKeyLastModifiedDate, + Type: model.BlockContentDataviewSort_Desc, + IncludeTime: true, + }}, + Offset: offset, + Limit: limit + 1, // one extra record detects has_more without a second scan + }) + if err != nil { + return nil, 0, false, fmt.Errorf("query objects in space %s: %w", spaceId, err) + } + + hasMore := len(records) > limit + if hasMore { + records = records[:limit] + } + + builder, err := s.newObjectRowBuilder(spaceId, fields) + if err != nil { + return nil, 0, false, err + } + rows := make([]v2model.ObjectRow, 0, len(records)) + for _, record := range records { + rows = append(rows, builder.row(record)) + } + return rows, total, hasMore, nil +} + +// v2FieldAliases maps the file vocabulary of the v2 query surface onto the +// store relations backing it (Phase 7): `mimeType` and `size` are the +// format's OWN names for a file's mime and byte size (the SPEC §5 file-block +// fields, and the POST /files result) — the store relations are named +// fileMimeType/sizeInBytes. An ACTIVE alias (activeFieldAliases) is live in +// every channel — fields=, filters and sorts — translated to the backing +// relation, so the one advertised spelling works everywhere (C2). +var v2FieldAliases = map[string]domain.RelationKey{ + "mimeType": bundle.RelationKeyFileMimeType, + "size": bundle.RelationKeySizeInBytes, +} + +// activeFieldAliasesIn resolves the aliases against one primed live set: +// an alias is active only when no LIVE property claims its spelling — +// through the same chain everything else resolves, so a stored key OR a +// slug claims it, and a UI-deleted (uninstalled) relation no longer +// deactivates the alias space-wide (the review's corpse-blind finding: +// one uninstalled mimeType relation silently dropped the field from every +// file row and filter). The decision is per SPACE, never per row. +func (s *Service) activeFieldAliasesIn(entries []propertyEntry) map[string]domain.RelationKey { + active := make(map[string]domain.RelationKey, len(v2FieldAliases)) + for alias, backing := range v2FieldAliases { + if entry, ok, ambiguous := s.resolvePropertyInput(alias, entries); len(ambiguous) > 0 || (ok && entry.Id != "") { + continue // a real live property claims the spelling + } + active[alias] = backing + } + return active +} + +// activeFieldAliases is the load-owning form; on a store error no alias is +// served (conservative — the real property, if any, must win). +func (s *Service) activeFieldAliases(spaceId string) map[string]domain.RelationKey { + entries, err := s.liveProperties(spaceId) + if err != nil { + return nil + } + return s.activeFieldAliasesIn(entries) +} + +// objectRowBuilder assembles C5 minimal rows (id, name, type + requested +// property values) for one space, caching the type-key map and the property +// resolvers across rows. Shared by the object list, the query surface and +// the set/collection reads. +type objectRowBuilder struct { + index spaceindex.Store + typeKeys map[string]string + fields []string + opts anyblockjson.Options + spaceId string // the store-facing full id + // spaceRef is what a row's space_id FIELD carries when includeSpaceId + // (global search): the §8.35 short reference by default, the full id + // when its tail collides with another visible space's. Defaults to + // spaceId so a builder constructed without a census still serves a + // working value. + spaceRef string + // aliases is the builder's per-space alias resolution, computed ONCE at + // construction (activeFieldAliases) — never per row + aliases map[string]domain.RelationKey + + includeSpaceId bool +} + +func (s *Service) newObjectRowBuilder(spaceId string, fields []string) (*objectRowBuilder, error) { + typeKeys, err := s.typeKeysById(spaceId) + if err != nil { + return nil, err + } + index := s.store.SpaceIndex(spaceId) + b := &objectRowBuilder{index: index, typeKeys: typeKeys, fields: fields, spaceId: spaceId, spaceRef: spaceId} + if len(fields) > 0 { + b.opts = storeresolver.New(index).Options() + // requested fields canonicalize through the one chain (file aliases + // + §7.5a-5): the value is read from the STORED key and emitted + // under the REQUESTED spelling — the listing's slug spelling works + // in fields= exactly as advertised (review cause 3) + kc, err := s.newKeyCanon(spaceId) + if err != nil { + return nil, err + } + for _, field := range fields { + if canonical, ambiguous := kc.canon(field); len(ambiguous) == 0 && canonical != field { + if b.aliases == nil { + b.aliases = map[string]domain.RelationKey{} + } + b.aliases[field] = domain.RelationKey(canonical) + } + } + } + return b, nil +} + +func (b *objectRowBuilder) row(record database.Record) v2model.ObjectRow { + typeId := record.Details.GetString(bundle.RelationKeyType) + typeKey, cached := b.typeKeys[typeId] + if !cached && typeId != "" { + // the bulk map misses edge type ids (hidden/bundled); resolve the + // one type object directly so no row carries an empty type (C5). + if det, err := b.index.GetDetails(typeId); err == nil { + if k, err := domain.GetTypeKeyFromRawUniqueKey(det.GetString(bundle.RelationKeyUniqueKey)); err == nil { + typeKey = string(k) + } + } + b.typeKeys[typeId] = typeKey // memoize (including "" to avoid re-querying) + } + row := v2model.ObjectRow{ + Id: record.Details.GetString(bundle.RelationKeyId), + Name: record.Details.GetString(bundle.RelationKeyName), + Type: typeKey, + } + if b.includeSpaceId { + row.SpaceId = b.spaceRef + } + if len(b.fields) > 0 { + values := map[string]any{} + proto := record.Details.ToProto() + for _, key := range b.fields { + // MarshalPropertyValue also returns this key's option-id legend + // (name -> stored option id). A ROW is not a document: it has no + // envelope to hang a legend on, and select values have always been + // served here as bare names. Dropping it knowingly keeps that + // contract; serving option ids on rows is a surface decision, not + // a consequence of the format change. + if v, ok := proto.Fields[key]; ok { + values[key], _ = anyblockjson.MarshalPropertyValue(key, v, b.opts) + continue + } + // the file aliases (Phase 7): read the backing store relation, + // emit under the requested name. b.aliases resolved per SPACE at + // construction — a real property keyed mimeType/size deactivates + // the alias for every row, never per record + if backing, ok := b.aliases[key]; ok { + if v, ok := proto.Fields[string(backing)]; ok { + values[key], _ = anyblockjson.MarshalPropertyValue(string(backing), v, b.opts) + } + } + } + if len(values) > 0 { + row.Properties = values + } + } + return row +} + +// typeKeysById maps type object ids to type keys — rows carry the type key +// (C2), never the type object (C5). Live types are spelled as their served +// key (the slug for a BSON-keyed type, §7.5a — the spelling the search +// type filter resolves right back); removed types (uninstalled, archived — +// or the prod corpse shape carrying isDeleted) stay in the map so their +// objects' rows keep a type, spelled by the honest internal key. +// +// The query suppresses both injected defaults DELIBERATELY (§8.41): a +// production corpse carries isDeleted, so the plain query this used to be +// never returned one and the corpse branch below was dead outside flag-only +// fixtures — production rows fell through to the per-row GetDetails fallback +// instead. Tombstoned rows ({id, isDeleted} only) still land here but carry +// no uniqueKey and are skipped; their objects' rows serve an empty type for +// the window, the only honest answer a keyless row allows. +func (s *Service) typeKeysById(spaceId string) (map[string]string, error) { + records, err := s.store.SpaceIndex(spaceId).Query(database.Query{ + Filters: []database.FilterRequest{ + { + RelationKey: bundle.RelationKeyResolvedLayout, + Condition: model.BlockContentDataviewFilter_Equal, + Value: domain.Int64(int64(model.ObjectType_objectType)), + }, + {RelationKey: bundle.RelationKeyIsArchived, Condition: model.BlockContentDataviewFilter_None}, + {RelationKey: bundle.RelationKeyIsDeleted, Condition: model.BlockContentDataviewFilter_None}, + }, + }) + if err != nil { + return nil, fmt.Errorf("query types in space %s: %w", spaceId, err) + } + liveEntries, err := s.liveTypes(spaceId) + if err != nil { + return nil, err + } + keyTaken, slugHolders := servedTypeKeySets(liveEntries) + out := make(map[string]string, len(records)) + for _, record := range records { + id := record.Details.GetString(bundle.RelationKeyId) + uniqueKey := record.Details.GetString(bundle.RelationKeyUniqueKey) + key, err := domain.GetTypeKeyFromRawUniqueKey(uniqueKey) + if err != nil { + continue + } + if corpseFlagged(record.Details) { + out[id] = string(key) // a corpse's slug vacated the namespace + continue + } + out[id] = servedTypeKeyOf(string(key), record.Details.GetString(bundle.RelationKeyApiObjectKey), keyTaken, slugHolders) + } + return out, nil +} diff --git a/core/api/v2/service/object_test.go b/core/api/v2/service/object_test.go new file mode 100644 index 0000000000..e740ab958e --- /dev/null +++ b/core/api/v2/service/object_test.go @@ -0,0 +1,735 @@ +package v2service + +import ( + "context" + "encoding/json" + "net/http" + "strings" + "testing" + + "github.com/anyproto/any-sync/commonspace/object/tree/treestorage" + "github.com/gogo/protobuf/types" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + + apicore "github.com/anyproto/anytype-heart/core/api/core" + "github.com/anyproto/anytype-heart/core/api/core/mock_apicore" + v2model "github.com/anyproto/anytype-heart/core/api/v2/model" + "github.com/anyproto/anytype-heart/core/domain" + "github.com/anyproto/anytype-heart/pb" + "github.com/anyproto/anytype-heart/pkg/lib/anyblockjson" + "github.com/anyproto/anytype-heart/pkg/lib/bundle" + "github.com/anyproto/anytype-heart/pkg/lib/localstore/objectstore" + "github.com/anyproto/anytype-heart/pkg/lib/pb/model" + "github.com/anyproto/anytype-heart/util/pbtypes" +) + +const ( + testSpaceId = "space1" + // testAccountId feeds the §6.2 current-user placeholder substitution + // (Phase 4): _participant__. + testAccountId = "accountA" +) + +type v2Fixture struct { + *Service + mwMock *mock_apicore.MockClientCommands + readerMock *mock_apicore.MockObjectReader + creatorMock *mock_apicore.MockObjectCreator + mutatorMock *mock_apicore.MockObjectMutator + provenanceMock *mock_apicore.MockObjectProvenance + objectStore *objectstore.StoreFixture +} + +// newV2FixtureBare builds the service with an empty tech space (no space +// registered). Used by the ListSpaces tests, which manage the tech space's +// spaceViews themselves. +func newV2FixtureBare(t *testing.T) *v2Fixture { + mwMock := mock_apicore.NewMockClientCommands(t) + readerMock := mock_apicore.NewMockObjectReader(t) + creatorMock := mock_apicore.NewMockObjectCreator(t) + mutatorMock := mock_apicore.NewMockObjectMutator(t) + provenanceMock := mock_apicore.NewMockObjectProvenance(t) + objectStore := objectstore.NewStoreFixture(t) + // Deterministic derived-id stubs (ADDRESSING §2.4: a derived object's id + // is a pure function of space and key; the mock's function is `drv-rel-` + // / `drv-ot-` + key). The §8.41 tombstone probes derive an id and + // point-look-up its row on every bundled key that is not installed — + // which is most creates — so the stub lives in the fixture; a tombstone + // test materializes a store row AT the derived id. NOTE testify matches + // the FIRST registered expectation, so a test needing a different answer + // cannot override these — place rows in the store instead. + creatorMock.EXPECT().RelationIdByKey(mock.Anything, mock.Anything, mock.Anything).RunAndReturn( + func(_ context.Context, _ string, key domain.RelationKey) (string, error) { + return "drv-rel-" + string(key), nil + }).Maybe() + creatorMock.EXPECT().TypeIdByKey(mock.Anything, mock.Anything, mock.Anything).RunAndReturn( + func(_ context.Context, _ string, key domain.TypeKey) (string, error) { + return "drv-ot-" + string(key), nil + }).Maybe() + return &v2Fixture{ + Service: NewService(mwMock, readerMock, creatorMock, mutatorMock, provenanceMock, objectStore, objectstore.TestTechSpaceId, testAccountId), + mwMock: mwMock, + readerMock: readerMock, + creatorMock: creatorMock, + mutatorMock: mutatorMock, + provenanceMock: provenanceMock, + objectStore: objectStore, + } +} + +// newV2Fixture builds the service with the default test space registered, so +// the C2 ensureSpace guard resolves testSpaceId as a real space. +func newV2Fixture(t *testing.T) *v2Fixture { + fx := newV2FixtureBare(t) + fx.registerSpace(t, testSpaceId) + return fx +} + +// registerSpace adds a spaceView for spaceId to the tech space so +// ensureSpace (via GetSpaceViewDetails) resolves it. +func (fx *v2Fixture) registerSpace(t *testing.T, spaceId string) { + fx.objectStore.AddObjects(t, objectstore.TestTechSpaceId, []objectstore.TestObject{ + { + bundle.RelationKeyId: domain.String("spaceView_" + spaceId), + bundle.RelationKeyResolvedLayout: domain.Int64(int64(model.ObjectType_spaceView)), + bundle.RelationKeyTargetSpaceId: domain.String(spaceId), + }, + }) +} + +func textContent(text string, style model.BlockContentTextStyle) *model.BlockContentOfText { + return &model.BlockContentOfText{Text: &model.BlockContentText{Text: text, Style: style}} +} + +// testObjectRead builds a live read of a small document: +// heading "Section", paragraph "parent" with child "child", then "sibling". +func testObjectRead() apicore.ObjectRead { + return apicore.ObjectRead{ + SbType: model.SmartBlockType_Page, + Snapshot: &model.SmartBlockSnapshotBase{ + Details: &types.Struct{Fields: map[string]*types.Value{ + "id": pbtypes.String("obj1"), + "name": pbtypes.String("Doc"), + }}, + ObjectTypes: []string{"ot-page"}, + Blocks: []*model.Block{ + {Id: "obj1", ChildrenIds: []string{"h1", "p1", "p3"}, + Content: &model.BlockContentOfSmartblock{Smartblock: &model.BlockContentSmartblock{}}}, + {Id: "h1", Content: textContent("Section", model.BlockContentText_Header1)}, + {Id: "p1", ChildrenIds: []string{"p2"}, Content: textContent("parent", model.BlockContentText_Paragraph)}, + {Id: "p2", Content: textContent("child", model.BlockContentText_Paragraph)}, + {Id: "p3", Content: textContent("sibling", model.BlockContentText_Paragraph)}, + }, + }, + Heads: []string{"headB", "headA"}, + } +} + +// Minted-shape (24-hex) block ids for the relabeling fixtures — only +// machine-minted opaque ids relabel, so a fixture that wants a real (not +// identity) relabel operation must mint like the editor does. +const ( + testMintedHeadingId = "0000000000000000000aaaa1" // label "aaaa1" + testMintedParentId = "0000000000000000000bbbb1" // label "bbbb1" + testMintedChildId = "0000000000000000000cccc1" // label "cccc1" + testMintedSiblingId = "0000000000000000000dddd1" // label "dddd1" + testMintedLinkId = "0000000000000000000eeee1" // label "eeee1" +) + +// testObjectReadLongIds mirrors testObjectRead but with minted-shape block +// ids, so relabeling is a real (not identity) operation — the case the +// short-id fixtures cannot exercise (M1). +func testObjectReadLongIds() apicore.ObjectRead { + return apicore.ObjectRead{ + SbType: model.SmartBlockType_Page, + Snapshot: &model.SmartBlockSnapshotBase{ + Details: &types.Struct{Fields: map[string]*types.Value{ + "id": pbtypes.String("obj1"), + "name": pbtypes.String("Doc"), + }}, + ObjectTypes: []string{"ot-page"}, + Blocks: []*model.Block{ + {Id: "obj1", ChildrenIds: []string{testMintedHeadingId, testMintedParentId, testMintedSiblingId}, + Content: &model.BlockContentOfSmartblock{Smartblock: &model.BlockContentSmartblock{}}}, + {Id: testMintedHeadingId, Content: textContent("Section", model.BlockContentText_Header1)}, + {Id: testMintedParentId, ChildrenIds: []string{testMintedChildId}, Content: textContent("parent", model.BlockContentText_Paragraph)}, + {Id: testMintedChildId, Content: textContent("child", model.BlockContentText_Paragraph)}, + {Id: testMintedSiblingId, Content: textContent("sibling", model.BlockContentText_Paragraph)}, + }, + }, + Heads: []string{"headB", "headA"}, + } +} + +func TestV2OutlineBlockRoundTrip(t *testing.T) { + // M1: a compact outline label must resolve in a follow-up ?block= read — + // the core outline-then-fetch flow on a large document. + fx := newV2Fixture(t) + fx.readerMock.EXPECT().ReadObject(mock.Anything, testSpaceId, "obj1").Return(testObjectReadLongIds(), nil).Times(2) + + // outline read yields compact block labels (not the full ids) + outlineBody, _, err := fx.GetObject(context.Background(), testSpaceId, "obj1", ObjectQuery{Outline: true}) + require.NoError(t, err) + entries := decodeBody(t, outlineBody)["outline"].([]any) + require.Len(t, entries, 4) + label := entries[1].(map[string]any)["id"].(string) // the parent paragraph + require.NotEqual(t, testMintedParentId, label, "outline must emit a compact label, not the full id") + require.True(t, strings.HasSuffix(testMintedParentId, label), "label is the id suffix") + + // the label round-trips: ?block=